542 lines
24 KiB
Swift
542 lines
24 KiB
Swift
import SwiftUI
|
|
import MacToolsPluginKit
|
|
|
|
/// Launcher settings. Host renders the page header; this view starts at the first
|
|
/// group (per the settings spec). Bindings write straight through to the persisted
|
|
/// `LaunchpadPreferences`.
|
|
struct LaunchpadSettingsView: View {
|
|
@ObservedObject var preferences: LaunchpadPreferences
|
|
/// Observed so the sorting group appears / disappears as the custom layout is created or reset.
|
|
@ObservedObject var layoutStore: LaunchpadLayoutStore
|
|
let localization: PluginLocalization
|
|
|
|
private var isAutoColumns: Binding<Bool> {
|
|
Binding(
|
|
get: { preferences.columns == LaunchpadPreferences.autoColumns },
|
|
set: { preferences.columns = $0 ? LaunchpadPreferences.autoColumns : 7 }
|
|
)
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.section) {
|
|
windowSection
|
|
appearanceSection
|
|
backgroundSection
|
|
gridSection
|
|
sortSection
|
|
hiddenSection
|
|
}
|
|
}
|
|
|
|
/// Only shown once the user has dragged into a custom order (`layout != nil`); resetting
|
|
/// removes the layout key → back to alphabetical, and the group disappears.
|
|
@ViewBuilder
|
|
private var sortSection: some View {
|
|
if layoutStore.layout != nil {
|
|
section(
|
|
title: localization.string("settings.sorting.title", defaultValue: "排序"),
|
|
icon: "arrow.up.arrow.down"
|
|
) {
|
|
row(
|
|
title: localization.string("settings.sorting.customOrder.title", defaultValue: "自定义排序"),
|
|
description: localization.string(
|
|
"settings.sorting.customOrder.description",
|
|
defaultValue: "已手动调整过应用顺序"
|
|
)
|
|
) {
|
|
Button(localization.string("settings.sorting.restoreAlphabetical", defaultValue: "恢复字母序")) {
|
|
layoutStore.resetToAlphabetical()
|
|
}
|
|
.controlSize(.small)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var sortedHiddenIDs: [String] {
|
|
preferences.hiddenAppIDs.sorted {
|
|
appName(for: $0).localizedCaseInsensitiveCompare(appName(for: $1)) == .orderedAscending
|
|
}
|
|
}
|
|
|
|
/// Hidden ids are absolute paths; derive a display name without needing the catalog
|
|
/// (the app may even be uninstalled).
|
|
private func appName(for id: String) -> String {
|
|
URL(fileURLWithPath: id).deletingPathExtension().lastPathComponent
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var hiddenSection: some View {
|
|
if !preferences.hiddenAppIDs.isEmpty {
|
|
let ids = sortedHiddenIDs
|
|
section(title: localization.string("settings.hiddenApps.title", defaultValue: "隐藏的应用"), icon: "eye.slash") {
|
|
VStack(spacing: PluginSettingsTheme.Spacing.rowVertical) {
|
|
ForEach(ids, id: \.self) { id in
|
|
HStack(spacing: PluginSettingsTheme.Spacing.rowContentControl) {
|
|
Text(appName(for: id))
|
|
.font(PluginSettingsTheme.Typography.rowTitle)
|
|
.lineLimit(1)
|
|
.truncationMode(.middle)
|
|
Spacer(minLength: PluginSettingsTheme.Spacing.rowContentControl)
|
|
Button(localization.string("settings.hiddenApps.restore", defaultValue: "恢复")) {
|
|
preferences.unhide(id)
|
|
}
|
|
.controlSize(.small)
|
|
}
|
|
if id != ids.last { Divider() }
|
|
}
|
|
Divider()
|
|
HStack {
|
|
Spacer()
|
|
Button(localization.string("settings.hiddenApps.restoreAll", defaultValue: "全部恢复")) {
|
|
preferences.unhideAll()
|
|
}
|
|
.controlSize(.small)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var windowSection: some View {
|
|
section(title: localization.string("settings.window.title", defaultValue: "窗口"), icon: "macwindow") {
|
|
VStack(spacing: PluginSettingsTheme.Spacing.rowVertical) {
|
|
row(
|
|
title: localization.string("settings.window.mode.title", defaultValue: "唤出方式"),
|
|
description: preferences.windowMode == .fullscreen
|
|
? localization.string(
|
|
"settings.window.mode.fullscreenDescription",
|
|
defaultValue: "铺满当前屏幕,点击空白处关闭"
|
|
)
|
|
: localization.string(
|
|
"settings.window.mode.compactDescription",
|
|
defaultValue: "屏幕中央的浮窗,点击窗外关闭"
|
|
)
|
|
) {
|
|
Picker("", selection: $preferences.windowMode) {
|
|
ForEach(LaunchpadPreferences.WindowMode.allCases) { mode in
|
|
Text(mode.label(localization: localization)).tag(mode)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.labelsHidden()
|
|
// Intrinsic width (see the glass-style picker below) so the
|
|
// 2-segment control never overflows its card on either language.
|
|
.fixedSize()
|
|
}
|
|
Divider()
|
|
row(
|
|
title: localization.string("settings.hotCorner.title", defaultValue: "热区唤起"),
|
|
description: localization.string(
|
|
"settings.hotCorner.description",
|
|
defaultValue: "光标停在所选屏幕角落即唤出启动台"
|
|
)
|
|
) {
|
|
Picker("", selection: $preferences.hotCorner) {
|
|
ForEach(LaunchpadPreferences.HotCorner.allCases) { corner in
|
|
Text(corner.label(localization: localization)).tag(corner)
|
|
}
|
|
}
|
|
.labelsHidden()
|
|
.frame(width: 110)
|
|
}
|
|
// Window-size slider (ruling A6): only meaningful in compact mode, so it
|
|
// shows conditionally — same expand pattern as the fixed-columns stepper.
|
|
if preferences.windowMode == .compact {
|
|
Divider()
|
|
row(
|
|
title: localization.string("settings.window.size.title", defaultValue: "窗口大小"),
|
|
description: localization.string(
|
|
"settings.window.size.description",
|
|
defaultValue: "紧凑窗口占屏幕的比例"
|
|
)
|
|
) {
|
|
HStack(spacing: PluginSettingsTheme.Spacing.rowContentControl) {
|
|
Slider(
|
|
value: compactScaleBinding,
|
|
in: Double(LaunchpadPreferences.minCompactScale)
|
|
... Double(LaunchpadPreferences.maxCompactScale),
|
|
step: 1
|
|
)
|
|
.frame(minWidth: 120, idealWidth: 160, maxWidth: 200)
|
|
Text(
|
|
localization.format(
|
|
"settings.window.size.value",
|
|
defaultValue: "%d%%",
|
|
preferences.compactScalePercent
|
|
)
|
|
)
|
|
.font(PluginSettingsTheme.Typography.monospacedValue)
|
|
.frame(width: 44, alignment: .trailing)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Appearance (design §3.2)
|
|
|
|
private var showsAppNamesBinding: Binding<Bool> {
|
|
Binding(
|
|
get: { !preferences.hidesAppNames },
|
|
set: { preferences.hidesAppNames = !$0 }
|
|
)
|
|
}
|
|
|
|
private var iconSizeBinding: Binding<Double> {
|
|
Binding(
|
|
get: { Double(preferences.iconSize) },
|
|
set: { preferences.iconSize = Int($0.rounded()) }
|
|
)
|
|
}
|
|
|
|
private var compactScaleBinding: Binding<Double> {
|
|
Binding(
|
|
get: { Double(preferences.compactScalePercent) },
|
|
set: { preferences.compactScalePercent = Int($0.rounded()) }
|
|
)
|
|
}
|
|
|
|
/// Appearance group (features 7+8+9). Changes take effect the next time the launcher
|
|
/// is summoned (the overlay snapshots the resolved metrics at `open()`); the layout
|
|
/// preview in the first row tracks every published preference live — pure math, no IO.
|
|
private var appearanceSection: some View {
|
|
section(
|
|
title: localization.string("settings.appearance.title", defaultValue: "外观"),
|
|
icon: "paintbrush"
|
|
) {
|
|
VStack(spacing: PluginSettingsTheme.Spacing.rowVertical) {
|
|
LaunchpadLayoutPreviewView(preferences: preferences, localization: localization)
|
|
Divider()
|
|
row(
|
|
title: localization.string(
|
|
"settings.appearance.showNames.title",
|
|
defaultValue: "显示应用名称"
|
|
),
|
|
description: localization.string(
|
|
"settings.appearance.showNames.description",
|
|
defaultValue: "关闭后仅显示图标,排列更紧凑"
|
|
)
|
|
) {
|
|
Toggle("", isOn: showsAppNamesBinding)
|
|
.toggleStyle(.switch)
|
|
.labelsHidden()
|
|
}
|
|
Divider()
|
|
row(
|
|
title: localization.string(
|
|
"settings.appearance.iconSize.title",
|
|
defaultValue: "图标大小"
|
|
),
|
|
description: localization.string(
|
|
"settings.appearance.iconSize.description",
|
|
defaultValue: "每页行列数随之变化"
|
|
)
|
|
) {
|
|
HStack(spacing: PluginSettingsTheme.Spacing.rowContentControl) {
|
|
Slider(
|
|
value: iconSizeBinding,
|
|
in: Double(LaunchpadPreferences.minIconSize)
|
|
... Double(LaunchpadPreferences.maxIconSize),
|
|
step: Double(LaunchpadPreferences.iconSizeStep)
|
|
)
|
|
.frame(minWidth: 160, idealWidth: 200, maxWidth: 240)
|
|
Text(
|
|
localization.format(
|
|
"settings.appearance.iconSize.value",
|
|
defaultValue: "%d pt",
|
|
preferences.iconSize
|
|
)
|
|
)
|
|
.font(PluginSettingsTheme.Typography.monospacedValue)
|
|
.frame(width: 52, alignment: .trailing)
|
|
}
|
|
}
|
|
Divider()
|
|
row(
|
|
title: localization.string(
|
|
"settings.appearance.labelSize.title",
|
|
defaultValue: "标签字号"
|
|
),
|
|
description: localization.string(
|
|
"settings.appearance.labelSize.description",
|
|
defaultValue: "与图标大小协调缩放"
|
|
)
|
|
) {
|
|
Picker("", selection: $preferences.labelSize) {
|
|
ForEach(LaunchpadLabelSize.allCases) { size in
|
|
Text(size.label(localization: localization)).tag(size)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.labelsHidden()
|
|
// Intrinsic width; see the background-style picker note — a fixed frame
|
|
// lets NSSegmentedControl overdraw past the card on wider languages.
|
|
.fixedSize()
|
|
}
|
|
Divider()
|
|
row(
|
|
title: localization.string(
|
|
"settings.appearance.labelWeight.title",
|
|
defaultValue: "标签字重"
|
|
),
|
|
description: localization.string(
|
|
"settings.appearance.labelWeight.description",
|
|
defaultValue: "应用名称与文件夹标题共用"
|
|
)
|
|
) {
|
|
Picker("", selection: $preferences.labelWeight) {
|
|
ForEach(LaunchpadLabelWeight.allCases) { weight in
|
|
Text(weight.label(localization: localization)).tag(weight)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.labelsHidden()
|
|
.fixedSize()
|
|
}
|
|
Divider()
|
|
row(
|
|
title: localization.string(
|
|
"settings.appearance.labelColor.title",
|
|
defaultValue: "标签颜色"
|
|
),
|
|
description: localization.string(
|
|
"settings.appearance.labelColor.description",
|
|
defaultValue: "深色背景下可选浅色更清晰"
|
|
)
|
|
) {
|
|
Picker("", selection: $preferences.labelColor) {
|
|
ForEach(LaunchpadLabelColor.allCases) { color in
|
|
Text(color.label(localization: localization)).tag(color)
|
|
}
|
|
}
|
|
.pickerStyle(.menu)
|
|
.labelsHidden()
|
|
.frame(width: 110)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Background (design §5.3)
|
|
|
|
private var dimPercentBinding: Binding<Double> {
|
|
Binding(
|
|
get: { Double(preferences.backgroundDimPercent) },
|
|
set: { preferences.backgroundDimPercent = Int($0.rounded()) }
|
|
)
|
|
}
|
|
|
|
private var backgroundStyleDescription: String {
|
|
switch preferences.backgroundStyle {
|
|
case .clear:
|
|
localization.string(
|
|
"settings.background.style.clearDescription",
|
|
defaultValue: "更通透,桌面清晰可见"
|
|
)
|
|
case .standard:
|
|
localization.string(
|
|
"settings.background.style.standardDescription",
|
|
defaultValue: "默认观感,均衡的玻璃质感"
|
|
)
|
|
case .deep:
|
|
localization.string(
|
|
"settings.background.style.deepDescription",
|
|
defaultValue: "更暗更沉浸,聚焦图标"
|
|
)
|
|
case .custom:
|
|
localization.string(
|
|
"settings.background.style.customDescription",
|
|
defaultValue: "自选材质与暗化程度"
|
|
)
|
|
}
|
|
}
|
|
|
|
private var backgroundSection: some View {
|
|
section(
|
|
title: localization.string("settings.background.title", defaultValue: "背景"),
|
|
icon: "circle.lefthalf.filled"
|
|
) {
|
|
VStack(spacing: PluginSettingsTheme.Spacing.rowVertical) {
|
|
backgroundPreviewCard
|
|
Divider()
|
|
row(
|
|
title: localization.string("settings.background.style.title", defaultValue: "玻璃风格"),
|
|
description: backgroundStyleDescription
|
|
) {
|
|
Picker("", selection: $preferences.backgroundStyle) {
|
|
ForEach(LaunchpadBackgroundStyle.allCases) { style in
|
|
Text(style.label(localization: localization)).tag(style)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.labelsHidden()
|
|
// Take the control's intrinsic width instead of a hardcoded
|
|
// value: a 4-segment control's intrinsic minimum (~250pt) is
|
|
// wider than the old 224, so NSSegmentedControl overdrew past
|
|
// the frame and spilled out of the card. fixedSize adapts to
|
|
// the label language (zh/en) and the row's trailing Spacer
|
|
// keeps it inside the card.
|
|
.fixedSize()
|
|
}
|
|
if preferences.backgroundStyle == .custom {
|
|
Divider()
|
|
row(
|
|
title: localization.string("settings.background.material.title", defaultValue: "玻璃材质"),
|
|
description: localization.string(
|
|
"settings.background.material.description",
|
|
defaultValue: "背景玻璃的取样材质"
|
|
)
|
|
) {
|
|
Picker("", selection: $preferences.backgroundMaterial) {
|
|
ForEach(LaunchpadGlassMaterial.allCases) { material in
|
|
Text(material.label(localization: localization)).tag(material)
|
|
}
|
|
}
|
|
.labelsHidden()
|
|
.frame(width: 110)
|
|
}
|
|
Divider()
|
|
row(
|
|
title: localization.string("settings.background.dim.title", defaultValue: "背景暗化"),
|
|
description: localization.string(
|
|
"settings.background.dim.description",
|
|
defaultValue: "数值越大背景越暗"
|
|
)
|
|
) {
|
|
HStack(spacing: PluginSettingsTheme.Spacing.rowContentControl) {
|
|
Slider(
|
|
value: dimPercentBinding,
|
|
in: Double(LaunchpadBackgroundDim.percentRange.lowerBound)
|
|
... Double(LaunchpadBackgroundDim.percentRange.upperBound),
|
|
step: 1
|
|
)
|
|
.frame(minWidth: 120, idealWidth: 160, maxWidth: 200)
|
|
Text(
|
|
localization.format(
|
|
"settings.background.dim.value",
|
|
defaultValue: "%d%%",
|
|
preferences.backgroundDimPercent
|
|
)
|
|
)
|
|
.font(PluginSettingsTheme.Typography.monospacedValue)
|
|
.frame(width: 44, alignment: .trailing)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Inline glass preview (G6): the settings window taking key always closes the overlay,
|
|
/// so tuning would otherwise be blind. A desktop-ish gradient underlay + the SAME recipe
|
|
/// glass (within-window, so it samples the gradient) + the dim layer — live, since it
|
|
/// binds the published preferences directly. Gradient and recipe fill are shared with
|
|
/// the layout preview's canvas (`LaunchpadDesktopMockGradient` / `LaunchpadRecipeGlassFill`
|
|
/// in LaunchpadGlassBackdrop.swift) — one implementation, per ruling G6.
|
|
private var backgroundPreviewCard: some View {
|
|
ZStack {
|
|
LaunchpadDesktopMockGradient()
|
|
// Soft shapes give the blur something legible to chew on.
|
|
Circle()
|
|
.fill(.white.opacity(0.55))
|
|
.frame(width: 46, height: 46)
|
|
.offset(x: -64, y: -10)
|
|
Circle()
|
|
.fill(Color(red: 0.18, green: 0.65, blue: 0.45).opacity(0.8))
|
|
.frame(width: 34, height: 34)
|
|
.offset(x: 52, y: 16)
|
|
LaunchpadRecipeGlassFill(recipe: preferences.backgroundRecipe)
|
|
}
|
|
.frame(width: 220, height: 80)
|
|
.clipShape(RoundedRectangle(cornerRadius: PluginSettingsTheme.Radius.card, style: .continuous))
|
|
.frame(maxWidth: .infinity)
|
|
.accessibilityElement(children: .ignore)
|
|
.accessibilityLabel(
|
|
localization.string(
|
|
"settings.background.preview.accessibility",
|
|
defaultValue: "背景玻璃效果预览"
|
|
)
|
|
)
|
|
}
|
|
|
|
private var gridSection: some View {
|
|
section(title: localization.string("settings.grid.title", defaultValue: "网格"), icon: "square.grid.3x3") {
|
|
VStack(spacing: PluginSettingsTheme.Spacing.rowVertical) {
|
|
row(
|
|
title: localization.string("settings.grid.autoColumns.title", defaultValue: "自动列数"),
|
|
description: localization.string(
|
|
"settings.grid.autoColumns.description",
|
|
defaultValue: "按窗口宽度自动排布"
|
|
)
|
|
) {
|
|
Toggle("", isOn: isAutoColumns)
|
|
.toggleStyle(.switch)
|
|
.labelsHidden()
|
|
}
|
|
if preferences.columns != LaunchpadPreferences.autoColumns {
|
|
Divider()
|
|
row(
|
|
title: localization.string("settings.grid.columns.title", defaultValue: "每行图标"),
|
|
description: localization.string(
|
|
"settings.grid.columns.description",
|
|
defaultValue: "固定每行的应用数量"
|
|
)
|
|
) {
|
|
Stepper(
|
|
value: $preferences.columns,
|
|
in: LaunchpadPreferences.minColumns...LaunchpadPreferences.maxColumns
|
|
) {
|
|
Text(
|
|
localization.format(
|
|
"settings.grid.columns.value",
|
|
defaultValue: "%d 个",
|
|
preferences.columns
|
|
)
|
|
)
|
|
.font(PluginSettingsTheme.Typography.monospacedValue)
|
|
.frame(width: 40, alignment: .trailing)
|
|
}
|
|
.fixedSize()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Building blocks
|
|
|
|
private func section<Content: View>(
|
|
title: String,
|
|
icon: String,
|
|
@ViewBuilder content: () -> Content
|
|
) -> some View {
|
|
VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) {
|
|
Label(title, systemImage: icon)
|
|
.font(PluginSettingsTheme.Typography.sectionTitle)
|
|
.foregroundStyle(.secondary)
|
|
content()
|
|
.pluginSettingsListRowPadding()
|
|
.pluginSettingsCardBackground(.host)
|
|
}
|
|
}
|
|
|
|
private func row<Control: View>(
|
|
title: String,
|
|
description: String,
|
|
@ViewBuilder control: () -> Control
|
|
) -> some View {
|
|
HStack(spacing: PluginSettingsTheme.Spacing.rowContentControl) {
|
|
VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.rowTitleDescription) {
|
|
Text(title).font(PluginSettingsTheme.Typography.rowTitle)
|
|
Text(description)
|
|
.font(PluginSettingsTheme.Typography.rowDescription)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Spacer(minLength: PluginSettingsTheme.Spacing.rowContentControl)
|
|
control()
|
|
}
|
|
}
|
|
}
|