From 0ae3a9359e4cdf6ae5b669c4affd91662463f370 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Wed, 01 Jul 2026 23:17:36 +0800
Subject: [PATCH] Prepare TagLauncher 8.3.1 candidate

---
 src/Apptag/ContentView.swift |  831 +++++++++++++++++++++++++++++++++++++++++++++-------------
 1 files changed, 642 insertions(+), 189 deletions(-)

diff --git a/src/Apptag/ContentView.swift b/src/Apptag/ContentView.swift
index 87ffec3..3fede8e 100644
--- a/src/Apptag/ContentView.swift
+++ b/src/Apptag/ContentView.swift
@@ -16,7 +16,11 @@
 
 enum SettingsTabTarget {
     static let userInfoKey = "tab"
+    static let theme = "theme"
     static let tags = "tags"
+    static let data = "data"
+    static let pro = "pro"
+    static let about = "about"
 }
 
 // MARK: - Edit Phase
@@ -34,8 +38,8 @@
 
 // MARK: - Native NSTextField (avoids SwiftUI TextField event issues)
 
-/// Custom container that wraps NSTextField so that hitTest returns the container
-/// and mouseDown can reliably make the text field first responder.
+/// Custom container that wraps NSTextField so first focus is reliable while
+/// subsequent clicks keep native caret placement and selection behavior.
 final class TextFieldContainer: NSView {
     let textField: NSTextField
 
@@ -54,14 +58,33 @@
         textField.frame = bounds
     }
 
+    private var isEditing: Bool {
+        guard let window else { return false }
+        return window.firstResponder === textField || textField.currentEditor() != nil
+    }
+
     override func hitTest(_ point: NSPoint) -> NSView? {
-        if bounds.contains(point) { return self }
-        return nil
+        guard bounds.contains(point) else { return nil }
+        if isEditing {
+            let fieldPoint = textField.convert(point, from: self)
+            return textField.hitTest(fieldPoint) ?? textField
+        }
+        return self
+    }
+
+    func focusTextField() {
+        guard let w = window else { return }
+        let shouldSelectAll = !isEditing
+        w.makeFirstResponder(textField)
+        if shouldSelectAll {
+            textField.selectText(nil)
+        }
     }
 
     override func mouseDown(with event: NSEvent) {
-        if let w = window { w.makeFirstResponder(textField) }
-        textField.mouseDown(with: event)
+        // Do not forward this same NSEvent to NSTextField.mouseDown. AppKit can
+        // forward it back to this container during control tracking and recurse.
+        focusTextField()
     }
 }
 
