From b4a1ba5df0f073bf346c3f4407b613bf08d29f08 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Thu, 28 May 2026 19:34:57 +0800
Subject: [PATCH] Add AppKit tag navigation

---
 TODO.md                        |   20 +-
 Apptag/TagNavigationView.swift |  295 ++++++++++++++++++++++++++++++++++++++++++
 Apptag/ContentView.swift       |   92 +++++++++---
 Apptag/AppDefaults.swift       |    2 
 4 files changed, 372 insertions(+), 37 deletions(-)

diff --git a/Apptag/AppDefaults.swift b/Apptag/AppDefaults.swift
index 71e08d9..f0472ac 100644
--- a/Apptag/AppDefaults.swift
+++ b/Apptag/AppDefaults.swift
@@ -12,6 +12,7 @@
     static let showDockIcon = true
     static let launchAtLogin = true
     static let showUncommonAppBubbles = false
+    static let useAppKitTagNavigation = true
 
     static func register() {
         UserDefaults.standard.register(defaults: [
@@ -23,6 +24,7 @@
             "showDockIcon": showDockIcon,
             "launchAtLogin": launchAtLogin,
             "showUncommonAppBubbles": showUncommonAppBubbles,
+            "useAppKitTagNavigation": useAppKitTagNavigation,
             "skipTagRemovalDropConfirm": false,
             LauncherHotkeyRegistrationStore.mainStateKey: LauncherHotkeyRegistrationState.active.rawValue,
             LauncherHotkeyRegistrationStore.quickSearchStateKey: LauncherHotkeyRegistrationState.active.rawValue
diff --git a/Apptag/ContentView.swift b/Apptag/ContentView.swift
index f94de57..382f6b8 100644
--- a/Apptag/ContentView.swift
+++ b/Apptag/ContentView.swift
@@ -418,6 +418,7 @@
     @AppStorage("displayMode") private var displayMode = AppDefaults.displayMode
     @AppStorage("hideAppNames") private var hideAppNames = AppDefaults.hideAppNames
     @AppStorage("showUncommonAppBubbles") private var showUncommonAppBubbles = AppDefaults.showUncommonAppBubbles
+    @AppStorage("useAppKitTagNavigation") private var useAppKitTagNavigation = AppDefaults.useAppKitTagNavigation
     @AppStorage("skipTagRemovalDropConfirm") private var skipTagRemovalDropConfirm = false
 
     private let editSidebarWidth: CGFloat = 188
@@ -865,7 +866,7 @@
     private var topLayout: some View {
         VStack(spacing: 0) {
             Spacer().frame(height: notchHeight > 0 ? notchHeight + 14 : 28)
-            if !tagLabels.isEmpty { tagBar.padding(.bottom, 8) }
+            if !tagLabels.isEmpty { topTagNavigation.padding(.bottom, 8) }
             Divider().opacity(0.3)
             appGridContent
         }
@@ -876,14 +877,65 @@
             Spacer().frame(height: notchHeight > 0 ? notchHeight + 14 : 28)
             Divider().opacity(0.3)
             HStack(spacing: 0) {
-                if tagPosition == "left" { tagSidebar; sideDivider }
+                if tagPosition == "left" { leftTagSidebar; sideDivider }
                 appGridContent
                 if tagPosition == "right" { sideDivider; rightTagSidebar }
             }
         }
     }
 
-    private var tagBar: some View {
+    private var topTagNavigation: some View {
+        Group {
+            if useAppKitTagNavigation {
+                appKitTagNavigation(orientation: .horizontal)
+                    .frame(height: 34)
+            } else {
+                swiftUITopTagBar
+            }
+        }
+    }
+
+    private var leftTagSidebar: some View {
+        Group {
+            if useAppKitTagNavigation {
+                appKitTagNavigation(orientation: .vertical)
+            } else {
+                swiftUITagSidebarList
+            }
+        }
+        .frame(width: 135)
+    }
+
+    private var rightTagSidebar: some View {
+        Group {
+            if useAppKitTagNavigation {
+                appKitTagNavigation(orientation: .vertical)
+            } else {
+                swiftUITagSidebarList
+            }
+        }
+        .padding(.top, rightSidebarFloatingClearance)
+        .frame(width: 135)
+    }
+
+    private func appKitTagNavigation(orientation: TagNavigationView.Orientation) -> some View {
+        let isHorizontal = orientation == .horizontal
+        return TagNavigationView(
+            items: tagLabels,
+            orientation: orientation,
+            contentInsets: isHorizontal
+                ? NSEdgeInsets(top: 3, left: 24, bottom: 3, right: tagPosition == "top" ? floatingControlsReservedWidth : 24)
+                : NSEdgeInsets(top: 12, left: 12, bottom: 12, right: 12),
+            onActivate: { tagID in
+                activateTagNavigation(tagID)
+            },
+            onHover: { tagID in
+                handleTagNavigationHover(tagID)
+            }
+        )
+    }
+
+    private var swiftUITopTagBar: some View {
         ScrollView(.horizontal, showsIndicators: false) {
             HStack(spacing: 8) {
                 ForEach(tagLabels) { tag in
@@ -898,8 +950,7 @@
                     .highPriorityGesture(tagNavReorderGesture(for: tag.name))
                     .onHover { hovering in
                         if hovering {
-                            fillColorlessContainer(tag.id)
-                            scrollTo(tag.id)
+                            handleTagNavigationHover(tag.id)
                         }
                     }
                 }
@@ -913,18 +964,7 @@
         }
     }
 
-    private var tagSidebar: some View {
-        tagSidebarList
-            .frame(width: 135)
-    }
-
-    private var rightTagSidebar: some View {
-        tagSidebarList
-            .padding(.top, rightSidebarFloatingClearance)
-            .frame(width: 135)
-    }
-
-    private var tagSidebarList: some View {
+    private var swiftUITagSidebarList: some View {
         ScrollView(.vertical, showsIndicators: false) {
             VStack(spacing: 6) {
                 ForEach(tagLabels) { tag in
@@ -939,8 +979,7 @@
                     .highPriorityGesture(tagNavReorderGesture(for: tag.name))
                     .onHover { hovering in
                         if hovering {
-                            fillColorlessContainer(tag.id)
-                            scrollTo(tag.id)
+                            handleTagNavigationHover(tag.id)
                         }
                     }
                 }
@@ -1732,8 +1771,8 @@
         groupLayoutVersion &+= 1
     }
 
-    private var tagLabels: [TagLabel] {
-        displayGroups.map { TagLabel(name: $0.name, colorIndex: tagColors[$0.name] ?? 0) }
+    private var tagLabels: [TagNavigationItem] {
+        displayGroups.map { TagNavigationItem(name: $0.name, colorIndex: tagColors[$0.name] ?? 0) }
     }
 
     private var editGroups: [TagGroup] {
@@ -1903,6 +1942,11 @@
         if isColorlessContainerMode {
             toggleColorlessFill(id)
         }
+        scrollTo(id)
+    }
+
+    private func handleTagNavigationHover(_ id: String) {
+        fillColorlessContainer(id)
         scrollTo(id)
     }
 
@@ -2360,12 +2404,6 @@
     static func reduce(value: inout [String: CGRect], nextValue: () -> [String: CGRect]) {
         value.merge(nextValue(), uniquingKeysWith: { _, new in new })
     }
-}
-
-private struct TagLabel: Identifiable {
-    var id: String { name }
-    let name: String
-    let colorIndex: Int
 }
 
 // MARK: - NSVisualEffectView bridge
diff --git a/Apptag/TagNavigationView.swift b/Apptag/TagNavigationView.swift
new file mode 100644
index 0000000..95f7915
--- /dev/null
+++ b/Apptag/TagNavigationView.swift
@@ -0,0 +1,295 @@
+import AppKit
+import SwiftUI
+
+struct TagNavigationItem: Identifiable, Equatable {
+    var id: String { name }
+    let name: String
+    let colorIndex: Int
+}
+
+struct TagNavigationView: NSViewRepresentable {
+    enum Orientation: Equatable {
+        case horizontal
+        case vertical
+    }
+
+    let items: [TagNavigationItem]
+    let orientation: Orientation
+    let contentInsets: NSEdgeInsets
+    let onActivate: (String) -> Void
+    let onHover: (String) -> Void
+
+    func makeNSView(context: Context) -> TagNavigationHostView {
+        let view = TagNavigationHostView()
+        view.update(
+            items: items,
+            orientation: orientation,
+            contentInsets: contentInsets,
+            onActivate: onActivate,
+            onHover: onHover
+        )
+        return view
+    }
+
+    func updateNSView(_ nsView: TagNavigationHostView, context: Context) {
+        nsView.update(
+            items: items,
+            orientation: orientation,
+            contentInsets: contentInsets,
+            onActivate: onActivate,
+            onHover: onHover
+        )
+    }
+}
+
+final class TagNavigationHostView: NSView {
+    private let scrollView = NSScrollView()
+    private let documentView = TagNavigationDocumentView()
+
+    override var isFlipped: Bool { true }
+
+    override init(frame frameRect: NSRect) {
+        super.init(frame: frameRect)
+        setup()
+    }
+
+    required init?(coder: NSCoder) {
+        super.init(coder: coder)
+        setup()
+    }
+
+    func update(
+        items: [TagNavigationItem],
+        orientation: TagNavigationView.Orientation,
+        contentInsets: NSEdgeInsets,
+        onActivate: @escaping (String) -> Void,
+        onHover: @escaping (String) -> Void
+    ) {
+        documentView.update(
+            items: items,
+            orientation: orientation,
+            contentInsets: contentInsets,
+            onActivate: onActivate,
+            onHover: onHover
+        )
+        scrollView.hasHorizontalScroller = orientation == .horizontal
+        scrollView.hasVerticalScroller = orientation == .vertical
+        needsLayout = true
+    }
+
+    override func layout() {
+        super.layout()
+        scrollView.frame = bounds
+        let visibleSize = scrollView.contentView.bounds.size
+        documentView.frame = NSRect(
+            origin: .zero,
+            size: documentView.contentSize(fitting: visibleSize)
+        )
+        documentView.needsLayout = true
+    }
+
+    private func setup() {
+        wantsLayer = true
+        layer?.backgroundColor = NSColor.clear.cgColor
+
+        scrollView.drawsBackground = false
+        scrollView.borderType = .noBorder
+        scrollView.autohidesScrollers = true
+        scrollView.scrollerStyle = .overlay
+        scrollView.hasHorizontalScroller = false
+        scrollView.hasVerticalScroller = false
+        scrollView.documentView = documentView
+        addSubview(scrollView)
+    }
+}
+
+final class TagNavigationDocumentView: NSView {
+    private var items: [TagNavigationItem] = []
+    private var orientation: TagNavigationView.Orientation = .horizontal
+    private var contentInsets = NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
+    private var buttons: [TagNavigationButton] = []
+    private var onActivate: (String) -> Void = { _ in }
+    private var onHover: (String) -> Void = { _ in }
+
+    override var isFlipped: Bool { true }
+
+    func update(
+        items: [TagNavigationItem],
+        orientation: TagNavigationView.Orientation,
+        contentInsets: NSEdgeInsets,
+        onActivate: @escaping (String) -> Void,
+        onHover: @escaping (String) -> Void
+    ) {
+        let needsRebuild = self.items != items || self.orientation != orientation
+        self.items = items
+        self.orientation = orientation
+        self.contentInsets = contentInsets
+        self.onActivate = onActivate
+        self.onHover = onHover
+
+        if needsRebuild {
+            rebuildButtons()
+        } else {
+            for (button, item) in zip(buttons, items) {
+                button.configure(item: item, orientation: orientation)
+            }
+        }
+        needsLayout = true
+    }
+
+    func contentSize(fitting visibleSize: NSSize) -> NSSize {
+        switch orientation {
+        case .horizontal:
+            let width = buttons.reduce(contentInsets.left + contentInsets.right) { total, button in
+                total + button.preferredSize.width
+            } + CGFloat(max(0, buttons.count - 1)) * 8
+            let height = max(visibleSize.height, contentInsets.top + 28 + contentInsets.bottom)
+            return NSSize(width: max(width, visibleSize.width), height: height)
+        case .vertical:
+            let rowHeight: CGFloat = 28
+            let height = contentInsets.top
+                + CGFloat(buttons.count) * rowHeight
+                + CGFloat(max(0, buttons.count - 1)) * 6
+                + contentInsets.bottom
+            return NSSize(width: max(visibleSize.width, 1), height: max(height, visibleSize.height))
+        }
+    }
+
+    override func layout() {
+        super.layout()
+        switch orientation {
+        case .horizontal:
+            layoutHorizontal()
+        case .vertical:
+            layoutVertical()
+        }
+    }
+
+    private func rebuildButtons() {
+        buttons.forEach { $0.removeFromSuperview() }
+        buttons = items.map { item in
+            let button = TagNavigationButton(item: item, orientation: orientation)
+            button.onActivate = { [weak self] tagID in self?.onActivate(tagID) }
+            button.onHover = { [weak self] tagID in self?.onHover(tagID) }
+            addSubview(button)
+            return button
+        }
+    }
+
+    private func layoutHorizontal() {
+        var x = contentInsets.left
+        let height: CGFloat = 28
+        let y = max(contentInsets.top, (bounds.height - height) / 2)
+        for button in buttons {
+            let size = button.preferredSize
+            button.frame = NSRect(x: x, y: y, width: size.width, height: height)
+            x += size.width + 8
+        }
+    }
+
+    private func layoutVertical() {
+        let rowHeight: CGFloat = 28
+        let width = max(1, bounds.width - contentInsets.left - contentInsets.right)
+        var y = contentInsets.top
+        for button in buttons {
+            button.frame = NSRect(x: contentInsets.left, y: y, width: width, height: rowHeight)
+            y += rowHeight + 6
+        }
+    }
+}
+
+final class TagNavigationButton: NSButton {
+    var onActivate: (String) -> Void = { _ in }
+    var onHover: (String) -> Void = { _ in }
+
+    private var item: TagNavigationItem
+    private var orientation: TagNavigationView.Orientation
+    private var trackingAreaRef: NSTrackingArea?
+
+    var preferredSize: NSSize {
+        let font = NSFont.systemFont(ofSize: 13, weight: .medium)
+        let textWidth = (item.name as NSString).size(withAttributes: [.font: font]).width
+        switch orientation {
+        case .horizontal:
+            return NSSize(width: ceil(textWidth + 24), height: 28)
+        case .vertical:
+            return NSSize(width: ceil(textWidth + 20), height: 28)
+        }
+    }
+
+    init(item: TagNavigationItem, orientation: TagNavigationView.Orientation) {
+        self.item = item
+        self.orientation = orientation
+        super.init(frame: .zero)
+        isBordered = false
+        setButtonType(.momentaryChange)
+        target = self
+        action = #selector(performActivation)
+        wantsLayer = true
+        configure(item: item, orientation: orientation)
+    }
+
+    required init?(coder: NSCoder) {
+        self.item = TagNavigationItem(name: "", colorIndex: 0)
+        self.orientation = .horizontal
+        super.init(coder: coder)
+    }
+
+    func configure(item: TagNavigationItem, orientation: TagNavigationView.Orientation) {
+        self.item = item
+        self.orientation = orientation
+        title = item.name
+        alignment = orientation == .horizontal ? .center : .left
+        setAccessibilityLabel(item.name)
+        updateAppearance()
+        needsLayout = true
+    }
+
+    override func updateTrackingAreas() {
+        super.updateTrackingAreas()
+        if let trackingAreaRef {
+            removeTrackingArea(trackingAreaRef)
+        }
+        let area = NSTrackingArea(
+            rect: bounds,
+            options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect],
+            owner: self,
+            userInfo: nil
+        )
+        trackingAreaRef = area
+        addTrackingArea(area)
+    }
+
+    override func mouseEntered(with event: NSEvent) {
+        super.mouseEntered(with: event)
+        onHover(item.id)
+    }
+
+    @objc private func performActivation() {
+        onActivate(item.id)
+    }
+
+    private func updateAppearance() {
+        let background = TagColor.nsColor(for: item.colorIndex)
+        layer?.backgroundColor = background.cgColor
+        layer?.cornerRadius = orientation == .horizontal ? 7 : 6
+        layer?.shadowColor = NSColor.black.withAlphaComponent(0.20).cgColor
+        layer?.shadowOpacity = 1
+        layer?.shadowRadius = 3
+        layer?.shadowOffset = NSSize(width: 0, height: -1)
+
+        let textColor: NSColor = (item.colorIndex == 0 || item.colorIndex == 5) ? .labelColor : .white
+        let paragraph = NSMutableParagraphStyle()
+        paragraph.alignment = alignment
+        if orientation == .vertical {
+            paragraph.firstLineHeadIndent = 10
+            paragraph.headIndent = 10
+        }
+        let attributes: [NSAttributedString.Key: Any] = [
+            .font: NSFont.systemFont(ofSize: 13, weight: .medium),
+            .foregroundColor: textColor,
+            .paragraphStyle: paragraph
+        ]
+        attributedTitle = NSAttributedString(string: item.name, attributes: attributes)
+    }
+}
diff --git a/TODO.md b/TODO.md
index 403fa88..d940cd0 100644
--- a/TODO.md
+++ b/TODO.md
@@ -11,22 +11,17 @@
 
 ## In Progress
 
-- [2026-05-28] 第二批 AppKit 化第 1 项:清理 `ContentView` 的 App Grid 交互职责。
-  - 分支: `codex/post-freeze-new-requirement`
-  - 目标: 先做低风险状态收口,把 App Grid 的 bubble、drag、drop、refresh toast 等临时交互状态集中管理,降低后续标签栏 AppKit 化风险。
-  - 原则: 不改变用户可见 UI,不改变拖拽/hover/Quick Search/窗口行为。
-  - 验收: `bash build.sh`、`codesign --verify --deep --strict`、localization JSON 解析、App Grid 交互 targeted QA、必要时跑 `Scripts/window_logic_qa.sh`。
-  - QA: 自动化/半自动化 QA 已通过;待用户最终体验验收后移动到 `Done`。
-  - 结果: `ContentView` 中 App Grid 的 bubble、drag、drop、refresh toast 临时状态已收口到 `AppGridInteractionState`;窗口 8 逻辑 QA 已全部通过。
-
-## Todo
-
 - [2026-05-28] 第二批 AppKit 化第 2 项:标签栏 AppKit 化第一阶段,只读导航。
+  - 分支: `codex/post-freeze-new-requirement`
   - 目标: 抽 `TagNavItem` / callbacks 边界,新增可回滚的 AppKit 标签栏。
   - 范围: 顶部、左侧、右侧标签栏显示;点击标签滚动到对应分组。
   - 暂不做: 标签拖拽排序。
   - 回滚: 保留旧 SwiftUI 标签栏作为开关。
   - 验收: 三种标签位置、点击滚动、长标签、多语言、全屏和 Split View。
+  - QA: 构建、签名、本地化 JSON、三种位置屏幕点击复核、SwiftUI 回滚开关、窗口 8 逻辑 QA 已通过;待用户最终体验验收后移动到 `Done`。
+  - 结果: 默认启用 AppKit 标签导航,`useAppKitTagNavigation=false` 可回滚到旧 SwiftUI 标签栏。
+
+## Todo
 
 - [2026-05-28] 第二批 AppKit 化第 3 项:标签栏 AppKit 化第二阶段,hover 与容器高亮。
   - 目标: hover 标签时高亮对应容器,保留 colorless container 的填充/取消逻辑。
@@ -66,6 +61,11 @@
 
 ## Done
 
+- [2026-05-28] 第二批 AppKit 化第 1 项:清理 `ContentView` 的 App Grid 交互职责。
+  - 提交: `561541e`
+  - 结果: `ContentView` 中 App Grid 的 bubble、drag、drop、refresh toast 临时状态已收口到 `AppGridInteractionState`。
+  - 验证: `bash build.sh`、`codesign --verify --deep --strict`、localization JSON 解析、App Grid targeted QA、`Scripts/window_logic_qa.sh` 全部通过;用户已确认第 1 项 OK。
+
 - [2026-05-28] 冻结发布版本并从冻结 tag 拉出新需求分支。
   - 冻结 tag: `appstore-7.6.0-20260527.0124`
   - 新需求分支: `codex/post-freeze-new-requirement`

--
Gitblit v1.9.3