| | |
| | | import SwiftUI |
| | | import AppKit |
| | | import Carbon |
| | | |
| | | // MARK: - Preferences View |
| | | |
| | |
| | | @State private var isDataFilePanelPresented = false |
| | | @State private var hotkeyStatusToast: String? = nil |
| | | @State private var hotkeyStatusToastToken: UUID? = nil |
| | | @State private var recordingHotkeyKind: LauncherHotkeyKind? = nil |
| | | @State private var hotkeyRecorderMonitor: Any? = nil |
| | | @State private var hotkeySettingsRefreshToken = UUID() |
| | | @State private var selectedTab: SettingsTab = .general |
| | | @State private var selectedInitialLayoutMode: InitialLayoutMode = .smart |
| | | @State private var settingsProPromptFeature: ProFeature? = nil |
| | |
| | | let status = LauncherHotkeyRegistrationStore.failureCode(for: kind) |
| | | .map(String.init) ?? "-" |
| | | return tr(key) |
| | | .replacingOccurrences(of: "%shortcut%", with: kind.hotkey.displayString) |
| | | .replacingOccurrences(of: "%shortcut%", with: LauncherHotkeySettings.effectiveHotkey(for: kind).displayString) |
| | | .replacingOccurrences(of: "%status%", with: status) |
| | | } |
| | | |
| | | private func startHotkeyRecording(for kind: LauncherHotkeyKind) { |
| | | guard ProEntitlementPolicy.isUnlocked(.customHotkeys) else { |
| | | presentSettingsProPrompt(for: .customHotkeys) |
| | | return |
| | | } |
| | | stopHotkeyRecording(showCancelledToast: false) |
| | | recordingHotkeyKind = kind |
| | | showHotkeyStatusToast(tr("hotkeys.recording")) |
| | | hotkeyRecorderMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in |
| | | DispatchQueue.main.async { |
| | | self.captureHotkeyEvent(event) |
| | | } |
| | | return nil |
| | | } |
| | | } |
| | | |
| | | private func captureHotkeyEvent(_ event: NSEvent) { |
| | | guard let kind = recordingHotkeyKind else { return } |
| | | if Int(event.keyCode) == kVK_Escape { |
| | | stopHotkeyRecording(showCancelledToast: true) |
| | | return |
| | | } |
| | | let candidate = LauncherHotkeySettings.candidate(from: event) |
| | | let result = (NSApp.delegate as? AppDelegate)? |
| | | .applyCustomHotkey(candidate, for: kind) |
| | | ?? .registrationFailed(-1) |
| | | stopHotkeyRecording(showCancelledToast: false) |
| | | handleHotkeyCustomizationResult(result) |
| | | } |
| | | |
| | | private func restoreDefaultHotkey(for kind: LauncherHotkeyKind) { |
| | | guard ProEntitlementPolicy.isUnlocked(.customHotkeys) else { |
| | | presentSettingsProPrompt(for: .customHotkeys) |
| | | return |
| | | } |
| | | let result = (NSApp.delegate as? AppDelegate)? |
| | | .restoreDefaultHotkey(for: kind) |
| | | ?? .registrationFailed(-1) |
| | | handleHotkeyCustomizationResult(result) |
| | | } |
| | | |
| | | private func stopHotkeyRecording(showCancelledToast: Bool = false) { |
| | | if let hotkeyRecorderMonitor { |
| | | NSEvent.removeMonitor(hotkeyRecorderMonitor) |
| | | } |
| | | hotkeyRecorderMonitor = nil |
| | | recordingHotkeyKind = nil |
| | | if showCancelledToast { |
| | | showHotkeyStatusToast(tr("hotkeys.recordingCancelled")) |
| | | } |
| | | } |
| | | |
| | | private func handleHotkeyCustomizationResult(_ result: LauncherHotkeyCustomizationResult) { |
| | | if case .locked = result { |
| | | presentSettingsProPrompt(for: .customHotkeys) |
| | | return |
| | | } |
| | | hotkeySettingsRefreshToken = UUID() |
| | | showHotkeyStatusToast(hotkeyCustomizationMessage(for: result)) |
| | | } |
| | | |
| | | private func hotkeyCustomizationMessage(for result: LauncherHotkeyCustomizationResult) -> String { |
| | | switch result { |
| | | case .registrationFailed(let status): |
| | | return tr(result.messageKey).replacingOccurrences(of: "%status%", with: String(status)) |
| | | default: |
| | | return tr(result.messageKey) |
| | | } |
| | | } |
| | | |
| | | private func showHotkeyStatusToast(_ message: String) { |
| | |
| | | .font(.system(size: 13, weight: .semibold)) |
| | | .foregroundStyle(.secondary) |
| | | |
| | | StaticHotkeyInfoRow( |
| | | CustomizableHotkeyInfoRow( |
| | | title: tr("quickSearch.mainHotkey"), |
| | | description: tr("quickSearch.mainHotkeyDesc"), |
| | | displayText: LauncherHotkey.main.displayString, |
| | | displayText: LauncherHotkeySettings.effectiveHotkey(for: .main).displayString, |
| | | statusText: hotkeyStatusText(for: .main), |
| | | statusTone: hotkeyStatusTone(for: .main) |
| | | statusTone: hotkeyStatusTone(for: .main), |
| | | customizeTitle: proEntitlement.isUnlocked ? tr("hotkeys.customize") : tr("pro.card.badge"), |
| | | restoreTitle: tr("hotkeys.restoreDefault"), |
| | | isRecording: recordingHotkeyKind == .main, |
| | | canRestore: proEntitlement.isUnlocked && LauncherHotkeySettings.hasCustomHotkey(for: .main), |
| | | onCustomize: { startHotkeyRecording(for: .main) }, |
| | | onRestore: { restoreDefaultHotkey(for: .main) } |
| | | ) |
| | | |
| | | StaticHotkeyInfoRow( |
| | |
| | | statusTone: .neutral |
| | | ) |
| | | |
| | | StaticHotkeyInfoRow( |
| | | CustomizableHotkeyInfoRow( |
| | | title: tr("quickSearch.globalHotkey"), |
| | | description: tr("quickSearch.globalHotkeyDesc"), |
| | | displayText: LauncherHotkey.quickSearch.displayString, |
| | | displayText: LauncherHotkeySettings.effectiveHotkey(for: .quickSearch).displayString, |
| | | statusText: hotkeyStatusText(for: .quickSearch), |
| | | statusTone: hotkeyStatusTone(for: .quickSearch) |
| | | statusTone: hotkeyStatusTone(for: .quickSearch), |
| | | customizeTitle: proEntitlement.isUnlocked ? tr("hotkeys.customize") : tr("pro.card.badge"), |
| | | restoreTitle: tr("hotkeys.restoreDefault"), |
| | | isRecording: recordingHotkeyKind == .quickSearch, |
| | | canRestore: proEntitlement.isUnlocked && LauncherHotkeySettings.hasCustomHotkey(for: .quickSearch), |
| | | onCustomize: { startHotkeyRecording(for: .quickSearch) }, |
| | | onRestore: { restoreDefaultHotkey(for: .quickSearch) } |
| | | ) |
| | | |
| | | StaticHotkeyInfoRow( |
| | |
| | | statusTone: .neutral |
| | | ) |
| | | } |
| | | .id(hotkeySettingsRefreshToken) |
| | | } |
| | | |
| | | private var themeProStatusRow: some View { |
| | |
| | | showPendingHotkeyWarningIfNeeded() |
| | | refreshThemePreviewCountdown() |
| | | } |
| | | .onDisappear { |
| | | stopHotkeyRecording() |
| | | } |
| | | .onReceive(themePreviewCountdownTimer) { now in |
| | | refreshThemePreviewCountdown(now: now) |
| | | } |
| | | .onReceive(NotificationCenter.default.publisher(for: .tagLauncherHotkeyRegistrationChanged)) { _ in |
| | | hotkeySettingsRefreshToken = UUID() |
| | | showPendingHotkeyWarningIfNeeded() |
| | | } |
| | | .onReceive(NotificationCenter.default.publisher(for: .tagLauncherPreferencesTabRequested)) { notification in |
| | |
| | | } |
| | | } |
| | | |
| | | private struct CustomizableHotkeyInfoRow: View { |
| | | let title: String |
| | | let description: String |
| | | let displayText: String |
| | | let statusText: String |
| | | let statusTone: HotkeyStatusTone |
| | | let customizeTitle: String |
| | | let restoreTitle: String |
| | | let isRecording: Bool |
| | | let canRestore: Bool |
| | | let onCustomize: () -> Void |
| | | let onRestore: () -> Void |
| | | |
| | | 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: 7) { |
| | | Text(isRecording ? "…" : displayText) |
| | | .font(.system(size: 15, weight: .semibold)) |
| | | .lineLimit(1) |
| | | .minimumScaleFactor(0.85) |
| | | |
| | | HStack(spacing: 6) { |
| | | Text(statusText) |
| | | .font(.system(size: 11, weight: .semibold)) |
| | | .foregroundStyle(statusTone.foreground) |
| | | .padding(.horizontal, 8) |
| | | .padding(.vertical, 3) |
| | | .background(Capsule().fill(statusTone.background)) |
| | | |
| | | if isRecording { |
| | | Text(tr("hotkeys.recordingInline")) |
| | | .font(.system(size: 11, weight: .semibold)) |
| | | .foregroundStyle(.blue) |
| | | } |
| | | } |
| | | |
| | | HStack(spacing: 8) { |
| | | Button(customizeTitle, action: onCustomize) |
| | | .buttonStyle(.borderedProminent) |
| | | .controlSize(.small) |
| | | |
| | | Button(restoreTitle, action: onRestore) |
| | | .buttonStyle(.bordered) |
| | | .controlSize(.small) |
| | | .disabled(!canRestore) |
| | | } |
| | | } |
| | | .frame(width: 230, 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 SettingsConfirmationView: View { |
| | | let title: String |
| | | let message: String |