@@ -211,19 +234,21 @@
 struct TagPill: View {
     let name: String
     let colorIndex: Int
+    var customColor: TagCustomColor? = nil
     var dragModeActive: Bool = false
     var isDragging: Bool = false
     let action: () -> Void
 
+    private var resolvedNSColor: NSColor {
+        TagColor.nsColor(for: colorIndex, customColor: customColor)
+    }
+
     private var bgColor: Color {
-        Color(nsColor: TagColor.nsColor(for: colorIndex))
+        Color(nsColor: resolvedNSColor)
     }
 
     private var textColor: Color {
-        if colorIndex == 0 || colorIndex == 5 {
-            return .primary
-        }
-        return .white
+        TagColor.prefersDarkText(for: resolvedNSColor) ? .primary : .white
     }
 
     var body: some View {
@@ -250,15 +275,21 @@
 struct SideTagPill: View {
     let name: String
     let colorIndex: Int
+    var customColor: TagCustomColor? = nil
     var dragModeActive: Bool = false
     var isDragging: Bool = false
     let action: () -> Void
 
-    private var bgColor: Color {
-        Color(nsColor: TagColor.nsColor(for: colorIndex))
+    private var resolvedNSColor: NSColor {
+        TagColor.nsColor(for: colorIndex, customColor: customColor)
     }
+
+    private var bgColor: Color {
+        Color(nsColor: resolvedNSColor)
+    }
+
     private var textColor: Color {
-        colorIndex == 0 || colorIndex == 5 ? .primary : .white
+        TagColor.prefersDarkText(for: resolvedNSColor) ? .primary : .white
     }
 
     var body: some View {
@@ -326,8 +357,11 @@
     var editingBubble: AppBubbleContext? = nil
     var pendingUncategorizedDrop: PendingUncategorizedDrop? = nil
     var pendingTagRemovalDrop: PendingTagRemovalDrop? = nil
+    var proPrompt: PendingProPrompt? = nil
     var uncategorizedDropSuppressFuturePrompt = false
     var tagRemovalDropSuppressFuturePrompt = false
+    var usageTipsCloseReminderVisible = false
+    var usageTipsCloseSuppressFuturePrompt = false
     var appDragResetToken = 0
     var bubbleDraftNote = ""
     var tagNavigationHoveredGroupName: String? = nil
@@ -355,15 +389,25 @@
     let tagName: String
 }
 
+private struct PendingProPrompt: Identifiable {
+    let id = UUID()
+    let feature: ProFeature
+    let noteQuotaStatus: ProNoteQuotaStatus?
+    let customContainerQuotaStatus: ProCustomContainerQuotaStatus?
+}
+
 struct ContentView: View {
     let hideOverlay: () -> Void
     private let initialQuickSearchSource: String?
 
     @Environment(\.colorScheme) private var colorScheme
+    @ObservedObject private var proEntitlement = ProEntitlementCenter.shared
     @State private var allApps: [AppInfo] = []
     @State private var displayGroups: [TagGroup] = []
     @State private var tagColors: [String: Int] = [:]
+    @State private var tagCustomColors: [String: TagCustomColor] = [:]
     @State private var tagDefinitions: [String: TagDatabase.TagDef] = [:]
+    @State private var customContainerQuotaStatus = ProEntitlementPolicy.customContainerQuotaStatus()
     @State private var containerAppOrder: [String: [String]] = [:]
     @State private var groupLayoutVersion = 0
     @State private var appGridScrollTargetID: String? = nil
@@ -435,9 +479,12 @@
     @AppStorage("showUncommonAppBubbles") private var showUncommonAppBubbles = AppDefaults.showUncommonAppBubbles
     @AppStorage("hideUsageTips") private var hideUsageTips = AppDefaults.hideUsageTips
     @AppStorage(AppGridTheme.storageKey) private var appGridThemeID = AppDefaults.appGridThemeID
+    @AppStorage(AppGridTheme.deepBlueTuningStorageKey) private var deepBlueThemeTuning = AppGridTheme.deepBlue.defaultColorTuning
+    @AppStorage(AppGridTheme.colorfulTuningStorageKey) private var colorfulThemeTuning = AppGridTheme.colorful.defaultColorTuning
     @AppStorage("useAppKitTagNavigation") private var useAppKitTagNavigation = AppDefaults.useAppKitTagNavigation
     @AppStorage("skipTagRemovalDropConfirm") private var skipTagRemovalDropConfirm = false
     @AppStorage("skipUncategorizedDropConfirm") private var skipUncategorizedDropConfirm = false
+    @AppStorage("skipUsageTipsCloseReminder") private var skipUsageTipsCloseReminder = false
 
     private let editSidebarWidth: CGFloat = 188
     private let editSidebarHorizontalInset: CGFloat = 12
@@ -449,6 +496,8 @@
             || appGridInteraction.appDragModeActive
             || appGridInteraction.pendingUncategorizedDrop != nil
             || appGridInteraction.pendingTagRemovalDrop != nil
+            || appGridInteraction.usageTipsCloseReminderVisible
+            || appGridInteraction.proPrompt != nil
     }
     private var appGridHighlightedGroupName: String? {
         appGridInteraction.tagNavigationHoveredGroupName
@@ -468,7 +517,32 @@
     }
 
     private var renderedAppGridTheme: AppGridTheme {
-        editPhase == .none ? appGridTheme : .defaultLight
+        editPhase == .none
+            ? ProEntitlementPolicy.effectiveTheme(for: appGridTheme)
+            : .defaultLight
+    }
+
+    private var renderedAppGridThemeTuning: Double? {
+        guard editPhase == .none else { return nil }
+        return ProEntitlementPolicy.effectiveThemeTuning(
+            for: appGridTheme,
+            storedTuning: storedThemeTuning(for: appGridTheme)
+        )
+    }
+
+    private var renderedDisplayMode: String {
+        ProEntitlementPolicy.effectiveDisplayMode(for: displayMode)
+    }
+
+    private func storedThemeTuning(for theme: AppGridTheme) -> Double? {
+        switch theme {
+        case .deepBlue:
+            return deepBlueThemeTuning
+        case .colorful:
+            return colorfulThemeTuning
+        case .defaultLight, .black, .pink, .purple, .green, .blue:
+            return nil
+        }
     }
 
     private var isSideLayout: Bool {
@@ -480,7 +554,7 @@
     }
 
     private var isColorlessContainerMode: Bool {
-        displayMode == "container" || displayMode == "gridContainer"
+        renderedDisplayMode == "container" || renderedDisplayMode == "gridContainer"
     }
 
     private var shouldShowUsageTips: Bool {
@@ -508,176 +582,174 @@
     }
 
     var body: some View {
+        contentStack
+            .onAppear(perform: handleContentAppear)
+            .onReceive(NotificationCenter.default.publisher(for: .tagLauncherOverlayDidShow), perform: handleOverlayDidShow)
+            .onReceive(NotificationCenter.default.publisher(for: .tagLauncherOverlayDidHide), perform: handleOverlayDidHide)
+            .onReceive(NotificationCenter.default.publisher(for: .tagLauncherQuickSearchRequested), perform: handleQuickSearchRequested)
+            .onReceive(NotificationCenter.default.publisher(for: .tagLauncherQuickSearchDismissRequested), perform: handleQuickSearchDismissRequested)
+            .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification), perform: handleApplicationDidBecomeActive)
+            .onReceive(NotificationCenter.default.publisher(for: .tagLauncherDataDidChange), perform: handleDataDidChange)
+            .onReceive(NotificationCenter.default.publisher(for: .appLanguageDidChange), perform: handleAppLanguageDidChange)
+            .onChange(of: editPhase) { _, newPhase in
+                let active = newPhase != .none
+                dismissAppBubble()
+                NotificationCenter.default.post(
+                    name: .tagLauncherEditModeChanged,
+                    object: nil,
+                    userInfo: ["active": active]
+                )
+            }
+            .onChange(of: bubbleNoteFocused) { _, focused in
+                if appGridInteraction.editingBubble != nil && !focused {
+                    commitBubbleNote()
+                }
+            }
+            .onChange(of: draggedTagNames) { _, newOrder in
+                rebuildDisplayGroups(apps: allApps, tagOrder: newOrder)
+            }
+            .onChange(of: defaultGroupName) { _, _ in
+                rebuildDisplayGroups(apps: allApps, tagOrder: draggedTagNames)
+            }
+            .onChange(of: quickSearchQuery) { _, _ in
+                quickSearchErrorMessage = nil
+                refreshQuickSearchResults()
+            }
+            .onChange(of: appGridInteraction.pendingUncategorizedDrop != nil) { _, _ in
+                publishModalInteractionState()
+            }
+            .onChange(of: appGridInteraction.pendingTagRemovalDrop != nil) { _, _ in
+                publishModalInteractionState()
+            }
+            .onChange(of: appGridInteraction.proPrompt != nil) { _, _ in
+                publishModalInteractionState()
+            }
+            .onChange(of: smartStartNotice != nil) { _, _ in
+                publishModalInteractionState()
+            }
+            .onChange(of: proEntitlement.accessState) { _, newState in
+                handleProAccessStateChange(newState)
+            }
+            .onDisappear {
+                NotificationCenter.default.post(
+                    name: .tagLauncherModalInteractionChanged,
+                    object: nil,
+                    userInfo: ["active": false]
+                )
+            }
+    }
+
+    @ViewBuilder
+    private var contentStack: some View {
         ZStack {
             if shouldRenderAppGridBehindQuickSearch {
                 appGridBackground
             }
 
             if shouldRenderAppGridBehindQuickSearch {
-                if notchHeight > 0 {
-                    VStack {
-                        Rectangle().fill(.black)
-                            .frame(height: notchHeight)
-                            .ignoresSafeArea(edges: .top)
-                        Spacer()
-                    }
-                    .allowsHitTesting(false)
-                }
-
-                switch editPhase {
-                case .none:
-                    normalContent
-                case .editingTags:
-                    editTagsView
-                case .editingApps:
-                    editAppsView
-                }
-
-                uncommonAppBubbleOverlay
-                smartStartNoticeOverlay
-                editActionFeedbackOverlay
-                uncategorizedDropConfirmOverlay
-                tagRemovalDropConfirmOverlay
+                appGridInteractionLayer
             }
 
             quickSearchOverlay
 
-            if shouldRenderAppGridBehindQuickSearch, let message = appGridInteraction.dropWarningToast {
-                Text(message)
-                    .font(.system(size: 16, weight: .semibold))
-                    .foregroundStyle(.primary)
-                    .padding(.horizontal, 22)
-                    .padding(.vertical, 12)
-                    .background(
-                        RoundedRectangle(cornerRadius: 10)
-                            .fill(.ultraThickMaterial)
-                            .shadow(color: .black.opacity(0.22), radius: 18, y: 10)
-                    )
-                    .transition(.scale(scale: 0.96).combined(with: .opacity))
-                    .allowsHitTesting(false)
-            }
-
-            if shouldRenderAppGridBehindQuickSearch && appGridInteraction.dropRefreshVisible {
-                Color.black.opacity(0.08)
-                    .ignoresSafeArea()
-                    .transition(.opacity)
-                    .allowsHitTesting(false)
-
-                ProgressView()
-                    .progressViewStyle(.circular)
-                    .controlSize(.large)
-                    .scaleEffect(1.15)
-                    .padding(24)
-                    .background(
-                        RoundedRectangle(cornerRadius: 12)
-                            .fill(.ultraThickMaterial)
-                            .shadow(color: .black.opacity(0.26), radius: 24, y: 12)
-                    )
-                    .transition(.scale(scale: 0.92).combined(with: .opacity))
-                    .allowsHitTesting(false)
-            }
+            dropWarningToastOverlay
+            dropRefreshOverlay
         }
-        .onAppear {
-            refreshNotchHeight()
-            refreshAppsForOverlay()
-            if let initialQuickSearchSource, !initialQuickSearchConsumed {
-                initialQuickSearchConsumed = true
-                if initialQuickSearchSource == QuickSearchOpenSource.globalHidden {
-                    quickSearchCloseHidesOverlay = true
-                    quickSearchOnlySession = true
-                }
-                if !quickSearchVisible {
-                    quickSearchVisible = true
-                    quickSearchFocusToken &+= 1
-                }
-                quickSearchOpenedAt = Date()
-                refreshQuickSearchResults()
-                refreshAppsForQuickSearch()
-                NotificationCenter.default.post(
-                    name: .tagLauncherQuickSearchVisibilityChanged,
-                    object: nil,
-                    userInfo: [
-                        "active": true,
-                        "hideOverlayOnClose": quickSearchCloseHidesOverlay
-                    ]
+    }
+
+    @ViewBuilder
+    private var appGridInteractionLayer: some View {
+        if notchHeight > 0 {
+            VStack {
+                Rectangle().fill(.black)
+                    .frame(height: notchHeight)
+                    .ignoresSafeArea(edges: .top)
+                Spacer()
+            }
+            .allowsHitTesting(false)
+        }
+
+        switch editPhase {
+        case .none:
+            normalContent
+        case .editingTags:
+            editTagsView
+        case .editingApps:
+            editAppsView
+        }
+
+        uncommonAppBubbleOverlay
+        smartStartNoticeOverlay
+        editActionFeedbackOverlay
+        uncategorizedDropConfirmOverlay
+        tagRemovalDropConfirmOverlay
+        usageTipsCloseReminderOverlay
+        proAccessPromptOverlay
+    }
+
+    @ViewBuilder
+    private var dropWarningToastOverlay: some View {
+        if shouldRenderAppGridBehindQuickSearch, let message = appGridInteraction.dropWarningToast {
+            Text(message)
+                .font(.system(size: 16, weight: .semibold))
+                .foregroundStyle(.primary)
+                .padding(.horizontal, 22)
+                .padding(.vertical, 12)
+                .background(
+                    RoundedRectangle(cornerRadius: 10)
+                        .fill(.ultraThickMaterial)
+                        .shadow(color: .black.opacity(0.22), radius: 18, y: 10)
+                )
+                .transition(.scale(scale: 0.96).combined(with: .opacity))
+                .allowsHitTesting(false)
+        }
+    }
+
+    @ViewBuilder
+    private var proAccessPromptOverlay: some View {
+        if let prompt = appGridInteraction.proPrompt {
+            ZStack {
+                Color.black.opacity(0.16)
+                    .ignoresSafeArea()
+
+                ProUpgradePromptView(
+                    title: tr(prompt.feature.titleKey),
+                    message: proPromptMessage(for: prompt),
+                    benefitText: tr(prompt.feature.benefitKey),
+                    operationText: proEntitlement.operationState.statusMessageKey.map(tr),
+                    unlockTitle: tr("pro.card.unlock"),
+                    restoreTitle: proPromptSecondaryTitle(for: prompt),
+                    isBusy: proEntitlement.operationState.isBusy,
+                    onClose: dismissProPrompt,
+                    onUnlock: { proEntitlement.purchasePro() },
+                    onRestore: { handleProPromptSecondaryAction(prompt) }
                 )
             }
+            .transition(.opacity)
+            .zIndex(920)
         }
-        .onReceive(NotificationCenter.default.publisher(for: .tagLauncherOverlayDidShow)) { _ in
-            resetTransientDragState()
-            refreshNotchHeight()
-            refreshAppsForOverlay()
-        }
-        .onReceive(NotificationCenter.default.publisher(for: .tagLauncherOverlayDidHide)) { _ in
-            resetTransientDragState()
-            closeQuickSearch(notify: true, hideOverlayIfNeeded: false)
-            quickSearchOnlySession = false
-            quickSearchCloseHidesOverlay = false
-        }
-        .onReceive(NotificationCenter.default.publisher(for: .tagLauncherQuickSearchRequested)) { notification in
-            let source = notification.userInfo?["source"] as? String ?? QuickSearchOpenSource.mainOverlay
-            openQuickSearch(source: source)
-        }
-        .onReceive(NotificationCenter.default.publisher(for: .tagLauncherQuickSearchDismissRequested)) { notification in
-            let source = notification.userInfo?["source"] as? String
-            closeQuickSearch(dismissalSource: source)
-        }
-        .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
-            guard allApps.isEmpty, !refreshInProgress else { return }
-            refreshApps()
-        }
-        .onReceive(NotificationCenter.default.publisher(for: .tagLauncherDataDidChange)) { _ in
-            refreshApps(forceLayoutRefresh: true)
-        }
-        .onReceive(NotificationCenter.default.publisher(for: .appLanguageDidChange)) { _ in
-            let appsSnapshot = allApps
-            DispatchQueue.global(qos: .userInitiated).async {
-                _ = AppleDefaultAppCatalog.relocalizeDefaultNotesForCurrentLanguage(apps: appsSnapshot)
-                _ = SmartStartService.relocalizeDefaultNotesForCurrentLanguage(apps: appsSnapshot)
-                DispatchQueue.main.async {
-                    refreshApps(forceLayoutRefresh: true)
-                }
-            }
-        }
-        .onChange(of: editPhase) { _, newPhase in
-            let active = newPhase != .none
-            dismissAppBubble()
-            NotificationCenter.default.post(
-                name: .tagLauncherEditModeChanged,
-                object: nil,
-                userInfo: ["active": active]
-            )
-        }
-        .onChange(of: bubbleNoteFocused) { _, focused in
-            if appGridInteraction.editingBubble != nil && !focused {
-                commitBubbleNote()
-            }
-        }
-        .onChange(of: draggedTagNames) { _, newOrder in
-            rebuildDisplayGroups(apps: allApps, tagOrder: newOrder)
-        }
-        .onChange(of: defaultGroupName) { _, _ in
-            rebuildDisplayGroups(apps: allApps, tagOrder: draggedTagNames)
-        }
-        .onChange(of: quickSearchQuery) { _, _ in
-            quickSearchErrorMessage = nil
-            refreshQuickSearchResults()
-        }
-        .onChange(of: appGridInteraction.pendingUncategorizedDrop != nil) { _, _ in
-            publishModalInteractionState()
-        }
-        .onChange(of: appGridInteraction.pendingTagRemovalDrop != nil) { _, _ in
-            publishModalInteractionState()
-        }
-        .onChange(of: smartStartNotice != nil) { _, _ in
-            publishModalInteractionState()
-        }
-        .onDisappear {
-            NotificationCenter.default.post(
-                name: .tagLauncherModalInteractionChanged,
-                object: nil,
-                userInfo: ["active": false]
-            )
+    }
+
+    @ViewBuilder
+    private var dropRefreshOverlay: some View {
+        if shouldRenderAppGridBehindQuickSearch && appGridInteraction.dropRefreshVisible {
+            Color.black.opacity(0.08)
+                .ignoresSafeArea()
+                .transition(.opacity)
+                .allowsHitTesting(false)
+
+            ProgressView()
+                .progressViewStyle(.circular)
+                .controlSize(.large)
+                .scaleEffect(1.15)
+                .padding(24)
+                .background(
+                    RoundedRectangle(cornerRadius: 12)
+                        .fill(.ultraThickMaterial)
+                        .shadow(color: .black.opacity(0.26), radius: 24, y: 12)
+                )
+                .transition(.scale(scale: 0.92).combined(with: .opacity))
+                .allowsHitTesting(false)
         }
     }
 
@@ -689,6 +761,8 @@
                 "active": smartStartNotice != nil
                     || appGridInteraction.pendingUncategorizedDrop != nil
                     || appGridInteraction.pendingTagRemovalDrop != nil
+                    || appGridInteraction.usageTipsCloseReminderVisible
+                    || appGridInteraction.proPrompt != nil
             ]
         )
     }
@@ -701,7 +775,10 @@
             // Always sync tag list from database when entering edit mode
             let store = TagDatabase.load()
             tagColors = store.tags.mapValues { $0.color }
+            tagCustomColors = store.tags.compactMapValues { $0.customColor }
+            tagDefinitions = store.tags
             draggedTagNames = TagEditor.orderedTagNames()
+            refreshCustomContainerQuotaStatus(in: store)
         }
         editPhase = phase
         if phase == .none {
@@ -974,6 +1051,7 @@
 
     private var appGridBackground: some View {
         let theme = renderedAppGridTheme
+        let tuning = renderedAppGridThemeTuning
         return ZStack {
             if theme.usesVisualEffectBackdrop {
                 VisualEffectView(
@@ -986,12 +1064,12 @@
             }
             if !theme.isDefaultLight {
                 LinearGradient(
-                    colors: theme.backgroundBaseColors,
+                    colors: theme.backgroundBaseColors(tuning: tuning),
                     startPoint: .topLeading,
                     endPoint: .bottomTrailing
                 )
                 LinearGradient(
-                    colors: theme.backgroundAccentColors,
+                    colors: theme.backgroundAccentColors(tuning: tuning),
                     startPoint: .topTrailing,
                     endPoint: .bottomLeading
                 )
@@ -1145,6 +1223,7 @@
             HStack(spacing: 8) {
                 ForEach(tagLabels) { tag in
                     TagPill(name: tag.name, colorIndex: tag.colorIndex,
+                            customColor: tag.customColor,
                             dragModeActive: tagNavDragModeActive && canReorderTag(tag.name),
                             isDragging: tagNavDragItem == tag.name,
                             action: {
@@ -1172,6 +1251,7 @@
             VStack(spacing: 6) {
                 ForEach(tagLabels) { tag in
                     SideTagPill(name: tag.name, colorIndex: tag.colorIndex,
+                                customColor: tag.customColor,
                                 dragModeActive: tagNavDragModeActive && canReorderTag(tag.name),
                                 isDragging: tagNavDragItem == tag.name,
                                 action: {
@@ -1209,7 +1289,8 @@
                 AppGridCollectionView(
                     groups: displayGroups,
                     tagColors: tagColors,
-                    displayMode: displayMode,
+                    tagCustomColors: tagCustomColors,
+                    displayMode: renderedDisplayMode,
                     iconSize: iconSize,
                     showNames: !hideAppNames,
                     appGridTheme: renderedAppGridTheme,
@@ -1242,6 +1323,7 @@
                     },
                     onScrollActivity: handleAppGridScrollActivity,
                     onDragModeChange: { setAppDragMode($0) },
+                    onHideUsageTips: requestHideUsageTips,
                     onUsageTipsHoverChange: handleUsageTipsHoverChange
                 )
             }
@@ -1270,6 +1352,8 @@
                     isEditing: editing,
                     placement: placement,
                     arrowOffset: metrics.arrowOffset,
+                    noteQuotaText: editing ? noteQuotaHintText(for: context.app) : nil,
+                    noteQuotaWarning: editing && isNoteQuotaWarning(for: context.app),
                     draftNote: $appGridInteraction.bubbleDraftNote,
                     noteFocused: $bubbleNoteFocused,
                     onCommit: commitBubbleNote,
@@ -1333,10 +1417,51 @@
 
             TagEditorView(
                 tagColors: $tagColors,
+                tagCustomColors: $tagCustomColors,
                 excludedTagNames: ["Mac自带", defaultGroupName],
-                onRefresh: { refreshApps() }
+                isCustomColorUnlocked: proEntitlement.isUnlocked,
+                onLockedCustomColor: { presentProPrompt(for: .customTagColors) },
+                onCustomContainerQuotaExceeded: presentCustomContainerQuotaPrompt,
+                onCustomContainerQuotaStatusChanged: refreshCustomContainerQuotaStatus,
+                topLeadingAccessory: AnyView(customContainerQuotaAccessory),
+                onRefresh: {
+                    refreshCustomContainerQuotaStatus()
+                    refreshApps()
+                }
             )
         }
+    }
+
+    private var effectiveCustomContainerQuotaStatus: ProCustomContainerQuotaStatus {
+        if proEntitlement.isUnlocked {
+            return ProCustomContainerQuotaStatus(isUnlimited: true, used: 0, limit: Int.max)
+        }
+        return customContainerQuotaStatus
+    }
+
+    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 refreshCustomContainerQuotaStatus(_ status: ProCustomContainerQuotaStatus) {
+        customContainerQuotaStatus = status
+    }
+
+    private func refreshCustomContainerQuotaStatus(in store: TagDatabase.Store? = nil) {
+        customContainerQuotaStatus = ProEntitlementPolicy.customContainerQuotaStatus(in: store)
     }
 
     // MARK: - Edit Apps View
@@ -1488,7 +1613,7 @@
             .onEnded { _ in
                 guard canReorderTag(tagName) else { return }
                 dragItem = nil
-                TagEditor.reorderTags(draggedTagNames)
+                persistTagOrderIfAllowed()
             }
     }
 
@@ -1516,9 +1641,11 @@
 
         switch editTagOperation {
         case .add:
-            TagEditor.appendTags(tags, to: paths)
+            let result = TagEditor.appendTags(tags, to: paths)
+            guard handleTagMutationResult(result) else { return }
         case .remove:
-            TagEditor.removeTags(tags, from: paths)
+            let result = TagEditor.removeTags(tags, from: paths)
+            guard handleTagMutationResult(result) else { return }
         }
         selectedAppPaths = []
         selectedTagNames = []
@@ -1688,6 +1815,34 @@
         }
         .ignoresSafeArea()
         .allowsHitTesting(appGridInteraction.pendingTagRemovalDrop != nil)
+    }
+
+    private var usageTipsCloseReminderOverlay: some View {
+        GeometryReader { proxy in
+            if appGridInteraction.usageTipsCloseReminderVisible {
+                ZStack {
+                    Color.black.opacity(0.14)
+                        .ignoresSafeArea()
+
+                    UsageTipsCloseReminderBubble(
+                        title: tr("usageTips.closeReminder.title"),
+                        message: tr("usageTips.closeReminder.message"),
+                        previewCallout: tr("usageTips.closeReminder.previewCallout"),
+                        doNotRemindTitle: tr("usageTips.closeReminder.doNotRemind"),
+                        doNotRemind: $appGridInteraction.usageTipsCloseSuppressFuturePrompt,
+                        confirmTitle: tr("edit.confirm"),
+                        onCancel: dismissUsageTipsCloseReminder,
+                        onConfirm: confirmUsageTipsCloseReminder
+                    )
+                    .frame(width: min(760, max(420, proxy.size.width - 120)))
+                    .position(x: proxy.size.width / 2, y: proxy.size.height / 2)
+                    .transition(.scale(scale: 0.94).combined(with: .opacity))
+                }
+                .zIndex(712)
+            }
+        }
+        .ignoresSafeArea()
+        .allowsHitTesting(appGridInteraction.usageTipsCloseReminderVisible)
     }
 
     private func buildEditActionFeedback(for selectedApps: [AppInfo], tags: [String]) -> EditActionFeedback {
@@ -1938,7 +2093,13 @@
     }
 
     private var tagLabels: [TagNavigationItem] {
-        displayGroups.map { TagNavigationItem(name: $0.name, colorIndex: tagColors[$0.name] ?? 0) }
+        displayGroups.map {
+            TagNavigationItem(
+                name: $0.name,
+                colorIndex: tagColors[$0.name] ?? 0,
+                customColor: tagCustomColors[$0.name]
+            )
+        }
     }
 
     private var editGroups: [TagGroup] {
@@ -2039,10 +2200,18 @@
 
     private func commitBubbleNote() {
         guard let context = appGridInteraction.editingBubble else { return }
+        guard appGridInteraction.proPrompt == nil else { return }
         let limited = String(appGridInteraction.bubbleDraftNote.prefix(TagDatabase.maxAppNoteLength))
             .trimmingCharacters(in: .whitespacesAndNewlines)
-        TagEditor.setAppNote(limited, for: context.app.path.path)
+        let decision = TagEditor.setAppNote(limited, for: context.app.path.path)
         appGridInteraction.bubbleDraftNote = limited
+        guard case .allow = decision else {
+            if case .blocked(let status) = decision {
+                presentProPrompt(for: .unlimitedNotes, noteQuotaStatus: status)
+            }
+            bubbleNoteFocused = false
+            return
+        }
         appGridInteraction.editingBubble = nil
         bubbleNoteFocused = false
         notifyAppNoteEditing(active: false)
@@ -2086,6 +2255,215 @@
         return app.note ?? ""
     }
 
+    private func noteQuotaStatus(for app: AppInfo) -> ProNoteQuotaStatus? {
+        guard !proEntitlement.isUnlocked else { return nil }
+        let decision = ProEntitlementPolicy.noteSaveDecision(
+            note: appGridInteraction.bubbleDraftNote,
+            for: app.path.path
+        )
+        switch decision {
+        case .allow(let status), .blocked(let status):
+            return status
+        }
+    }
+
+    private func noteQuotaHintText(for app: AppInfo) -> String? {
+        guard let status = noteQuotaStatus(for: app), !status.isUnlimited else { return nil }
+        if status.remaining <= 0 {
+            return tr("pro.notes.limitReached")
+        }
+        return tr(
+            "pro.notes.remaining",
+            replacements: [
+                "%remaining%": "\(status.remaining)",
+                "%limit%": "\(status.limit)"
+            ]
+        )
+    }
+
+    private func isNoteQuotaWarning(for app: AppInfo) -> Bool {
+        guard let status = noteQuotaStatus(for: app), !status.isUnlimited else { return false }
+        return status.remaining <= 0
+    }
+
+    private func proPromptMessage(for prompt: PendingProPrompt) -> String {
+        if prompt.feature == .unlimitedNotes,
+           let status = prompt.noteQuotaStatus,
+           !status.isUnlimited,
+           status.remaining <= 0 {
+            return tr("pro.notes.limitReached")
+        }
+        if prompt.feature == .customContainerQuota,
+           let status = prompt.customContainerQuotaStatus,
+           !status.isUnlimited {
+            return tr(
+                "pro.customContainers.quotaPrompt",
+                replacements: [
+                    "%used%": "\(status.used)",
+                    "%limit%": "\(status.limit)",
+                    "%remaining%": "\(status.remaining)"
+                ]
+            )
+        }
+        return tr(prompt.feature.promptMessageKey)
+    }
+
+    private func proPromptSecondaryTitle(for prompt: PendingProPrompt) -> String {
+        prompt.feature == .customContainerQuota
+            ? tr("pro.customContainers.manageExisting")
+            : tr("pro.card.restore")
+    }
+
+    private func handleProPromptSecondaryAction(_ prompt: PendingProPrompt) {
+        if prompt.feature == .customContainerQuota {
+            dismissProPrompt()
+            setEditPhase(.editingTags)
+            return
+        }
+        proEntitlement.restorePurchases()
+    }
+
+    private func presentProPrompt(
+        for feature: ProFeature,
+        noteQuotaStatus: ProNoteQuotaStatus? = nil,
+        customContainerQuotaStatus: ProCustomContainerQuotaStatus? = nil
+    ) {
+        proEntitlement.clearTransientOperationState()
+        withAnimation(.spring(response: 0.22, dampingFraction: 0.84)) {
+            appGridInteraction.proPrompt = PendingProPrompt(
+                feature: feature,
+                noteQuotaStatus: noteQuotaStatus,
+                customContainerQuotaStatus: customContainerQuotaStatus
+            )
+        }
+    }
+
+    private func presentCustomContainerQuotaPrompt(_ status: ProCustomContainerQuotaStatus) {
+        presentProPrompt(
+            for: .customContainerQuota,
+            customContainerQuotaStatus: status
+        )
+    }
+
+    private func handleTagMutationResult(_ result: TagEditorMutationResult) -> Bool {
+        switch result {
+        case .saved:
+            refreshCustomContainerQuotaStatus()
+            return true
+        case .noChange:
+            return false
+        case .blockedCustomContainerQuota(let status):
+            presentCustomContainerQuotaPrompt(status)
+            return false
+        }
+    }
+
+    private func dismissProPrompt() {
+        proEntitlement.clearTransientOperationState()
+        withAnimation(.easeOut(duration: 0.18)) {
+            appGridInteraction.proPrompt = nil
+        }
+    }
+
+    private func handleProAccessStateChange(_ newState: ProAccessState) {
+        guard newState.isUnlocked else { return }
+
+        if let preview = proEntitlement.themePreviewState {
+            appGridThemeID = preview.theme.rawValue
+            proEntitlement.stopThemePreview()
+        }
+        if let preview = proEntitlement.displayModePreviewState {
+            displayMode = preview.displayMode
+            proEntitlement.stopDisplayModePreview()
+        }
+
+        let hadPrompt = appGridInteraction.proPrompt != nil
+        if hadPrompt {
+            dismissProPrompt()
+        }
+
+        if appGridInteraction.editingBubble != nil {
+            DispatchQueue.main.async {
+                bubbleNoteFocused = true
+            }
+        }
+    }
+
+    private func handleContentAppear() {
+        refreshNotchHeight()
+        refreshAppsForOverlay()
+        guard let initialQuickSearchSource, !initialQuickSearchConsumed else { return }
+
+        initialQuickSearchConsumed = true
+        if initialQuickSearchSource == QuickSearchOpenSource.globalHidden {
+            quickSearchCloseHidesOverlay = true
+            quickSearchOnlySession = true
+        }
+        if !quickSearchVisible {
+            quickSearchVisible = true
+            quickSearchFocusToken &+= 1
+        }
+        quickSearchOpenedAt = Date()
+        refreshQuickSearchResults()
+        refreshAppsForQuickSearch()
+        NotificationCenter.default.post(
+            name: .tagLauncherQuickSearchVisibilityChanged,
+            object: nil,
+            userInfo: [
+                "active": true,
+                "hideOverlayOnClose": quickSearchCloseHidesOverlay
+            ]
+        )
+    }
+
+    private func handleOverlayDidShow(_ notification: Notification) {
+        _ = notification
+        resetTransientDragState()
+        refreshNotchHeight()
+        refreshAppsForOverlay()
+    }
+
+    private func handleOverlayDidHide(_ notification: Notification) {
+        _ = notification
+        resetTransientDragState()
+        closeQuickSearch(notify: true, hideOverlayIfNeeded: false)
+        quickSearchOnlySession = false
+        quickSearchCloseHidesOverlay = false
+    }
+
+    private func handleQuickSearchRequested(_ notification: Notification) {
+        let source = notification.userInfo?["source"] as? String ?? QuickSearchOpenSource.mainOverlay
+        openQuickSearch(source: source)
+    }
+
+    private func handleQuickSearchDismissRequested(_ notification: Notification) {
+        let source = notification.userInfo?["source"] as? String
+        closeQuickSearch(dismissalSource: source)
+    }
+
+    private func handleApplicationDidBecomeActive(_ notification: Notification) {
+        _ = notification
+        guard allApps.isEmpty, !refreshInProgress else { return }
+        refreshApps()
+    }
+
+    private func handleDataDidChange(_ notification: Notification) {
+        _ = notification
+        refreshApps(forceLayoutRefresh: true)
+    }
+
+    private func handleAppLanguageDidChange(_ notification: Notification) {
+        _ = notification
+        let appsSnapshot = allApps
+        DispatchQueue.global(qos: .userInitiated).async {
+            _ = AppleDefaultAppCatalog.relocalizeDefaultNotesForCurrentLanguage(apps: appsSnapshot)
+            _ = SmartStartService.relocalizeDefaultNotesForCurrentLanguage(apps: appsSnapshot)
+            DispatchQueue.main.async {
+                refreshApps(forceLayoutRefresh: true)
+            }
+        }
+    }
+
     private func bubblePlacement(for frame: CGRect, rootFrame: CGRect) -> BubblePlacement {
         frame.minY < 170 ? .below : .above
     }
@@ -2121,7 +2499,8 @@
 
     private func estimatedBubbleHeight(for app: AppInfo, width: CGFloat, isEditing: Bool) -> CGFloat {
         if isEditing {
-            return 120
+            let hint = noteQuotaHintText(for: app)
+            return hint == nil ? 124 : 148
         }
         let note = currentNote(for: app).trimmingCharacters(in: .whitespacesAndNewlines)
         guard !note.isEmpty else { return 68 }
@@ -2265,11 +2644,22 @@
         let hadDragState = tagNavDragModeActive || tagNavDragItem != nil
         guard hadDragState else { return }
         if tagNavDragModeActive && tagNavReorderDidMove {
-            TagEditor.reorderTags(draggedTagNames)
+            persistTagOrderIfAllowed()
         }
         tagNavDragModeActive = false
         tagNavDragItem = nil
         tagNavReorderDidMove = false
+    }
+
+    private func persistTagOrderIfAllowed() {
+        guard proEntitlement.isUnlocked else {
+            showDropWarning(message: tr("pro.tagSorting.previewToast"))
+            return
+        }
+        guard TagEditor.reorderTags(draggedTagNames) else {
+            showDropWarning(message: tr("pro.tagSorting.previewToast"))
+            return
+        }
     }
 
     private func cancelTagNavReorderVisualState() {
@@ -2317,13 +2707,14 @@
         }
 
         guard tagColors[targetTag] != nil else { return }
-        TagEditor.moveApp(
+        let result = TagEditor.moveApp(
             path: path,
             from: sourceTag,
             to: targetTag,
             color: tagColors[targetTag] ?? 0,
             copy: copy
         )
+        guard handleTagMutationResult(result) else { return }
         showDropRefresh()
         refreshApps(forceLayoutRefresh: true)
     }
@@ -2333,7 +2724,8 @@
         guard canAssignDroppedAppToTag(targetTag) else { return }
         guard let app = allApps.first(where: { $0.path.path == path }) else { return }
         guard !appHasTag(app, tagName: targetTag) else { return }
-        TagEditor.appendTags([targetTag], to: [path])
+        let result = TagEditor.appendTags([targetTag], to: [path])
+        guard handleTagMutationResult(result) else { return }
         showDropRefresh()
         refreshApps(forceLayoutRefresh: true)
     }
@@ -2346,6 +2738,10 @@
 
         containerAppOrder[key] = paths
         rebuildDisplayGroups(apps: allApps, tagOrder: draggedTagNames)
+        guard proEntitlement.isUnlocked else {
+            showDropWarning(message: tr("pro.sorting.previewToast"))
+            return
+        }
         TagEditor.reorderApps(inContainer: key, orderedPaths: paths)
         refreshApps(forceLayoutRefresh: true)
     }
@@ -2466,7 +2862,8 @@
 
     private func moveDroppedAppToUncategorized(path: String, tags: [String]) {
         guard !tags.isEmpty else { return }
-        TagEditor.removeTags(tags, from: [path])
+        let result = TagEditor.removeTags(tags, from: [path])
+        guard handleTagMutationResult(result) else { return }
         showDropRefresh()
         refreshApps(forceLayoutRefresh: true)
     }
@@ -2495,11 +2892,46 @@
         }
     }
 
+    private func requestHideUsageTips() {
+        guard !hideUsageTips else { return }
+        guard !skipUsageTipsCloseReminder else {
+            hideUsageTips = true
+            return
+        }
+        clearAppBubbleState()
+        appGridInteraction.usageTipsCloseSuppressFuturePrompt = false
+        withAnimation(.spring(response: 0.24, dampingFraction: 0.84)) {
+            appGridInteraction.usageTipsCloseReminderVisible = true
+        }
+        publishModalInteractionState()
+    }
+
+    private func dismissUsageTipsCloseReminder() {
+        appGridInteraction.usageTipsCloseSuppressFuturePrompt = false
+        withAnimation(.easeOut(duration: 0.18)) {
+            appGridInteraction.usageTipsCloseReminderVisible = false
+        }
+        publishModalInteractionState()
+    }
+
+    private func confirmUsageTipsCloseReminder() {
+        if appGridInteraction.usageTipsCloseSuppressFuturePrompt {
+            skipUsageTipsCloseReminder = true
+        }
+        appGridInteraction.usageTipsCloseSuppressFuturePrompt = false
+        withAnimation(.easeOut(duration: 0.16)) {
+            appGridInteraction.usageTipsCloseReminderVisible = false
+        }
+        hideUsageTips = true
+        publishModalInteractionState()
+    }
+
     private func removeTagFromDroppedApp(app: AppInfo, tagName: String) {
         guard isRemovableRegularTag(tagName),
               appHasTag(app, tagName: tagName)
         else { return }
-        TagEditor.removeTags([tagName], from: [app.path.path])
+        let result = TagEditor.removeTags([tagName], from: [app.path.path])
+        guard handleTagMutationResult(result) else { return }
         showDropRefresh()
         refreshApps(forceLayoutRefresh: true)
     }
@@ -2532,9 +2964,9 @@
         return !protectedNames.contains(tag) && tagColors[tag] != nil
     }
 
-    private func showDropWarning() {
+    private func showDropWarning(message: String = tr("drop.systemDefaultWarning")) {
         withAnimation(.spring(response: 0.24, dampingFraction: 0.82)) {
-            appGridInteraction.dropWarningToast = tr("drop.systemDefaultWarning")
+            appGridInteraction.dropWarningToast = message
         }
         DispatchQueue.main.asyncAfter(deadline: .now() + 1.6) {
             withAnimation(.easeOut(duration: 0.18)) {
@@ -2675,8 +3107,10 @@
         quickSearchDocuments = snapshot.quickSearchDocuments
         tagColors = snapshot.tagColors
         tagDefinitions = snapshot.tagDefinitions
+        tagCustomColors = snapshot.tagDefinitions.compactMapValues { $0.customColor }
         containerAppOrder = snapshot.containerAppOrder
         draggedTagNames = snapshot.tagOrder
+        refreshCustomContainerQuotaStatus()
         rebuildDisplayGroups(apps: snapshot.apps, tagOrder: snapshot.tagOrder)
         if quickSearchVisible {
             refreshQuickSearchResults()
@@ -2705,6 +3139,15 @@
             closeQuickSearch(hideOverlayIfNeeded: true)
             hideOverlay()
         }
+        if isCurrentApp(app) {
+            if closeQuickSearchOnSuccess && !closeQuickSearchAndOverlayBeforeOpening {
+                closeQuickSearch(hideOverlayIfNeeded: closeOverlayOnSuccess)
+            }
+            if closeOverlayOnSuccess && !closeQuickSearchAndOverlayBeforeOpening {
+                hideOverlay()
+            }
+            return
+        }
         let configuration = NSWorkspace.OpenConfiguration()
         NSWorkspace.shared.openApplication(at: app.path, configuration: configuration) { _, error in
             DispatchQueue.main.async {
@@ -2726,6 +3169,16 @@
         }
     }
 
+    private func isCurrentApp(_ app: AppInfo) -> Bool {
+        if app.bundleIdentifier?.caseInsensitiveCompare(AppIdentity.bundleIdentifier) == .orderedSame {
+            return true
+        }
+
+        let currentURL = Bundle.main.bundleURL.standardizedFileURL.resolvingSymlinksInPath()
+        let appURL = app.path.standardizedFileURL.resolvingSymlinksInPath()
+        return currentURL == appURL
+    }
+
     func openApp(_ app: AppInfo) {
         hideOverlay()
         launchApp(app, closeOverlayOnSuccess: false)

--
Gitblit v1.9.3