Ariver
2026-07-02 b2a06ec5ebc86c0241e635782ae5a032ab5aad0a
src/Apptag/PreferencesView.swift
@@ -54,6 +54,7 @@
    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
@@ -93,10 +94,13 @@
    @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) {
@@ -116,11 +120,13 @@
            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
            }
        }
    }
@@ -230,6 +236,68 @@
        }
    }
    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
@@ -242,14 +310,21 @@
            .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) {
@@ -688,6 +763,23 @@
        Color(red: 0.56, green: 0.35, blue: 0.03)
    }
    @ViewBuilder
    private func proHeaderStatusLabel(compact: Bool = false) -> some View {
        if proEntitlement.accessState == .free && !proEntitlement.isPreviewingProAppearance {
            Text(tr("settings.proStatus.freeUser"))
                .font(.system(size: compact ? 12 : 13, weight: .semibold))
                .foregroundStyle(.secondary)
                .lineLimit(1)
                .minimumScaleFactor(0.82)
        } else {
            ProStatusPill(
                text: tr(proEntitlement.compactStatusTextKey),
                style: proStatusPillStyle,
                compact: compact
            )
        }
    }
    private func centeredSettingsScrollContent<Content: View>(
        width: CGFloat,
        @ViewBuilder content: @escaping () -> Content
@@ -733,10 +825,7 @@
        compact: Bool = false
    ) -> some View {
        HStack(spacing: compact ? 8 : 12) {
            ProStatusPill(
                text: tr(proEntitlement.compactStatusTextKey),
                style: proStatusPillStyle
            )
            proHeaderStatusLabel(compact: compact)
            .layoutPriority(1)
            Text(tr(textKey))
@@ -811,7 +900,9 @@
        }
        switch selectedTab {
        case .general, .language, .about:
        case .language, .about:
            return ""
        case .general:
            return tr(proEntitlement.isUnlocked ? "settings.proBenefit.general" : "settings.proGuide.general")
        case .theme:
            return themeStatusSummary
@@ -826,44 +917,105 @@
        }
    }
    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 {
        selectedTab != .language && selectedTab != .about
        true
    }
    private var shouldCenterUnlockedIdentityHeader: Bool {
        guard proEntitlement.isUnlocked else { return false }
        switch selectedTab {
        case .language, .pro, .about:
            return true
        case .general, .theme, .hotkeys, .tags, .data:
            return false
        }
    }
    private var settingsFixedProHeader: some View {
        HStack {
            HStack(spacing: 12) {
                if selectedTab != .pro {
                if shouldCenterUnlockedIdentityHeader {
                    Spacer(minLength: 0)
                    ProStatusPill(
                        text: tr(proEntitlement.compactStatusTextKey),
                        style: proStatusPillStyle
                        text: tr("pro.status.unlocked"),
                        style: .unlocked,
                        compact: true
                    )
                    .layoutPriority(1)
                }
                Text(settingsProHeaderText)
                    .font(.system(size: 13, weight: .medium))
                    .foregroundStyle(.secondary)
                    .lineLimit(2)
                    .fixedSize(horizontal: false, vertical: true)
                    .layoutPriority(2)
                    Text(tr("settings.proStatus.proUser"))
                        .font(.system(size: 13, weight: .medium))
                        .foregroundStyle(.secondary)
                        .lineLimit(1)
                        .fixedSize(horizontal: false, vertical: true)
                        .layoutPriority(2)
                Spacer(minLength: 8)
                if !proEntitlement.isUnlocked {
                    Button(unlockButtonTitle()) {
                        presentSettingsProPrompt(for: settingsProHeaderFeature)
                    Spacer(minLength: 0)
                } else {
                    if selectedTab != .pro {
                        proHeaderStatusLabel(compact: true)
                        .layoutPriority(1)
                    }
                    .buttonStyle(.borderedProminent)
                    .controlSize(.small)
                    .layoutPriority(3)
                    Button(tr("pro.card.restore")) {
                        proEntitlement.restorePurchases()
                    if !settingsProHeaderText.isEmpty {
                        Text(settingsProHeaderText)
                            .font(.system(size: 13, weight: .medium))
                            .foregroundStyle(.secondary)
                            .lineLimit(2)
                            .fixedSize(horizontal: false, vertical: true)
                            .layoutPriority(2)
                    }
                    .buttonStyle(.bordered)
                    .controlSize(.small)
                    .layoutPriority(3)
                    Spacer(minLength: 8)
                    if !proEntitlement.isUnlocked {
                        Button(unlockButtonTitle()) {
                            presentSettingsProPrompt(for: settingsProHeaderFeature)
                        }
                        .buttonStyle(.borderedProminent)
                        .controlSize(.small)
                        .layoutPriority(3)
                        Button(tr("pro.card.restore")) {
                            proEntitlement.restorePurchases()
                        }
                        .buttonStyle(.bordered)
                        .controlSize(.small)
                        .layoutPriority(3)
                    }
                }
            }
            .padding(.horizontal, 16)
@@ -887,6 +1039,33 @@
    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",
@@ -953,7 +1132,7 @@
                    Text(tr("settings.proCompare.free"))
                        .font(.system(size: 12, weight: .semibold))
                        .foregroundStyle(.secondary)
                        .frame(width: 118, alignment: .center)
                        .frame(width: 118, alignment: .leading)
                    HStack(spacing: 4) {
                        Image(systemName: "crown")
                            .font(.system(size: 12, weight: .semibold))
@@ -963,7 +1142,7 @@
                    .foregroundStyle(proAccentColor)
                    .lineLimit(1)
                    .minimumScaleFactor(0.82)
                    .frame(width: 118, alignment: .center)
                    .frame(width: 118, alignment: .leading)
                }
                ForEach(proTabComparisonFeatures) { feature in
@@ -983,14 +1162,14 @@
                            style: feature.freeStyle,
                            compact: true
                        )
                        .frame(width: 118, alignment: .center)
                        .frame(width: 118, alignment: .leading)
                        ProStatusPill(
                            text: tr(proStatusKey),
                            style: proStyle,
                            compact: true
                        )
                        .frame(width: 118, alignment: .center)
                        .frame(width: 118, alignment: .leading)
                    }
                    .padding(.horizontal, 10)
                    .padding(.vertical, 8)
@@ -1124,11 +1303,22 @@
        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? {
@@ -1163,6 +1353,38 @@
        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) {
@@ -1354,10 +1576,7 @@
            proUnlockedBenefitLine("pro.theme.status.unlocked", bottomPadding: 0)
        } else {
            HStack(spacing: 12) {
                ProStatusPill(
                    text: tr(proEntitlement.compactStatusTextKey),
                    style: proStatusPillStyle
                )
                proHeaderStatusLabel()
                Text(themeStatusSummary)
                    .font(.system(size: 13, weight: .medium))
@@ -1445,7 +1664,7 @@
                    switch selectedTab {
                    case .language:
                // Tab 1: Language
                centeredSettingsScrollContent(width: settingsContentWidth) {
                centeredSettingsScrollContent(width: languageContentWidth) {
                        LazyVGrid(columns: languageColumns, alignment: .leading, spacing: 6) {
                            Button {
                                selectedLanguage = L10n.automaticCode
@@ -1638,10 +1857,19 @@
                    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
@@ -1866,6 +2094,7 @@
                } else {
                    syncSelectedLanguage()
                }
                refreshPreferencesWindowTitle()
                scanApps()
                refreshDataState()
                showLanguageRefresh()
@@ -1935,15 +2164,15 @@
                    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)
@@ -1975,10 +2204,13 @@
        .frame(width: settingsWindowWidth, height: settingsWindowHeight)
        .onAppear {
            syncSelectedLanguage()
            refreshPreferencesWindowTitle()
            installSettingsEscapeMonitor()
            showPendingHotkeyWarningIfNeeded()
            refreshThemePreviewCountdown()
        }
        .onDisappear {
            removeSettingsEscapeMonitor()
            stopHotkeyRecording()
        }
        .onReceive(themePreviewCountdownTimer) { now in