| | |
| | | private let settingsWindowWidth: CGFloat = 1000 |
| | | private let settingsWindowHeight: CGFloat = 640 |
| | | private let settingsContentWidth: CGFloat = 940 |
| | | private let languageContentWidth: CGFloat = 820 |
| | | private let generalContentWidth: CGFloat = 720 |
| | | private let generalLabelWidth: CGFloat = 190 |
| | | private let generalControlWidth: CGFloat = 500 |
| | |
| | | @State private var hotkeyStatusToastToken: UUID? = nil |
| | | @State private var recordingHotkeyKind: LauncherHotkeyKind? = nil |
| | | @State private var hotkeyRecorderMonitor: Any? = nil |
| | | @State private var settingsEscapeMonitor: 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 |
| | | @State private var settingsCustomContainerQuotaStatus: ProCustomContainerQuotaStatus? = nil |
| | | @State private var settingsCustomContainerQuotaOverviewStatus = ProEntitlementPolicy.customContainerQuotaStatus() |
| | | @State private var themePreviewNow = Date() |
| | | |
| | | init(initialTabRawValue: String? = nil) { |
| | |
| | | apps = TagEditor.annotate(apps: apps) |
| | | let colors = store.tags.mapValues { $0.color } |
| | | let customColors = store.tags.compactMapValues { $0.customColor } |
| | | let quotaStatus = ProEntitlementPolicy.customContainerQuotaStatus(in: store) |
| | | DispatchQueue.main.async { |
| | | allApps = apps |
| | | tagColors = colors |
| | | tagCustomColors = customColors |
| | | categoryScheme = store.categoryScheme |
| | | settingsCustomContainerQuotaOverviewStatus = quotaStatus |
| | | } |
| | | } |
| | | } |
| | |
| | | } |
| | | } |
| | | |
| | | private func installSettingsEscapeMonitor() { |
| | | guard settingsEscapeMonitor == nil else { return } |
| | | settingsEscapeMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in |
| | | guard Int(event.keyCode) == kVK_Escape, |
| | | isSettingsOverlayEscapeEvent(event) |
| | | else { return event } |
| | | return handleSettingsEscapeKey() ? nil : event |
| | | } |
| | | } |
| | | |
| | | private func removeSettingsEscapeMonitor() { |
| | | if let settingsEscapeMonitor { |
| | | NSEvent.removeMonitor(settingsEscapeMonitor) |
| | | } |
| | | settingsEscapeMonitor = nil |
| | | } |
| | | |
| | | private func isSettingsOverlayEscapeEvent(_ event: NSEvent) -> Bool { |
| | | guard let window = preferencesWindow, |
| | | window.isVisible, |
| | | let parent = window.parent as? OverlayPanel, |
| | | parent.isVisible |
| | | else { return false } |
| | | |
| | | if let eventWindow = event.window { |
| | | return eventWindow == window || eventWindow == parent || eventWindow.parent == window |
| | | } |
| | | return NSApp.keyWindow == window || NSApp.keyWindow == parent || NSApp.mainWindow == window |
| | | } |
| | | |
| | | private func handleSettingsEscapeKey() -> Bool { |
| | | guard let window = preferencesWindow else { return false } |
| | | guard !isDataFilePanelPresented, window.attachedSheet == nil else { return false } |
| | | |
| | | if settingsProPromptFeature != nil { |
| | | dismissSettingsProPrompt() |
| | | return true |
| | | } |
| | | |
| | | if showApplySystemSchemeConfirmation { |
| | | withAnimation(.easeOut(duration: 0.14)) { |
| | | showApplySystemSchemeConfirmation = false |
| | | } |
| | | return true |
| | | } |
| | | |
| | | if showResetToUncategorizedConfirmation { |
| | | withAnimation(.easeOut(duration: 0.14)) { |
| | | showResetToUncategorizedConfirmation = false |
| | | } |
| | | return true |
| | | } |
| | | |
| | | if recordingHotkeyKind != nil { |
| | | stopHotkeyRecording(showCancelledToast: true) |
| | | return true |
| | | } |
| | | |
| | | window.performClose(nil) |
| | | return true |
| | | } |
| | | |
| | | private var preferencesWindow: NSWindow? { |
| | | if let taggedWindow = NSApp.windows.first(where: { |
| | | $0.identifier?.rawValue == "TagLauncherPreferencesWindow" && $0.isVisible |
| | |
| | | .first(where: isPreferencesWindowFallback(_:)) |
| | | } |
| | | |
| | | private var localizedPreferencesWindowTitle: String { |
| | | tr("menu.preferences").replacingOccurrences(of: "…", with: "") |
| | | } |
| | | |
| | | private func refreshPreferencesWindowTitle() { |
| | | preferencesWindow?.title = localizedPreferencesWindowTitle |
| | | } |
| | | |
| | | 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 |
| | | return window.title == localizedPreferencesWindowTitle |
| | | } |
| | | |
| | | private func showDataAlert(title: String, message: String) { |
| | |
| | | } |
| | | } |
| | | |
| | | private var effectiveCustomContainerQuotaStatus: ProCustomContainerQuotaStatus { |
| | | if proEntitlement.isUnlocked { |
| | | return ProCustomContainerQuotaStatus(isUnlimited: true, used: 0, limit: Int.max) |
| | | } |
| | | return settingsCustomContainerQuotaOverviewStatus |
| | | } |
| | | |
| | | private var customContainerQuotaAccessory: some View { |
| | | let status = effectiveCustomContainerQuotaStatus |
| | | let text = status.isUnlimited |
| | | ? tr("pro.customContainers.quotaUnlimitedShort") |
| | | : tr( |
| | | "pro.customContainers.quotaShort", |
| | | replacements: [ |
| | | "%used%": "\(status.used)", |
| | | "%limit%": "\(status.limit)" |
| | | ] |
| | | ) |
| | | return Text(text) |
| | | .font(.system(size: 12, weight: .medium)) |
| | | .foregroundStyle(.secondary) |
| | | .lineLimit(1) |
| | | } |
| | | |
| | | private func refreshSettingsCustomContainerQuotaStatus(_ status: ProCustomContainerQuotaStatus) { |
| | | settingsCustomContainerQuotaOverviewStatus = status |
| | | } |
| | | |
| | | private func refreshSettingsCustomContainerQuotaStatus(in store: TagDatabase.Store? = nil) { |
| | | settingsCustomContainerQuotaOverviewStatus = ProEntitlementPolicy.customContainerQuotaStatus(in: store) |
| | | } |
| | | |
| | | private var shouldShowSettingsFixedProHeader: Bool { |
| | | true |
| | | } |
| | |
| | | |
| | | private var proTabComparisonFeatures: [ProTabComparisonFeature] { |
| | | [ |
| | | ProTabComparisonFeature( |
| | | title: tr("pro.feature.maintainableTagCount"), |
| | | freeStatusKey: "settings.proStatus.maxSixTags", |
| | | proLockedStatusKey: "settings.proStatus.unlimitedTags", |
| | | proUnlockedStatusKey: "settings.proStatus.unlimitedTags", |
| | | freeStyle: .neutral, |
| | | proLockedStyle: .neutral, |
| | | proUnlockedStyle: .neutral |
| | | ), |
| | | ProTabComparisonFeature( |
| | | title: tr("pro.feature.tagOrderPersistence"), |
| | | freeStatusKey: "settings.proStatus.previewOnly", |
| | | proLockedStatusKey: "settings.proStatus.supported", |
| | | proUnlockedStatusKey: "settings.proStatus.supported", |
| | | freeStyle: .neutral, |
| | | proLockedStyle: .neutral, |
| | | proUnlockedStyle: .neutral |
| | | ), |
| | | ProTabComparisonFeature( |
| | | title: tr("pro.feature.containerAppOrderPersistence"), |
| | | freeStatusKey: "settings.proStatus.previewOnly", |
| | | proLockedStatusKey: "settings.proStatus.supported", |
| | | proUnlockedStatusKey: "settings.proStatus.supported", |
| | | freeStyle: .neutral, |
| | | proLockedStyle: .neutral, |
| | | proUnlockedStyle: .neutral |
| | | ), |
| | | ProTabComparisonFeature( |
| | | title: tr("pro.feature.customTagColors"), |
| | | freeStatusKey: "settings.proStatus.unavailableFeature", |
| | |
| | | return effectiveDisplayMode == mode ? .checkmark : .circle |
| | | } |
| | | |
| | | private func presentSettingsProPrompt(for feature: ProFeature) { |
| | | private func presentSettingsProPrompt( |
| | | for feature: ProFeature, |
| | | customContainerQuotaStatus: ProCustomContainerQuotaStatus? = nil |
| | | ) { |
| | | proEntitlement.clearTransientOperationState() |
| | | settingsCustomContainerQuotaStatus = customContainerQuotaStatus |
| | | withAnimation(.spring(response: 0.22, dampingFraction: 0.84)) { |
| | | settingsProPromptFeature = feature |
| | | } |
| | | } |
| | | |
| | | private func presentSettingsCustomContainerQuotaPrompt(_ status: ProCustomContainerQuotaStatus) { |
| | | presentSettingsProPrompt( |
| | | for: .customContainerQuota, |
| | | customContainerQuotaStatus: status |
| | | ) |
| | | } |
| | | |
| | | private var themePreviewCountdownText: String? { |
| | |
| | | withAnimation(.easeOut(duration: 0.18)) { |
| | | settingsProPromptFeature = nil |
| | | } |
| | | settingsCustomContainerQuotaStatus = nil |
| | | } |
| | | |
| | | private func settingsProPromptMessage(for feature: ProFeature) -> String { |
| | | if feature == .customContainerQuota, |
| | | let status = settingsCustomContainerQuotaStatus, |
| | | !status.isUnlimited { |
| | | return tr( |
| | | "pro.customContainers.quotaPrompt", |
| | | replacements: [ |
| | | "%used%": "\(status.used)", |
| | | "%limit%": "\(status.limit)", |
| | | "%remaining%": "\(status.remaining)" |
| | | ] |
| | | ) |
| | | } |
| | | return tr(feature.promptMessageKey) |
| | | } |
| | | |
| | | private func settingsProPromptSecondaryTitle(for feature: ProFeature) -> String { |
| | | feature == .customContainerQuota |
| | | ? tr("pro.customContainers.manageExisting") |
| | | : tr("pro.card.restore") |
| | | } |
| | | |
| | | private func handleSettingsProPromptSecondaryAction(for feature: ProFeature) { |
| | | if feature == .customContainerQuota { |
| | | dismissSettingsProPrompt() |
| | | selectedTab = .tags |
| | | return |
| | | } |
| | | proEntitlement.restorePurchases() |
| | | } |
| | | |
| | | private func handleProAccessStateChange(_ newState: ProAccessState) { |
| | |
| | | switch selectedTab { |
| | | case .language: |
| | | // Tab 1: Language |
| | | centeredSettingsScrollContent(width: settingsContentWidth) { |
| | | centeredSettingsScrollContent(width: languageContentWidth) { |
| | | LazyVGrid(columns: languageColumns, alignment: .leading, spacing: 6) { |
| | | Button { |
| | | selectedLanguage = L10n.automaticCode |
| | |
| | | excludedTagNames: ["Mac自带", defaultGroupName], |
| | | isCustomColorUnlocked: proEntitlement.isUnlocked, |
| | | onLockedCustomColor: { presentSettingsProPrompt(for: .customTagColors) }, |
| | | onRefresh: { scanApps() } |
| | | onCustomContainerQuotaExceeded: presentSettingsCustomContainerQuotaPrompt, |
| | | onCustomContainerQuotaStatusChanged: refreshSettingsCustomContainerQuotaStatus, |
| | | topLeadingAccessory: AnyView(customContainerQuotaAccessory), |
| | | onRefresh: { |
| | | refreshSettingsCustomContainerQuotaStatus() |
| | | scanApps() |
| | | } |
| | | ) |
| | | } |
| | | .onAppear { scanApps() } |
| | | .onAppear { |
| | | refreshSettingsCustomContainerQuotaStatus() |
| | | scanApps() |
| | | } |
| | | |
| | | case .data: |
| | | // Tab 5: Data |
| | |
| | | } else { |
| | | syncSelectedLanguage() |
| | | } |
| | | refreshPreferencesWindowTitle() |
| | | scanApps() |
| | | refreshDataState() |
| | | showLanguageRefresh() |
| | |
| | | |
| | | ProUpgradePromptView( |
| | | title: tr(feature.titleKey), |
| | | message: tr(feature.promptMessageKey), |
| | | message: settingsProPromptMessage(for: feature), |
| | | benefitText: tr(feature.benefitKey), |
| | | operationText: proEntitlement.operationState.statusMessageKey.map(tr), |
| | | unlockTitle: unlockButtonTitle(), |
| | | restoreTitle: tr("pro.card.restore"), |
| | | restoreTitle: settingsProPromptSecondaryTitle(for: feature), |
| | | isBusy: proEntitlement.operationState.isBusy, |
| | | onClose: dismissSettingsProPrompt, |
| | | onUnlock: { proEntitlement.purchasePro() }, |
| | | onRestore: { proEntitlement.restorePurchases() } |
| | | onRestore: { handleSettingsProPromptSecondaryAction(for: feature) } |
| | | ) |
| | | } |
| | | .zIndex(4) |
| | |
| | | .frame(width: settingsWindowWidth, height: settingsWindowHeight) |
| | | .onAppear { |
| | | syncSelectedLanguage() |
| | | refreshPreferencesWindowTitle() |
| | | installSettingsEscapeMonitor() |
| | | showPendingHotkeyWarningIfNeeded() |
| | | refreshThemePreviewCountdown() |
| | | } |
| | | .onDisappear { |
| | | removeSettingsEscapeMonitor() |
| | | stopHotkeyRecording() |
| | | } |
| | | .onReceive(themePreviewCountdownTimer) { now in |