| | |
| | | import SwiftUI |
| | | import AppKit |
| | | import Carbon |
| | | |
| | | // MARK: - Preferences View |
| | | |
| | |
| | | @AppStorage("showDockIcon") private var showDockIcon = AppDefaults.showDockIcon |
| | | @AppStorage("launchAtLogin") private var launchAtLogin = AppDefaults.launchAtLogin |
| | | @AppStorage("showUncommonAppBubbles") private var showUncommonAppBubbles = AppDefaults.showUncommonAppBubbles |
| | | @State private var selectedLanguage = L10n.currentCode |
| | | @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 isRefreshingLanguage = false |
| | | @State private var allApps: [AppInfo] = [] |
| | | @State private var tagColors: [String: Int] = [:] |
| | | @State private var categoryScheme = TagDatabase.CategorySchemeState() |
| | | @State private var isApplyingSystemScheme = false |
| | | @State private var showApplySystemSchemeConfirmation = false |
| | | @State private var isDataFilePanelPresented = false |
| | | @State private var hotkeyStatusToast: String? = nil |
| | | @State private var hotkeyStatusToastToken: UUID? = nil |
| | | |
| | | private func scanApps() { |
| | | DispatchQueue.global(qos: .userInitiated).async { |
| | |
| | | } |
| | | |
| | | private func exportTags() { |
| | | let panel = NSSavePanel() |
| | | panel.title = tr("settings.export") |
| | | TagDatabase.flushPendingCategorySchemeBackupBatch() |
| | | refreshDataState() |
| | | panel.nameFieldStringValue = TagDatabase.exportFileName(for: categoryScheme) |
| | | panel.allowedContentTypes = [.json] |
| | | beginFilePanel(panel) { response in |
| | | guard response == .OK, let url = panel.url else { return } |
| | | do { |
| | | try TagDatabase.exportTo(url) |
| | | } catch { |
| | | fputs("[TagLauncher] Export failed: \(error)\n", stderr) |
| | | showDataAlert(title: tr("settings.exportFailed"), message: error.localizedDescription) |
| | | guard prepareDataFilePanelPresentation() else { return } |
| | | DispatchQueue.main.async { |
| | | TagDatabase.flushPendingCategorySchemeBackupBatch() |
| | | let currentScheme = TagDatabase.loadWithEnsuredCategoryScheme().categoryScheme |
| | | categoryScheme = currentScheme |
| | | |
| | | let panel = NSSavePanel() |
| | | panel.title = tr("settings.export") |
| | | panel.nameFieldStringValue = TagDatabase.exportFileName(for: currentScheme) |
| | | panel.allowedContentTypes = [.json] |
| | | |
| | | beginFilePanel(panel) { response in |
| | | finishDataFilePanelPresentation() |
| | | guard response == .OK, let url = panel.url else { return } |
| | | do { |
| | | try TagDatabase.exportTo(url) |
| | | } catch { |
| | | fputs("[TagLauncher] Export failed: \(error)\n", stderr) |
| | | showDataAlert(title: tr("settings.exportFailed"), message: error.localizedDescription) |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | private func importTags() { |
| | | let panel = NSOpenPanel() |
| | | panel.title = tr("settings.import") |
| | | panel.allowedContentTypes = [.json] |
| | | panel.allowsMultipleSelection = false |
| | | beginFilePanel(panel) { response in |
| | | guard response == .OK, let url = panel.url else { return } |
| | | do { |
| | | TagDatabase.flushPendingCategorySchemeBackupBatch() |
| | | _ = try TagDatabase.importFrom(url) |
| | | scanApps() |
| | | refreshDataState() |
| | | notifyDataChanged() |
| | | } catch { |
| | | fputs("[TagLauncher] Import failed: \(error)\n", stderr) |
| | | showDataAlert(title: tr("settings.importFailed"), message: error.localizedDescription) |
| | | guard prepareDataFilePanelPresentation() else { return } |
| | | DispatchQueue.main.async { |
| | | let panel = NSOpenPanel() |
| | | panel.title = tr("settings.import") |
| | | panel.allowedContentTypes = [.json] |
| | | panel.allowsMultipleSelection = false |
| | | |
| | | beginFilePanel(panel) { response in |
| | | finishDataFilePanelPresentation() |
| | | guard response == .OK, let url = panel.url else { return } |
| | | do { |
| | | TagDatabase.flushPendingCategorySchemeBackupBatch() |
| | | _ = try TagDatabase.importFrom(url) |
| | | scanApps() |
| | | refreshDataState() |
| | | notifyDataChanged() |
| | | } catch { |
| | | fputs("[TagLauncher] Import failed: \(error)\n", stderr) |
| | | showDataAlert(title: tr("settings.importFailed"), message: error.localizedDescription) |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | private func prepareDataFilePanelPresentation() -> Bool { |
| | | guard !isDataFilePanelPresented else { return false } |
| | | isDataFilePanelPresented = true |
| | | NotificationCenter.default.post( |
| | | name: .tagLauncherModalInteractionChanged, |
| | | object: nil, |
| | | userInfo: ["active": true] |
| | | ) |
| | | NSApp.activate(ignoringOtherApps: true) |
| | | preferencesWindow?.makeKeyAndOrderFront(nil) |
| | | return true |
| | | } |
| | | |
| | | private func finishDataFilePanelPresentation() { |
| | | isDataFilePanelPresented = false |
| | | NotificationCenter.default.post( |
| | | name: .tagLauncherModalInteractionChanged, |
| | | object: nil, |
| | | userInfo: ["active": false] |
| | | ) |
| | | } |
| | | |
| | | private func beginFilePanel( |
| | | _ panel: NSSavePanel, |
| | | completion: @escaping (NSApplication.ModalResponse) -> Void |
| | | ) { |
| | | NSApp.activate(ignoringOtherApps: true) |
| | | if let window = preferencesWindow { |
| | | panel.beginSheetModal(for: window, completionHandler: completion) |
| | | } else { |
| | |
| | | } |
| | | |
| | | private var preferencesWindow: NSWindow? { |
| | | NSApp.windows.first { |
| | | if let taggedWindow = NSApp.windows.first(where: { |
| | | $0.identifier?.rawValue == "TagLauncherPreferencesWindow" && $0.isVisible |
| | | } ?? NSApp.keyWindow ?? NSApp.mainWindow |
| | | }) { |
| | | return taggedWindow |
| | | } |
| | | |
| | | return [NSApp.keyWindow, NSApp.mainWindow] |
| | | .compactMap { $0 } |
| | | .first(where: isPreferencesWindowFallback(_:)) |
| | | } |
| | | |
| | | private func isPreferencesWindowFallback(_ window: NSWindow) -> Bool { |
| | | guard window.isVisible, |
| | | !(window is NSPanel), |
| | | !(window is OverlayPanel) |
| | | else { return false } |
| | | |
| | | let preferencesTitle = tr("menu.preferences").replacingOccurrences(of: "…", with: "") |
| | | return window.title == preferencesTitle |
| | | } |
| | | |
| | | private func showDataAlert(title: String, message: String) { |
| | |
| | | alert.beginSheetModal(for: window) |
| | | } else { |
| | | alert.runModal() |
| | | } |
| | | } |
| | | |
| | | private var mainHotkeyState: LauncherHotkeyRegistrationState { |
| | | LauncherHotkeyRegistrationState(rawValue: mainHotkeyRegistrationState) ?? .active |
| | | } |
| | | |
| | | private var quickSearchHotkeyState: LauncherHotkeyRegistrationState { |
| | | LauncherHotkeyRegistrationState(rawValue: quickSearchHotkeyRegistrationState) ?? .active |
| | | } |
| | | |
| | | private func hotkeyStatusText(for kind: LauncherHotkeyKind) -> String { |
| | | switch kind { |
| | | case .main: |
| | | return mainHotkeyState == .failed |
| | | ? tr("quickSearch.status.registrationFailed") |
| | | : tr("quickSearch.status.Active") |
| | | case .quickSearch: |
| | | return quickSearchHotkeyState == .failed |
| | | ? tr("quickSearch.status.unavailable") |
| | | : tr("quickSearch.status.Active") |
| | | } |
| | | } |
| | | |
| | | private func hotkeyStatusTone(for kind: LauncherHotkeyKind) -> HotkeyStatusTone { |
| | | switch kind { |
| | | case .main: |
| | | return mainHotkeyState == .failed ? .warning : .active |
| | | case .quickSearch: |
| | | return quickSearchHotkeyState == .failed ? .warning : .active |
| | | } |
| | | } |
| | | |
| | | private func showPendingHotkeyWarningIfNeeded() { |
| | | var messages: [String] = [] |
| | | if LauncherHotkeyRegistrationStore.consumeNeedsAttention(for: .main), |
| | | LauncherHotkeyRegistrationStore.state(for: .main) == .failed { |
| | | messages.append(hotkeyFailureMessage(for: .main)) |
| | | } |
| | | if LauncherHotkeyRegistrationStore.consumeNeedsAttention(for: .quickSearch), |
| | | LauncherHotkeyRegistrationStore.state(for: .quickSearch) == .failed { |
| | | messages.append(hotkeyFailureMessage(for: .quickSearch)) |
| | | } |
| | | guard !messages.isEmpty else { return } |
| | | showHotkeyStatusToast(messages.joined(separator: "\n\n")) |
| | | } |
| | | |
| | | private func hotkeyFailureMessage(for kind: LauncherHotkeyKind) -> String { |
| | | let key = kind == .main |
| | | ? "quickSearch.mainHotkeyUnavailableMessage" |
| | | : "quickSearch.globalHotkeyUnavailableMessage" |
| | | let status = LauncherHotkeyRegistrationStore.failureCode(for: kind) |
| | | .map(String.init) ?? "-" |
| | | return tr(key) |
| | | .replacingOccurrences(of: "%shortcut%", with: kind.hotkey.displayString) |
| | | .replacingOccurrences(of: "%status%", with: status) |
| | | } |
| | | |
| | | private func showHotkeyStatusToast(_ message: String) { |
| | | let token = UUID() |
| | | hotkeyStatusToastToken = token |
| | | withAnimation(.easeOut(duration: 0.16)) { |
| | | hotkeyStatusToast = message |
| | | } |
| | | DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { |
| | | guard hotkeyStatusToastToken == token else { return } |
| | | withAnimation(.easeOut(duration: 0.18)) { |
| | | hotkeyStatusToast = nil |
| | | } |
| | | } |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | private func applySystemInitialScheme() { |
| | | guard confirmApplySystemInitialScheme() else { return } |
| | | guard !isApplyingSystemScheme else { return } |
| | | withAnimation(.easeOut(duration: 0.16)) { |
| | | showApplySystemSchemeConfirmation = true |
| | | } |
| | | } |
| | | |
| | | private func performApplySystemInitialScheme() { |
| | | guard !isApplyingSystemScheme else { return } |
| | | showApplySystemSchemeConfirmation = false |
| | | TagDatabase.flushPendingCategorySchemeBackupBatch() |
| | | isApplyingSystemScheme = true |
| | | |
| | | DispatchQueue.global(qos: .userInitiated).async { |
| | | let scannedApps = AppIndexer.scan() |
| | | let scannedApps = AppIndexer.scan(useCache: false) |
| | | let result = SmartStartService.applySystemInitialScheme(apps: scannedApps) |
| | | let store = result.store |
| | | let apps = TagEditor.annotate(apps: scannedApps, store: store) |
| | |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | private func confirmApplySystemInitialScheme() -> Bool { |
| | | let alert = NSAlert() |
| | | alert.messageText = tr("settings.applySystemSchemeWarningTitle") |
| | | alert.informativeText = tr("settings.applySystemSchemeWarningMessage") |
| | | alert.alertStyle = .warning |
| | | alert.addButton(withTitle: tr("settings.confirmApplyScheme")) |
| | | alert.addButton(withTitle: tr("settings.cancel")) |
| | | return alert.runModal() == .alertFirstButtonReturn |
| | | } |
| | | |
| | | private func formattedSystemSchemeAppliedMessage(_ summary: SmartStartSummary) -> String { |
| | |
| | | return FileManager.default.fileExists(atPath: path) |
| | | } |
| | | |
| | | private func bubbleScopeOption( |
| | | _ title: String, |
| | | isSelected: Bool, |
| | | action: @escaping () -> Void |
| | | ) -> some View { |
| | | Button(action: action) { |
| | | HStack(spacing: 6) { |
| | | Image(systemName: isSelected ? "largecircle.fill.circle" : "circle") |
| | | .font(.system(size: 13, weight: .medium)) |
| | | .frame(width: 16, height: 16) |
| | | Text(title) |
| | | .font(.system(size: 13, weight: .medium)) |
| | | } |
| | | .foregroundStyle(isSelected ? Color.accentColor : Color.primary) |
| | | .contentShape(Rectangle()) |
| | | } |
| | | .buttonStyle(.plain) |
| | | } |
| | | |
| | | private func syncSelectedLanguage() { |
| | | let code = L10n.currentCode |
| | | let code = L10n.selectedLanguageCode |
| | | guard selectedLanguage != code else { return } |
| | | selectedLanguage = code |
| | | } |
| | |
| | | } |
| | | private var buildVersion: String { |
| | | Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "?" |
| | | } |
| | | private var helpPDFURL: URL { |
| | | HelpDocument.currentURL |
| | | } |
| | | private var languageColumns: [GridItem] { |
| | | [ |
| | |
| | | .contentShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) |
| | | } |
| | | |
| | | private func hotkeySettingsPanel() -> some View { |
| | | VStack(alignment: .leading, spacing: 12) { |
| | | Text(tr("quickSearch.hotkeys")) |
| | | .font(.system(size: 13, weight: .semibold)) |
| | | .foregroundStyle(.secondary) |
| | | |
| | | StaticHotkeyInfoRow( |
| | | title: tr("quickSearch.mainHotkey"), |
| | | description: tr("quickSearch.mainHotkeyDesc"), |
| | | displayText: LauncherHotkey.main.displayString, |
| | | statusText: hotkeyStatusText(for: .main), |
| | | statusTone: hotkeyStatusTone(for: .main) |
| | | ) |
| | | |
| | | StaticHotkeyInfoRow( |
| | | title: tr("quickSearch.internalHotkey"), |
| | | description: tr("quickSearch.internalHotkeyDesc"), |
| | | displayText: tr("quickSearch.spaceDisplay"), |
| | | statusText: tr("quickSearch.internalHotkeyStatus"), |
| | | statusTone: .neutral |
| | | ) |
| | | |
| | | StaticHotkeyInfoRow( |
| | | title: tr("quickSearch.globalHotkey"), |
| | | description: tr("quickSearch.globalHotkeyDesc"), |
| | | displayText: LauncherHotkey.quickSearch.displayString, |
| | | statusText: hotkeyStatusText(for: .quickSearch), |
| | | statusTone: hotkeyStatusTone(for: .quickSearch) |
| | | ) |
| | | } |
| | | } |
| | | |
| | | var body: some View { |
| | | ZStack { |
| | | TabView { |
| | |
| | | |
| | | ScrollView { |
| | | LazyVGrid(columns: languageColumns, alignment: .leading, spacing: 6) { |
| | | Button { |
| | | selectedLanguage = L10n.automaticCode |
| | | } label: { |
| | | HStack(spacing: 8) { |
| | | Image(systemName: selectedLanguage == L10n.automaticCode ? "checkmark" : "circle") |
| | | .font(.system(size: 12, weight: .semibold)) |
| | | .foregroundStyle(selectedLanguage == L10n.automaticCode ? Color.accentColor : Color.secondary.opacity(0.28)) |
| | | .frame(width: 16) |
| | | Text(tr("settings.language.auto")) |
| | | .foregroundStyle(.primary) |
| | | .lineLimit(1) |
| | | Text("(\(L10n.supported.first(where: { $0.code == L10n.currentCode })?.name ?? "English"))") |
| | | .font(.caption) |
| | | .foregroundStyle(.secondary) |
| | | .lineLimit(1) |
| | | Spacer(minLength: 0) |
| | | } |
| | | .padding(.horizontal, 8) |
| | | .padding(.vertical, 5) |
| | | .contentShape(Rectangle()) |
| | | } |
| | | .buttonStyle(.plain) |
| | | .accessibilityLabel("\(tr("settings.languagePicker")) \(tr("settings.language.auto"))") |
| | | |
| | | ForEach(L10n.supported, id: \.code) { language in |
| | | Button { |
| | | selectedLanguage = language.code |
| | |
| | | .frame(maxHeight: 250) |
| | | |
| | | Spacer(minLength: 0) |
| | | ShortcutHintView() |
| | | .frame(maxWidth: .infinity, alignment: .center) |
| | | .padding(.bottom, 2) |
| | | } |
| | | .frame(maxWidth: settingsContentWidth, alignment: .leading) |
| | | .tabItem { Label(tr("settings.language"), systemImage: "globe") } |
| | | .padding() |
| | | |
| | | // Tab 2: General |
| | | VStack(alignment: .leading, spacing: 0) { |
| | | // Toggle row — centered as a rectangular block |
| | | HStack(spacing: 20) { |
| | | if AppDelegate.supportsLaunchAtLogin { |
| | | Toggle(tr("settings.launchAtLogin"), isOn: $launchAtLogin) |
| | | .onChange(of: launchAtLogin) { _, enabled in |
| | | if enabled { |
| | | AppDelegate.enableLaunchAtLogin() |
| | | } else { |
| | | AppDelegate.disableLaunchAtLogin() |
| | | ScrollView(.vertical, showsIndicators: true) { |
| | | VStack(alignment: .leading, spacing: 0) { |
| | | // Toggle row — centered as a rectangular block |
| | | HStack(spacing: 20) { |
| | | if AppDelegate.supportsLaunchAtLogin { |
| | | Toggle(tr("settings.launchAtLogin"), isOn: $launchAtLogin) |
| | | .onChange(of: launchAtLogin) { _, enabled in |
| | | if enabled { |
| | | AppDelegate.enableLaunchAtLogin() |
| | | } else { |
| | | AppDelegate.disableLaunchAtLogin() |
| | | } |
| | | } |
| | | } |
| | | Toggle(tr("settings.showInDock"), isOn: $showDockIcon) |
| | | .onChange(of: showDockIcon) { _, _ in |
| | | AppDelegate.refreshChromeSettings() |
| | | } |
| | | } |
| | | } |
| | | Toggle(tr("settings.showInDock"), isOn: $showDockIcon) |
| | | .onChange(of: showDockIcon) { _, _ in |
| | | AppDelegate.refreshChromeSettings() |
| | | Toggle(tr("settings.hideAppNames"), isOn: $hideAppNames) |
| | | } |
| | | Toggle(tr("settings.hideAppNames"), isOn: $hideAppNames) |
| | | } |
| | | .frame(maxWidth: .infinity, alignment: .center) |
| | | .padding(.bottom, 16) |
| | | .frame(maxWidth: .infinity, alignment: .center) |
| | | .padding(.bottom, 16) |
| | | |
| | | Divider() |
| | | .padding(.bottom, 16) |
| | | Divider() |
| | | .padding(.bottom, 16) |
| | | |
| | | VStack(spacing: 18) { |
| | | generalSettingRow(tr("settings.appListStyle"), description: tr("settings.flatDesc")) { |
| | | VStack(spacing: 8) { |
| | | DisplayModeOptionButton( |
| | | mode: "flat", |
| | | title: tr("settings.flat"), |
| | | isSelected: displayMode == "flat" |
| | | ) { |
| | | displayMode = "flat" |
| | | } |
| | | |
| | | LazyVGrid(columns: displayModeColumns, alignment: .leading, spacing: 8) { |
| | | ForEach(containerDisplayModeOptions, id: \.id) { option in |
| | | VStack(spacing: 18) { |
| | | generalSettingRow(tr("settings.appListStyle"), description: tr("settings.flatDesc")) { |
| | | VStack(spacing: 8) { |
| | | DisplayModeOptionButton( |
| | | mode: option.id, |
| | | title: option.title, |
| | | isSelected: displayMode == option.id |
| | | mode: "flat", |
| | | title: tr("settings.flat"), |
| | | isSelected: displayMode == "flat" |
| | | ) { |
| | | displayMode = option.id |
| | | displayMode = "flat" |
| | | } |
| | | |
| | | LazyVGrid(columns: displayModeColumns, alignment: .leading, spacing: 8) { |
| | | ForEach(containerDisplayModeOptions, id: \.id) { option in |
| | | DisplayModeOptionButton( |
| | | mode: option.id, |
| | | title: option.title, |
| | | isSelected: displayMode == option.id |
| | | ) { |
| | | displayMode = option.id |
| | | } |
| | | } |
| | | } |
| | | } |
| | | .frame(width: generalControlWidth, alignment: .leading) |
| | | } |
| | | |
| | | generalSettingRow(tr("settings.tagPosition"), description: tr("settings.tagPosDesc")) { |
| | | Picker("", selection: $tagPosition) { |
| | | Text(tr("settings.left")).tag("left") |
| | | Text(tr("settings.right")).tag("right") |
| | | Text(tr("settings.top")).tag("top") |
| | | } |
| | | .pickerStyle(.segmented) |
| | | .frame(width: compactPickerWidth, alignment: .leading) |
| | | } |
| | | |
| | | generalSettingRow(tr("settings.tagFontSize"), description: tr("settings.tagFontDesc")) { |
| | | Picker("", selection: $tagFontSize) { |
| | | ForEach([16.0, 18.0, 20.0, 22.0, 24.0, 26.0], id: \.self) { size in |
| | | Text("\(Int(size))").tag(size) |
| | | } |
| | | } |
| | | .pickerStyle(.segmented) |
| | | .frame(width: compactPickerWidth, alignment: .leading) |
| | | } |
| | | |
| | | generalSettingRow(tr("settings.iconSize"), description: tr("settings.iconSizeDesc")) { |
| | | Picker("", selection: $iconSize) { |
| | | ForEach([40.0, 48.0, 56.0, 64.0, 72.0, 80.0], id: \.self) { size in |
| | | Text("\(Int(size))").tag(size) |
| | | } |
| | | } |
| | | .pickerStyle(.segmented) |
| | | .frame(width: compactPickerWidth, alignment: .leading) |
| | | } |
| | | } |
| | | .frame(width: generalControlWidth, alignment: .leading) |
| | | } |
| | | |
| | | generalSettingRow(tr("settings.tagPosition"), description: tr("settings.tagPosDesc")) { |
| | | Picker("", selection: $tagPosition) { |
| | | Text(tr("settings.left")).tag("left") |
| | | Text(tr("settings.right")).tag("right") |
| | | Text(tr("settings.top")).tag("top") |
| | | } |
| | | .pickerStyle(.segmented) |
| | | .frame(width: compactPickerWidth, alignment: .leading) |
| | | } |
| | | |
| | | generalSettingRow(tr("settings.tagFontSize"), description: tr("settings.tagFontDesc")) { |
| | | Picker("", selection: $tagFontSize) { |
| | | ForEach([16.0, 18.0, 20.0, 22.0, 24.0, 26.0], id: \.self) { size in |
| | | Text("\(Int(size))").tag(size) |
| | | } |
| | | } |
| | | .pickerStyle(.segmented) |
| | | .frame(width: compactPickerWidth, alignment: .leading) |
| | | } |
| | | |
| | | generalSettingRow(tr("settings.iconSize"), description: tr("settings.iconSizeDesc")) { |
| | | Picker("", selection: $iconSize) { |
| | | ForEach([40.0, 48.0, 56.0, 64.0, 72.0, 80.0], id: \.self) { size in |
| | | Text("\(Int(size))").tag(size) |
| | | } |
| | | } |
| | | .pickerStyle(.segmented) |
| | | .frame(width: compactPickerWidth, alignment: .leading) |
| | | } |
| | | .frame(maxWidth: generalContentWidth, alignment: .center) |
| | | .padding() |
| | | } |
| | | } |
| | | .frame(maxWidth: generalContentWidth, alignment: .center) |
| | | .padding() |
| | | .tabItem { Label(tr("settings.general"), systemImage: "gearshape") } |
| | | .tabItem { Label(tr("settings.general"), systemImage: "gearshape") } |
| | | |
| | | // Tab 3: Tags |
| | | // Tab 3: Hotkeys |
| | | ScrollView(.vertical, showsIndicators: true) { |
| | | VStack(alignment: .leading, spacing: 16) { |
| | | hotkeySettingsPanel() |
| | | .frame(width: generalContentWidth, alignment: .leading) |
| | | } |
| | | .frame(maxWidth: generalContentWidth, alignment: .center) |
| | | .padding() |
| | | } |
| | | .tabItem { Label(tr("quickSearch.hotkeys"), systemImage: "keyboard") } |
| | | |
| | | // Tab 4: Tags |
| | | VStack(spacing: 0) { |
| | | TagEditorView( |
| | | tagColors: $tagColors, |
| | |
| | | onRefresh: { scanApps() } |
| | | ) |
| | | } |
| | | .padding(.leading, 16) |
| | | .tabItem { Label(tr("settings.tags"), systemImage: "tag.fill") } |
| | | .onAppear { scanApps() } |
| | | |
| | | // Tab 4: Data |
| | | // Tab 5: Data |
| | | VStack(spacing: 0) { |
| | | Spacer(minLength: 32) |
| | | |
| | |
| | | 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) |
| | |
| | | |
| | | Spacer(minLength: 22) |
| | | |
| | | VStack(alignment: .leading, spacing: 5) { |
| | | Toggle(tr("settings.uncommonBubble"), isOn: $showUncommonAppBubbles) |
| | | HStack(alignment: .center, spacing: 14) { |
| | | Text(tr("settings.bubbleDisplayScope")) |
| | | .font(.system(size: 13, weight: .medium)) |
| | | Text(tr("settings.uncommonBubbleDesc")) |
| | | .font(.caption) |
| | | .foregroundStyle(.secondary) |
| | | .fixedSize(horizontal: false, vertical: true) |
| | | .frame(width: 190, alignment: .trailing) |
| | | |
| | | HStack(spacing: 20) { |
| | | bubbleScopeOption( |
| | | tr("settings.bubbleAllApps"), |
| | | isSelected: !showUncommonAppBubbles |
| | | ) { |
| | | showUncommonAppBubbles = false |
| | | } |
| | | bubbleScopeOption( |
| | | tr("settings.bubbleUncommonOnly"), |
| | | isSelected: showUncommonAppBubbles |
| | | ) { |
| | | showUncommonAppBubbles = true |
| | | } |
| | | } |
| | | .frame(width: 300, alignment: .leading) |
| | | } |
| | | .frame(width: 420, alignment: .leading) |
| | | .frame(width: 520, alignment: .center) |
| | | |
| | | Spacer(minLength: 18) |
| | | } |
| | |
| | | .padding() |
| | | .onAppear { refreshDataState() } |
| | | |
| | | // Tab 5: About |
| | | // Tab 6: About |
| | | VStack(spacing: 0) { |
| | | Spacer(minLength: 72) |
| | | |
| | |
| | | Divider() |
| | | .padding(.vertical, 4) |
| | | |
| | | HStack(spacing: 8) { |
| | | Button { |
| | | NSWorkspace.shared.open(helpPDFURL) |
| | | } label: { |
| | | Label(tr("help.openPDF"), systemImage: "questionmark.circle") |
| | | } |
| | | .buttonStyle(.borderedProminent) |
| | | .controlSize(.regular) |
| | | |
| | | Button { |
| | | NSPasteboard.general.clearContents() |
| | | NSPasteboard.general.setString(helpPDFURL.absoluteString, forType: .string) |
| | | } label: { |
| | | Image(systemName: "link") |
| | | } |
| | | .buttonStyle(.bordered) |
| | | .controlSize(.regular) |
| | | .help(tr("help.copyLink")) |
| | | } |
| | | |
| | | Text(tr("help.currentLanguage")) |
| | | .font(.caption) |
| | | .foregroundStyle(.secondary) |
| | | .padding(.bottom, 2) |
| | | |
| | | Text("万物之中,希望最美") |
| | | .font(.caption) |
| | | .foregroundStyle(.secondary) |
| | | Text("永桔@2026-18602102518") |
| | | Text("永桔@shanghai3168@gmail.com") |
| | | .font(.caption) |
| | | .foregroundStyle(.secondary) |
| | | } |
| | |
| | | .frame(width: 560, alignment: .leading) |
| | | |
| | | Spacer(minLength: 30) |
| | | ShortcutHintView() |
| | | .frame(maxWidth: .infinity, alignment: .center) |
| | | Spacer(minLength: 18) |
| | | } |
| | | .tabItem { Label(tr("settings.about"), systemImage: "info.circle") } |
| | | .padding() |
| | | } |
| | | .onChange(of: selectedLanguage) { _, code in |
| | | L10n.switchTo(code) |
| | | L10n.switchSelection(to: code) |
| | | } |
| | | .onReceive(NotificationCenter.default.publisher(for: .appLanguageDidChange)) { notification in |
| | | if let code = notification.userInfo?["code"] as? String { |
| | |
| | | } |
| | | .transition(.opacity) |
| | | } |
| | | |
| | | if showApplySystemSchemeConfirmation { |
| | | ApplySystemSchemeConfirmationView( |
| | | title: tr("settings.applySystemSchemeWarningTitle"), |
| | | message: tr("settings.applySystemSchemeWarningMessage"), |
| | | confirmTitle: tr("settings.confirmApplyScheme"), |
| | | cancelTitle: tr("settings.cancel"), |
| | | onConfirm: performApplySystemInitialScheme, |
| | | onCancel: { |
| | | withAnimation(.easeOut(duration: 0.14)) { |
| | | showApplySystemSchemeConfirmation = false |
| | | } |
| | | } |
| | | ) |
| | | .zIndex(3) |
| | | .transition(.opacity) |
| | | } |
| | | |
| | | if let hotkeyStatusToast { |
| | | VStack { |
| | | Spacer() |
| | | Text(hotkeyStatusToast) |
| | | .font(.system(size: 13, weight: .semibold)) |
| | | .foregroundStyle(.white) |
| | | .multilineTextAlignment(.center) |
| | | .lineSpacing(2) |
| | | .padding(.horizontal, 18) |
| | | .padding(.vertical, 12) |
| | | .background( |
| | | RoundedRectangle(cornerRadius: 8, style: .continuous) |
| | | .fill(Color.black.opacity(0.92)) |
| | | ) |
| | | .shadow(color: .black.opacity(0.28), radius: 18, y: 10) |
| | | .frame(maxWidth: 620) |
| | | .padding(.bottom, 22) |
| | | } |
| | | .zIndex(4) |
| | | .transition(.opacity.combined(with: .move(edge: .bottom))) |
| | | } |
| | | } |
| | | .frame(width: settingsWindowWidth, height: 460) |
| | | .onAppear { |
| | | syncSelectedLanguage() |
| | | showPendingHotkeyWarningIfNeeded() |
| | | } |
| | | .onReceive(NotificationCenter.default.publisher(for: .tagLauncherHotkeyRegistrationChanged)) { _ in |
| | | showPendingHotkeyWarningIfNeeded() |
| | | } |
| | | } |
| | | |
| | |
| | | isRefreshingLanguage = false |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | private enum HotkeyStatusTone { |
| | | case active |
| | | case warning |
| | | case neutral |
| | | |
| | | var foreground: Color { |
| | | switch self { |
| | | case .active: return Color.green |
| | | case .warning: return Color.orange |
| | | case .neutral: return Color.secondary |
| | | } |
| | | } |
| | | |
| | | var background: Color { |
| | | foreground.opacity(0.13) |
| | | } |
| | | } |
| | | |
| | | private struct StaticHotkeyInfoRow: View { |
| | | let title: String |
| | | let description: String |
| | | let displayText: String |
| | | let statusText: String |
| | | let statusTone: HotkeyStatusTone |
| | | |
| | | var body: some View { |
| | | VStack(alignment: .leading, spacing: 12) { |
| | | HStack(alignment: .top, spacing: 12) { |
| | | VStack(alignment: .leading, spacing: 3) { |
| | | Text(title) |
| | | .font(.system(size: 13, weight: .semibold)) |
| | | Text(description) |
| | | .font(.caption) |
| | | .foregroundStyle(.secondary) |
| | | .fixedSize(horizontal: false, vertical: true) |
| | | } |
| | | .frame(maxWidth: .infinity, alignment: .leading) |
| | | |
| | | VStack(alignment: .leading, spacing: 4) { |
| | | Text(displayText) |
| | | .font(.system(size: 15, weight: .semibold)) |
| | | .lineLimit(1) |
| | | .minimumScaleFactor(0.85) |
| | | Text(statusText) |
| | | .font(.system(size: 11, weight: .semibold)) |
| | | .foregroundStyle(statusTone.foreground) |
| | | .padding(.horizontal, 8) |
| | | .padding(.vertical, 3) |
| | | .background(Capsule().fill(statusTone.background)) |
| | | } |
| | | .frame(width: 180, alignment: .leading) |
| | | } |
| | | } |
| | | .padding(12) |
| | | .background( |
| | | RoundedRectangle(cornerRadius: 8, style: .continuous) |
| | | .fill(Color(nsColor: .controlBackgroundColor)) |
| | | ) |
| | | .overlay( |
| | | RoundedRectangle(cornerRadius: 8, style: .continuous) |
| | | .stroke(Color.secondary.opacity(0.14), lineWidth: 1) |
| | | ) |
| | | } |
| | | } |
| | | |
| | | private struct ApplySystemSchemeConfirmationView: View { |
| | | let title: String |
| | | let message: String |
| | | let confirmTitle: String |
| | | let cancelTitle: String |
| | | let onConfirm: () -> Void |
| | | let onCancel: () -> Void |
| | | |
| | | var body: some View { |
| | | ZStack { |
| | | Color.black.opacity(0.20) |
| | | .contentShape(Rectangle()) |
| | | .onTapGesture { } |
| | | |
| | | VStack(alignment: .leading, spacing: 20) { |
| | | HStack(alignment: .top, spacing: 12) { |
| | | ZStack { |
| | | Circle() |
| | | .fill(Color.orange.opacity(0.16)) |
| | | .frame(width: 38, height: 38) |
| | | Image(systemName: "exclamationmark.triangle.fill") |
| | | .font(.system(size: 19, weight: .semibold)) |
| | | .foregroundStyle(Color.orange) |
| | | } |
| | | .frame(width: 40, height: 40) |
| | | |
| | | VStack(alignment: .leading, spacing: 8) { |
| | | Text(title) |
| | | .font(.system(size: 16, weight: .semibold)) |
| | | .foregroundStyle(.primary) |
| | | .fixedSize(horizontal: false, vertical: true) |
| | | Text(message) |
| | | .font(.system(size: 13)) |
| | | .foregroundStyle(Color.primary.opacity(0.78)) |
| | | .lineSpacing(2) |
| | | .fixedSize(horizontal: false, vertical: true) |
| | | } |
| | | } |
| | | |
| | | HStack(spacing: 10) { |
| | | Spacer() |
| | | ConfirmationActionButton( |
| | | title: cancelTitle, |
| | | prominent: false, |
| | | action: onCancel |
| | | ) |
| | | .keyboardShortcut(.cancelAction) |
| | | ConfirmationActionButton( |
| | | title: confirmTitle, |
| | | prominent: true, |
| | | action: onConfirm |
| | | ) |
| | | .keyboardShortcut(.defaultAction) |
| | | } |
| | | } |
| | | .padding(22) |
| | | .frame(width: 440) |
| | | .background( |
| | | RoundedRectangle(cornerRadius: 8, style: .continuous) |
| | | .fill(Color(nsColor: .windowBackgroundColor)) |
| | | ) |
| | | .overlay( |
| | | RoundedRectangle(cornerRadius: 8, style: .continuous) |
| | | .stroke(Color.primary.opacity(0.16), lineWidth: 1) |
| | | ) |
| | | .shadow(color: .black.opacity(0.24), radius: 20, x: 0, y: 10) |
| | | } |
| | | .frame(maxWidth: .infinity, maxHeight: .infinity) |
| | | } |
| | | } |
| | | |
| | | private struct ConfirmationActionButton: View { |
| | | let title: String |
| | | let prominent: Bool |
| | | let action: () -> Void |
| | | |
| | | var body: some View { |
| | | Button(action: action) { |
| | | Text(title) |
| | | .font(.system(size: 13, weight: .semibold)) |
| | | .foregroundStyle(prominent ? Color.white : Color.primary) |
| | | .lineLimit(1) |
| | | .minimumScaleFactor(0.86) |
| | | .frame(minWidth: 82, minHeight: 32) |
| | | .padding(.horizontal, 10) |
| | | .background( |
| | | RoundedRectangle(cornerRadius: 7, style: .continuous) |
| | | .fill(prominent ? Color.accentColor : Color(nsColor: .controlBackgroundColor)) |
| | | ) |
| | | .overlay( |
| | | RoundedRectangle(cornerRadius: 7, style: .continuous) |
| | | .stroke( |
| | | prominent ? Color.accentColor.opacity(0.40) : Color.primary.opacity(0.16), |
| | | lineWidth: 1 |
| | | ) |
| | | ) |
| | | } |
| | | .buttonStyle(.plain) |
| | | .contentShape(RoundedRectangle(cornerRadius: 7, style: .continuous)) |
| | | } |
| | | } |
| | | |
| | |
| | | } |
| | | let palette: [Color] = [.green, .purple, .blue, .orange] |
| | | return palette[index % palette.count].opacity(0.78) |
| | | } |
| | | } |
| | | |
| | | private struct ShortcutHintView: View { |
| | | var body: some View { |
| | | HStack(spacing: 12) { |
| | | ShortcutKeycap(symbol: "⌥") |
| | | Text("+") |
| | | .font(.system(size: 22, weight: .semibold)) |
| | | .foregroundStyle(.tertiary) |
| | | ShortcutKeycap(symbol: "⇧") |
| | | Text("+") |
| | | .font(.system(size: 22, weight: .semibold)) |
| | | .foregroundStyle(.tertiary) |
| | | SpacebarKeycap() |
| | | } |
| | | .accessibilityLabel("⌥ ⇧ Space") |
| | | .allowsHitTesting(false) |
| | | } |
| | | } |
| | | |
| | | private struct ShortcutKeycap: View { |
| | | let symbol: String |
| | | |
| | | var body: some View { |
| | | Text(symbol) |
| | | .font(.system(size: 36, weight: .medium)) |
| | | .foregroundStyle(.secondary) |
| | | .frame(width: 74, height: 58) |
| | | .background(KeycapBackground()) |
| | | } |
| | | } |
| | | |
| | | private struct SpacebarKeycap: View { |
| | | var body: some View { |
| | | ZStack { |
| | | KeycapBackground() |
| | | Capsule() |
| | | .fill(Color.secondary.opacity(0.38)) |
| | | .frame(width: 72, height: 3) |
| | | .offset(y: 13) |
| | | } |
| | | .frame(width: 190, height: 58) |
| | | } |
| | | } |
| | | |
| | | private struct KeycapBackground: View { |
| | | var body: some View { |
| | | RoundedRectangle(cornerRadius: 10, style: .continuous) |
| | | .fill(Color(nsColor: .controlBackgroundColor)) |
| | | .overlay( |
| | | RoundedRectangle(cornerRadius: 10, style: .continuous) |
| | | .stroke(Color.primary.opacity(0.12), lineWidth: 1) |
| | | ) |
| | | .shadow(color: .black.opacity(0.10), radius: 8, x: 0, y: 3) |
| | | } |
| | | } |