| | |
| | | private enum SettingsTab: String, CaseIterable, Identifiable { |
| | | case language |
| | | case general |
| | | case theme |
| | | case hotkeys |
| | | case tags |
| | | case data |
| | |
| | | switch self { |
| | | case .language: return "settings.language" |
| | | case .general: return "settings.general" |
| | | case .theme: return "settings.theme" |
| | | case .hotkeys: return "quickSearch.hotkeys" |
| | | case .tags: return "settings.tags" |
| | | case .data: return "settings.data" |
| | |
| | | switch self { |
| | | case .language: return "globe" |
| | | case .general: return "gearshape" |
| | | case .theme: return "paintpalette.fill" |
| | | case .hotkeys: return "keyboard" |
| | | case .tags: return "tag.fill" |
| | | case .data: return "externaldrive.fill" |
| | |
| | | private let generalLabelWidth: CGFloat = 190 |
| | | private let generalControlWidth: CGFloat = 500 |
| | | private let compactPickerWidth: CGFloat = 320 |
| | | private let dataPanelWidth: CGFloat = 760 |
| | | private let dataLabelWidth: CGFloat = 220 |
| | | private let dataActionWidth: CGFloat = 112 |
| | | private let dataPanelWidth: CGFloat = 820 |
| | | private let dataLabelWidth: CGFloat = 190 |
| | | private let dataActionWidth: CGFloat = 128 |
| | | |
| | | @AppStorage("tagFontSize") private var tagFontSize: Double = AppDefaults.tagFontSize |
| | | @AppStorage("iconSize") private var iconSize: Double = AppDefaults.iconSize |
| | |
| | | @AppStorage("showDockIcon") private var showDockIcon = AppDefaults.showDockIcon |
| | | @AppStorage("launchAtLogin") private var launchAtLogin = AppDefaults.launchAtLogin |
| | | @AppStorage("showUncommonAppBubbles") private var showUncommonAppBubbles = AppDefaults.showUncommonAppBubbles |
| | | @AppStorage("hideUsageTips") private var hideUsageTips = AppDefaults.hideUsageTips |
| | | @AppStorage(AppGridTheme.storageKey) private var appGridThemeID = AppDefaults.appGridThemeID |
| | | @AppStorage("mainHotkeyRegistrationState") private var mainHotkeyRegistrationState = LauncherHotkeyRegistrationState.active.rawValue |
| | | @AppStorage("quickSearchHotkeyRegistrationState") private var quickSearchHotkeyRegistrationState = LauncherHotkeyRegistrationState.active.rawValue |
| | | @State private var selectedLanguage = L10n.selectedLanguageCode |
| | |
| | | @State private var hotkeyStatusToastToken: UUID? = nil |
| | | @State private var selectedTab: SettingsTab = .general |
| | | @State private var selectedInitialLayoutMode: InitialLayoutMode = .smart |
| | | |
| | | init(initialTabRawValue: String? = nil) { |
| | | let initialTab = initialTabRawValue.flatMap(SettingsTab.init(rawValue:)) ?? .general |
| | | _selectedTab = State(initialValue: initialTab) |
| | | } |
| | | |
| | | private func selectTab(rawValue: String?) { |
| | | guard let rawValue, let tab = SettingsTab(rawValue: rawValue) else { return } |
| | | selectedTab = tab |
| | | } |
| | | |
| | | private func scanApps() { |
| | | DispatchQueue.global(qos: .userInitiated).async { |
| | |
| | | .buttonStyle(.plain) |
| | | } |
| | | |
| | | private func dataSectionTitle(_ title: String) -> some View { |
| | | Text(title) |
| | | .font(.system(size: 13, weight: .semibold)) |
| | | .foregroundStyle(.primary) |
| | | .lineLimit(1) |
| | | .minimumScaleFactor(0.82) |
| | | .multilineTextAlignment(.trailing) |
| | | .frame(width: dataLabelWidth, alignment: .trailing) |
| | | } |
| | | |
| | | private func dataSection<Content: View>( |
| | | minHeight: CGFloat, |
| | | @ViewBuilder content: () -> Content |
| | | ) -> some View { |
| | | content() |
| | | .padding(.horizontal, 20) |
| | | .padding(.vertical, 18) |
| | | .frame(width: dataPanelWidth, alignment: .leading) |
| | | .frame(minHeight: minHeight, alignment: .leading) |
| | | .background( |
| | | RoundedRectangle(cornerRadius: 12, style: .continuous) |
| | | .fill(Color(nsColor: .controlBackgroundColor)) |
| | | ) |
| | | .overlay( |
| | | RoundedRectangle(cornerRadius: 12, style: .continuous) |
| | | .stroke(Color.secondary.opacity(0.18), lineWidth: 1) |
| | | ) |
| | | } |
| | | |
| | | private func syncSelectedLanguage() { |
| | | let code = L10n.selectedLanguageCode |
| | | guard selectedLanguage != code else { return } |
| | |
| | | GridItem(.flexible(minimum: 220), spacing: 10, alignment: .top), |
| | | ] |
| | | } |
| | | private var themeColumns: [GridItem] { |
| | | [ |
| | | GridItem(.flexible(minimum: 220), spacing: 12, alignment: .top), |
| | | GridItem(.flexible(minimum: 220), spacing: 12, alignment: .top), |
| | | ] |
| | | } |
| | | private var displayModeOptions: [(id: String, title: String)] { |
| | | [ |
| | | ("flat", tr("settings.flat")), |
| | |
| | | } |
| | | private var containerDisplayModeOptions: [(id: String, title: String)] { |
| | | Array(displayModeOptions.dropFirst()) |
| | | } |
| | | private var selectedAppGridTheme: AppGridTheme { |
| | | AppGridTheme(storedID: appGridThemeID) |
| | | } |
| | | |
| | | private func generalSettingRow<Control: View>( |
| | |
| | | AppDelegate.refreshChromeSettings() |
| | | } |
| | | Toggle(tr("settings.hideAppNames"), isOn: $hideAppNames) |
| | | Toggle(tr("settings.hideUsageTips"), isOn: $hideUsageTips) |
| | | .help(tr("settings.hideUsageTipsDesc")) |
| | | } |
| | | .frame(maxWidth: .infinity, alignment: .center) |
| | | .padding(.bottom, 16) |
| | | .padding(.top, 12) |
| | | .padding(.bottom, 18) |
| | | |
| | | Divider() |
| | | .padding(.bottom, 16) |
| | |
| | | } |
| | | } |
| | | .frame(maxWidth: generalContentWidth, alignment: .center) |
| | | .padding() |
| | | .padding(.horizontal) |
| | | .padding(.top, 28) |
| | | .padding(.bottom) |
| | | } |
| | | |
| | | case .theme: |
| | | ScrollView(.vertical, showsIndicators: true) { |
| | | VStack(alignment: .leading, spacing: 18) { |
| | | VStack(alignment: .leading, spacing: 6) { |
| | | Text(tr("settings.theme")) |
| | | .font(.headline) |
| | | Text(tr("settings.themeDesc")) |
| | | .font(.caption) |
| | | .foregroundStyle(.secondary) |
| | | } |
| | | |
| | | LazyVGrid(columns: themeColumns, alignment: .leading, spacing: 12) { |
| | | ForEach(AppGridTheme.allCases) { theme in |
| | | ThemeOptionButton( |
| | | theme: theme, |
| | | title: tr(theme.titleKey), |
| | | isSelected: selectedAppGridTheme == theme |
| | | ) { |
| | | appGridThemeID = theme.rawValue |
| | | } |
| | | } |
| | | } |
| | | |
| | | Text(tr("settings.themeScopeDesc")) |
| | | .font(.caption) |
| | | .foregroundStyle(.secondary) |
| | | .fixedSize(horizontal: false, vertical: true) |
| | | } |
| | | .frame(maxWidth: generalContentWidth, alignment: .leading) |
| | | .padding(.horizontal) |
| | | .padding(.top, 28) |
| | | .padding(.bottom) |
| | | } |
| | | |
| | | case .hotkeys: |
| | | // Tab 3: Hotkeys |
| | |
| | | case .data: |
| | | // Tab 5: Data |
| | | VStack(spacing: 0) { |
| | | Spacer(minLength: 32) |
| | | Spacer(minLength: 36) |
| | | |
| | | VStack(alignment: .center, spacing: 0) { |
| | | VStack(alignment: .leading, spacing: 12) { |
| | | VStack(alignment: .center, spacing: 24) { |
| | | dataSection(minHeight: 124) { |
| | | HStack(alignment: .center, spacing: 12) { |
| | | Text(tr("settings.initialLayout")) |
| | | .font(.system(size: 13, weight: .semibold)) |
| | | .foregroundStyle(.secondary) |
| | | .lineLimit(1) |
| | | .minimumScaleFactor(0.82) |
| | | .multilineTextAlignment(.trailing) |
| | | .frame(width: dataLabelWidth, alignment: .trailing) |
| | | dataSectionTitle(tr("settings.initialLayout")) |
| | | |
| | | HStack(spacing: 22) { |
| | | initialLayoutOption( |
| | |
| | | .frame(width: dataActionWidth, alignment: .trailing) |
| | | .layoutPriority(2) |
| | | } |
| | | } |
| | | |
| | | HStack(alignment: .center, spacing: 12) { |
| | | Text(tr("settings.previousScheme")) |
| | | .font(.system(size: 13, weight: .semibold)) |
| | | .foregroundStyle(.secondary) |
| | | .lineLimit(1) |
| | | .minimumScaleFactor(0.82) |
| | | .multilineTextAlignment(.trailing) |
| | | .frame(width: dataLabelWidth, alignment: .trailing) |
| | | Text(previousCategorySchemeName) |
| | | .font(.system(size: 13, weight: .medium)) |
| | | .foregroundStyle(canRestorePreviousScheme ? .primary : .tertiary) |
| | | .lineLimit(1) |
| | | .truncationMode(.middle) |
| | | .frame(maxWidth: .infinity, alignment: .leading) |
| | | .layoutPriority(0) |
| | | dataSection(minHeight: 190) { |
| | | HStack(alignment: .top, spacing: 12) { |
| | | dataSectionTitle(tr("settings.customLayout")) |
| | | .padding(.top, 4) |
| | | |
| | | VStack(alignment: .leading, spacing: 10) { |
| | | HStack(alignment: .firstTextBaseline, spacing: 8) { |
| | | Text(tr("settings.previousScheme")) |
| | | .font(.system(size: 13, weight: .semibold)) |
| | | .foregroundStyle(.secondary) |
| | | .lineLimit(1) |
| | | .minimumScaleFactor(0.82) |
| | | Text(previousCategorySchemeName) |
| | | .font(.system(size: 13, weight: .medium)) |
| | | .foregroundStyle(canRestorePreviousScheme ? .primary : .tertiary) |
| | | .lineLimit(1) |
| | | .truncationMode(.middle) |
| | | .frame(maxWidth: .infinity, alignment: .leading) |
| | | } |
| | | |
| | | HStack(alignment: .firstTextBaseline, spacing: 8) { |
| | | Text(tr("settings.currentScheme")) |
| | | .font(.system(size: 13, weight: .semibold)) |
| | | .foregroundStyle(.secondary) |
| | | .lineLimit(1) |
| | | .minimumScaleFactor(0.82) |
| | | Text(currentCategorySchemeName) |
| | | .font(.system(size: 13, weight: .medium)) |
| | | .foregroundStyle(.primary) |
| | | .lineLimit(1) |
| | | .truncationMode(.middle) |
| | | .frame(maxWidth: .infinity, alignment: .leading) |
| | | } |
| | | |
| | | Divider().opacity(0.35) |
| | | .padding(.top, 2) |
| | | |
| | | VStack(alignment: .center, spacing: 8) { |
| | | HStack(spacing: 12) { |
| | | Button(tr("settings.export")) { exportTags() } |
| | | .buttonStyle(.bordered) |
| | | .disabled(isDataFilePanelPresented) |
| | | Button(tr("settings.import")) { importTags() } |
| | | .buttonStyle(.bordered) |
| | | .disabled(isDataFilePanelPresented) |
| | | } |
| | | Text(tr("settings.backupDesc")) |
| | | .font(.caption) |
| | | .foregroundStyle(.secondary) |
| | | .multilineTextAlignment(.center) |
| | | .fixedSize(horizontal: false, vertical: true) |
| | | .frame(maxWidth: dataPanelWidth - dataLabelWidth - dataActionWidth - 92, alignment: .center) |
| | | } |
| | | .frame(maxWidth: .infinity, alignment: .center) |
| | | } |
| | | .frame(maxWidth: .infinity, alignment: .leading) |
| | | .layoutPriority(1) |
| | | |
| | | dataActionButton( |
| | | tr("settings.restorePreviousScheme"), |
| | |
| | | .frame(width: dataActionWidth, alignment: .trailing) |
| | | .layoutPriority(2) |
| | | } |
| | | |
| | | HStack(alignment: .firstTextBaseline, spacing: 12) { |
| | | Text(tr("settings.currentScheme")) |
| | | .font(.system(size: 13, weight: .semibold)) |
| | | .foregroundStyle(.secondary) |
| | | .lineLimit(1) |
| | | .minimumScaleFactor(0.82) |
| | | .multilineTextAlignment(.trailing) |
| | | .frame(width: dataLabelWidth, alignment: .trailing) |
| | | Text(currentCategorySchemeName) |
| | | .font(.system(size: 13, weight: .medium)) |
| | | .foregroundStyle(.primary) |
| | | .lineLimit(1) |
| | | .truncationMode(.middle) |
| | | .frame(maxWidth: .infinity, alignment: .leading) |
| | | Color.clear |
| | | .frame(width: dataActionWidth, height: 1) |
| | | } |
| | | |
| | | Divider().opacity(0.35) |
| | | |
| | | VStack(alignment: .center, spacing: 8) { |
| | | HStack(spacing: 12) { |
| | | Button(tr("settings.export")) { exportTags() } |
| | | .buttonStyle(.bordered) |
| | | .disabled(isDataFilePanelPresented) |
| | | Button(tr("settings.import")) { importTags() } |
| | | .buttonStyle(.bordered) |
| | | .disabled(isDataFilePanelPresented) |
| | | } |
| | | Text(tr("settings.backupDesc")) |
| | | .font(.caption) |
| | | .foregroundStyle(.secondary) |
| | | .multilineTextAlignment(.center) |
| | | .fixedSize(horizontal: false, vertical: true) |
| | | .frame(maxWidth: dataPanelWidth - 72, alignment: .center) |
| | | } |
| | | .frame(maxWidth: .infinity, alignment: .center) |
| | | } |
| | | .padding(.horizontal, 20) |
| | | .padding(.vertical, 16) |
| | | .frame(width: dataPanelWidth, alignment: .leading) |
| | | .background( |
| | | RoundedRectangle(cornerRadius: 8, style: .continuous) |
| | | .fill(Color(nsColor: .controlBackgroundColor)) |
| | | ) |
| | | .overlay( |
| | | RoundedRectangle(cornerRadius: 8, style: .continuous) |
| | | .stroke(Color.secondary.opacity(0.16), lineWidth: 1) |
| | | ) |
| | | } |
| | | .frame(width: dataPanelWidth, alignment: .center) |
| | | |
| | | Spacer(minLength: 22) |
| | | Spacer(minLength: 30) |
| | | |
| | | HStack(alignment: .center, spacing: 14) { |
| | | Text(tr("settings.bubbleDisplayScope")) |
| | |
| | | .onReceive(NotificationCenter.default.publisher(for: .tagLauncherHotkeyRegistrationChanged)) { _ in |
| | | showPendingHotkeyWarningIfNeeded() |
| | | } |
| | | .onReceive(NotificationCenter.default.publisher(for: .tagLauncherPreferencesTabRequested)) { notification in |
| | | selectTab(rawValue: notification.userInfo?[SettingsTabTarget.userInfoKey] as? String) |
| | | } |
| | | } |
| | | |
| | | private func showLanguageRefresh() { |
| | |
| | | } |
| | | } |
| | | |
| | | private struct ThemeOptionButton: View { |
| | | let theme: AppGridTheme |
| | | let title: String |
| | | let isSelected: Bool |
| | | let action: () -> Void |
| | | |
| | | var body: some View { |
| | | Button(action: action) { |
| | | HStack(spacing: 12) { |
| | | RoundedRectangle(cornerRadius: 8, style: .continuous) |
| | | .fill( |
| | | LinearGradient( |
| | | colors: theme.previewColors, |
| | | startPoint: .topLeading, |
| | | endPoint: .bottomTrailing |
| | | ) |
| | | ) |
| | | .frame(width: 70, height: 42) |
| | | .overlay( |
| | | RoundedRectangle(cornerRadius: 8, style: .continuous) |
| | | .stroke(previewStrokeColor, lineWidth: 1) |
| | | ) |
| | | .shadow( |
| | | color: isSelected ? Color.accentColor.opacity(0.22) : Color.clear, |
| | | radius: 10, |
| | | x: 0, |
| | | y: 4 |
| | | ) |
| | | |
| | | Text(title) |
| | | .font(.system(size: 13, weight: .semibold)) |
| | | .foregroundStyle(.primary) |
| | | .lineLimit(1) |
| | | .minimumScaleFactor(0.82) |
| | | |
| | | Spacer(minLength: 0) |
| | | |
| | | Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") |
| | | .font(.system(size: 16, weight: .semibold)) |
| | | .foregroundStyle(isSelected ? Color.accentColor : Color.secondary.opacity(0.42)) |
| | | } |
| | | .padding(10) |
| | | .frame(maxWidth: .infinity, minHeight: 64) |
| | | .background( |
| | | RoundedRectangle(cornerRadius: 8, style: .continuous) |
| | | .fill(isSelected ? Color.accentColor.opacity(0.10) : Color(nsColor: .controlBackgroundColor)) |
| | | ) |
| | | .overlay( |
| | | RoundedRectangle(cornerRadius: 8, style: .continuous) |
| | | .stroke(isSelected ? Color.accentColor.opacity(0.32) : Color.secondary.opacity(0.16), lineWidth: 1) |
| | | ) |
| | | .contentShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) |
| | | } |
| | | .buttonStyle(.plain) |
| | | .accessibilityLabel(title) |
| | | .accessibilityIdentifier("theme-option-\(theme.rawValue)") |
| | | } |
| | | |
| | | private var previewStrokeColor: Color { |
| | | theme.isDefaultLight |
| | | ? Color.secondary.opacity(0.26) |
| | | : Color.white.opacity(0.24) |
| | | } |
| | | } |
| | | |
| | | private struct DisplayModeOptionButton: View { |
| | | let mode: String |
| | | let title: String |