From 0f7ee6e29f70017dd4fdfe2dd3a6106f7204f04f Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Thu, 02 Jul 2026 02:40:40 +0800
Subject: [PATCH] Document Raycast extension backlog

---
 src/Apptag/TagEditorView.swift |  126 ++++++++++++++++++++++++++++++++++++++++-
 1 files changed, 121 insertions(+), 5 deletions(-)

diff --git a/src/Apptag/TagEditorView.swift b/src/Apptag/TagEditorView.swift
index 775170a..701e532 100644
--- a/src/Apptag/TagEditorView.swift
+++ b/src/Apptag/TagEditorView.swift
@@ -1,3 +1,4 @@
+import AppKit
 import SwiftUI
 
 // MARK: - Reusable Tag Editor (overlay edit mode + Preferences)
@@ -6,7 +7,13 @@
 /// All changes (rename, delete, setColor) are saved to the local tag database.
 struct TagEditorView: View {
     @Binding var tagColors: [String: Int]
+    @Binding var tagCustomColors: [String: TagCustomColor]
     let excludedTagNames: Set<String>
+    let isCustomColorUnlocked: Bool
+    let onLockedCustomColor: () -> Void
+    let onCustomContainerQuotaExceeded: (ProCustomContainerQuotaStatus) -> Void
+    let onCustomContainerQuotaStatusChanged: (ProCustomContainerQuotaStatus) -> Void
+    var topLeadingAccessory: AnyView? = nil
     var onRefresh: (() -> Void)?
 
     @State private var editingTagName: String? = nil
@@ -30,7 +37,13 @@
         VStack(spacing: 0) {
             // "New Tag" button at top
             HStack {
-                Spacer()
+                if let topLeadingAccessory {
+                    topLeadingAccessory
+                        .frame(maxWidth: .infinity, alignment: .leading)
+                } else {
+                    Spacer()
+                }
+
                 Button {
                     addingNewTag = true
                     newTagNameText = ""
@@ -85,16 +98,21 @@
 
     private func tagEditRow(_ tagName: String) -> some View {
         let colorIndex = tagColors[tagName] ?? 0
+        let hasCustomColor = tagCustomColors[tagName] != nil
         return HStack(spacing: 10) {
             HStack(spacing: 4) {
                 ForEach(TagColor.allIndices, id: \.self) { idx in
-                    ColorSwatch(index: idx, isSelected: idx == colorIndex) {
-                        TagEditor.setColor(idx, for: tagName)
+                    ColorSwatch(index: idx, isSelected: !hasCustomColor && idx == colorIndex) {
+                        let result = TagEditor.setColor(idx, for: tagName)
+                        guard handleMutationResult(result) else { return }
                         tagColors[tagName] = idx
+                        tagCustomColors.removeValue(forKey: tagName)
                         onRefresh?()
                     }
                 }
             }
+
+            customColorControl(tagName: tagName, colorIndex: colorIndex)
 
             if editingTagName == tagName {
                 MacTextField(
@@ -164,9 +182,12 @@
     private func commitTagRename(_ oldName: String) {
         let newName = editingTagText.trimmingCharacters(in: .whitespaces)
         guard !newName.isEmpty, newName != oldName else { editingTagName = nil; return }
-        TagEditor.renameTag(from: oldName, to: newName)
+        let result = TagEditor.renameTag(from: oldName, to: newName)
+        guard handleMutationResult(result) else { return }
         tagColors[newName] = tagColors[oldName]
         tagColors.removeValue(forKey: oldName)
+        tagCustomColors[newName] = tagCustomColors[oldName]
+        tagCustomColors.removeValue(forKey: oldName)
         editingTagName = nil
         onRefresh?()
     }
@@ -175,15 +196,110 @@
     private func addNewTag() {
         let name = newTagNameText.trimmingCharacters(in: .whitespaces)
         guard !name.isEmpty else { return }
+        let result = TagEditor.createTag(name, color: newTagColorIndex)
+        guard handleMutationResult(result) else { return }
         tagColors[name] = newTagColorIndex
-        TagEditor.createTag(name, color: newTagColorIndex)
         addingNewTag = false
         newTagNameText = ""
+        onRefresh?()
     }
 
     private func deleteTag(_ name: String) {
         TagEditor.deleteTagCompletely(name)
         tagColors.removeValue(forKey: name)
+        tagCustomColors.removeValue(forKey: name)
+        onCustomContainerQuotaStatusChanged(ProEntitlementPolicy.customContainerQuotaStatus())
         onRefresh?()
     }
+
+    @ViewBuilder
+    private func customColorControl(tagName: String, colorIndex: Int) -> some View {
+        let isSelected = tagCustomColors[tagName] != nil
+        if isCustomColorUnlocked {
+            ColorPicker(
+                "",
+                selection: customColorBinding(tagName: tagName, colorIndex: colorIndex),
+                supportsOpacity: false
+            )
+            .labelsHidden()
+            .opacity(0.02)
+            .frame(width: 64, height: 24)
+            .overlay(
+                customColorSwatch(tagName: tagName, colorIndex: colorIndex, isSelected: isSelected)
+                    .allowsHitTesting(false)
+            )
+            .frame(width: 72, alignment: .leading)
+            .help(tr("tag.customColor"))
+        } else {
+            Button(action: onLockedCustomColor) {
+                customColorSwatch(tagName: tagName, colorIndex: colorIndex, isSelected: isSelected)
+            }
+            .buttonStyle(.plain)
+            .frame(width: 72, alignment: .leading)
+            .help(tr("pro.prompt.customTagColors"))
+        }
+    }
+
+    private func customColorSwatch(tagName: String, colorIndex: Int, isSelected: Bool) -> some View {
+        let fill = Color(nsColor: TagColor.nsColor(for: colorIndex, customColor: tagCustomColors[tagName]))
+        return ZStack {
+            RoundedRectangle(cornerRadius: 10, style: .continuous)
+                .fill(fill)
+                .frame(width: 58, height: 18)
+                .overlay(
+                    RoundedRectangle(cornerRadius: 10, style: .continuous)
+                        .stroke(Color.secondary.opacity(0.18), lineWidth: 1)
+                )
+                .padding(3)
+                .overlay(
+                    RoundedRectangle(cornerRadius: 13, style: .continuous)
+                        .stroke(isSelected ? Color.white : Color.clear, lineWidth: 2)
+                )
+                .shadow(color: .black.opacity(0.15), radius: 2, y: 1)
+
+            Image(systemName: "crown")
+                .font(.system(size: 10, weight: .semibold))
+                .symbolRenderingMode(.monochrome)
+                .foregroundStyle(Color.white)
+                .shadow(color: .black.opacity(0.28), radius: 1, y: 0.5)
+        }
+    }
+
+    private func customColorBinding(tagName: String, colorIndex: Int) -> Binding<Color> {
+        Binding(
+            get: {
+                Color(nsColor: TagColor.nsColor(for: colorIndex, customColor: tagCustomColors[tagName]))
+            },
+            set: { color in
+                guard isCustomColorUnlocked else {
+                    onLockedCustomColor()
+                    return
+                }
+                let nsColor = NSColor(color)
+                let customColor = TagCustomColor(nsColor: nsColor)
+                guard TagEditor.setCustomColor(customColor, for: tagName) else {
+                    onLockedCustomColor()
+                    return
+                }
+                tagCustomColors[tagName] = customColor
+                onCustomContainerQuotaStatusChanged(ProEntitlementPolicy.customContainerQuotaStatus())
+                onRefresh?()
+            }
+        )
+    }
+
+    @discardableResult
+    private func handleMutationResult(_ result: TagEditorMutationResult) -> Bool {
+        switch result {
+        case .saved:
+            onCustomContainerQuotaStatusChanged(ProEntitlementPolicy.customContainerQuotaStatus())
+            return true
+        case .noChange:
+            return false
+        case .blockedCustomContainerQuota(let status):
+            onCustomContainerQuotaStatusChanged(status)
+            onCustomContainerQuotaExceeded(status)
+            return false
+        }
+    }
 }

--
Gitblit v1.9.3