| | |
| | | @AppStorage("showUncommonAppBubbles") private var showUncommonAppBubbles = AppDefaults.showUncommonAppBubbles |
| | | @AppStorage("mainHotkeyRegistrationState") private var mainHotkeyRegistrationState = LauncherHotkeyRegistrationState.active.rawValue |
| | | @AppStorage("quickSearchHotkeyRegistrationState") private var quickSearchHotkeyRegistrationState = LauncherHotkeyRegistrationState.active.rawValue |
| | | @State private var selectedLanguage = L10n.currentCode |
| | | @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 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) { |
| | |
| | | } |
| | | |
| | | 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] { |
| | | [ |
| | |
| | | |
| | | 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 |
| | |
| | | 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) |
| | |
| | | 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) |
| | |
| | | .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 { |