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 |  223 ++++++++++++++++++++++++++++++++++++++++++++++++-------
 1 files changed, 193 insertions(+), 30 deletions(-)

diff --git a/src/Apptag/ContentView.swift b/src/Apptag/ContentView.swift
index 743ec5f..3fede8e 100644
--- a/src/Apptag/ContentView.swift
+++ b/src/Apptag/ContentView.swift
@@ -19,6 +19,7 @@
     static let theme = "theme"
     static let tags = "tags"
     static let data = "data"
+    static let pro = "pro"
     static let about = "about"
 }
 
@@ -37,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
 
@@ -57,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()
     }
 }
 
@@ -214,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 {
@@ -253,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 {
@@ -365,6 +393,7 @@
     let id = UUID()
     let feature: ProFeature
     let noteQuotaStatus: ProNoteQuotaStatus?
+    let customContainerQuotaStatus: ProCustomContainerQuotaStatus?
 }
 
 struct ContentView: View {
@@ -376,7 +405,9 @@
     @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
@@ -499,6 +530,10 @@
         )
     }
 
+    private var renderedDisplayMode: String {
+        ProEntitlementPolicy.effectiveDisplayMode(for: displayMode)
+    }
+
     private func storedThemeTuning(for theme: AppGridTheme) -> Double? {
         switch theme {
         case .deepBlue:
@@ -519,7 +554,7 @@
     }
 
     private var isColorlessContainerMode: Bool {
-        displayMode == "container" || displayMode == "gridContainer"
+        renderedDisplayMode == "container" || renderedDisplayMode == "gridContainer"
     }
 
     private var shouldShowUsageTips: Bool {
@@ -683,11 +718,11 @@
                     benefitText: tr(prompt.feature.benefitKey),
                     operationText: proEntitlement.operationState.statusMessageKey.map(tr),
                     unlockTitle: tr("pro.card.unlock"),
-                    restoreTitle: tr("pro.card.restore"),
+                    restoreTitle: proPromptSecondaryTitle(for: prompt),
                     isBusy: proEntitlement.operationState.isBusy,
                     onClose: dismissProPrompt,
                     onUnlock: { proEntitlement.purchasePro() },
-                    onRestore: { proEntitlement.restorePurchases() }
+                    onRestore: { handleProPromptSecondaryAction(prompt) }
                 )
             }
             .transition(.opacity)
@@ -740,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 {
@@ -1185,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: {
@@ -1212,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: {
@@ -1249,7 +1289,8 @@
                 AppGridCollectionView(
                     groups: displayGroups,
                     tagColors: tagColors,
-                    displayMode: displayMode,
+                    tagCustomColors: tagCustomColors,
+                    displayMode: renderedDisplayMode,
                     iconSize: iconSize,
                     showNames: !hideAppNames,
                     appGridTheme: renderedAppGridTheme,
@@ -1376,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
@@ -1531,7 +1613,7 @@
             .onEnded { _ in
                 guard canReorderTag(tagName) else { return }
                 dragItem = nil
-                TagEditor.reorderTags(draggedTagNames)
+                persistTagOrderIfAllowed()
             }
     }
 
@@ -1559,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 = []
@@ -2009,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] {
@@ -2203,16 +2293,68 @@
            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 presentProPrompt(for feature: ProFeature, noteQuotaStatus: ProNoteQuotaStatus? = nil) {
+    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
+                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
         }
     }
 
@@ -2229,6 +2371,10 @@
         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
@@ -2498,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() {
@@ -2550,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)
     }
@@ -2566,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)
     }
@@ -2703,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)
     }
@@ -2770,7 +2930,8 @@
         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)
     }
@@ -2946,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()

--
Gitblit v1.9.3