From 9d87dbed3f81d07a81b1484a1317d7f7c0258610 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Thu, 28 May 2026 19:58:24 +0800
Subject: [PATCH] Stabilize tag navigation hover highlighting

---
 TODO.md                            |   22 +++++------
 Apptag/AppGridCollectionView.swift |   11 ++---
 Apptag/TagNavigationView.swift     |   32 ++++++++++-----
 Apptag/ContentView.swift           |   44 +++++++++++++++++-----
 4 files changed, 70 insertions(+), 39 deletions(-)

diff --git a/Apptag/AppGridCollectionView.swift b/Apptag/AppGridCollectionView.swift
index 3516782..013e915 100644
--- a/Apptag/AppGridCollectionView.swift
+++ b/Apptag/AppGridCollectionView.swift
@@ -158,7 +158,6 @@
                 iconSize: iconSize,
                 showNames: showNames,
                 showUncommonAppBubbles: showUncommonAppBubbles,
-                highlightedGroupName: highlightedGroupName,
                 contentRevision: contentRevision
             )
             if nextSignature != contentSignature {
@@ -235,7 +234,6 @@
             iconSize: CGFloat,
             showNames: Bool,
             showUncommonAppBubbles: Bool,
-            highlightedGroupName: String?,
             contentRevision: Int
         ) -> String {
             let colorPart = tagColors
@@ -247,7 +245,6 @@
                 "\(Int(iconSize.rounded()))",
                 showNames ? "names" : "nonames",
                 showUncommonAppBubbles ? "uncommon" : "allbubbles",
-                highlightedGroupName ?? "",
                 colorPart,
                 "rev=\(contentRevision)"
             ].joined(separator: "|")
@@ -951,8 +948,9 @@
         guard let coordinator, let group else { return }
         let displayStyle = coordinator.displayStyle
         let tagColor = TagColor.nsColor(for: coordinator.tagColors[group.name] ?? 0)
+        let isNavigationHighlighted = coordinator.highlightedGroupName == group.name
         let isColorlessActive = coordinator.isColorlessContainerMode
-            && (isHovered || coordinator.highlightedGroupName == group.name)
+            && (isHovered || isNavigationHighlighted)
 
         if displayStyle.usesCardSurface {
             let rect = bounds.insetBy(dx: 0.5, dy: 0.5)
@@ -1033,10 +1031,11 @@
 
     private func updateCardShadow() {
         guard let coordinator else { return }
+        let isNavigationHighlighted = coordinator.highlightedGroupName == group?.name
         let isColorlessActive = coordinator.isColorlessContainerMode
-            && (isHovered || coordinator.highlightedGroupName == group?.name)
+            && (isHovered || isNavigationHighlighted)
         let shouldShadow = coordinator.displayStyle.usesCardSurface
-            && ((coordinator.isColoredContainerMode && isHovered) || isColorlessActive)
+            && ((coordinator.isColoredContainerMode && (isHovered || isNavigationHighlighted)) || isColorlessActive)
         layer?.shadowColor = NSColor.black.cgColor
         layer?.shadowOpacity = shouldShadow ? 0.22 : 0
         layer?.shadowRadius = shouldShadow ? 8 : 0
diff --git a/Apptag/ContentView.swift b/Apptag/ContentView.swift
index 382f6b8..c060b8b 100644
--- a/Apptag/ContentView.swift
+++ b/Apptag/ContentView.swift
@@ -316,6 +316,9 @@
     var tagRemovalDropSuppressFuturePrompt = false
     var appDragResetToken = 0
     var bubbleDraftNote = ""
+    var tagNavigationHoveredGroupName: String? = nil
+    var tagNavigationLastHoverScrollID: String? = nil
+    var tagNavigationLastHoverScrollAt: Date? = nil
 }
 
 private struct EditActionFeedback: Identifiable {
@@ -430,7 +433,12 @@
             || appGridInteraction.pendingUncategorizedDrop != nil
             || appGridInteraction.pendingTagRemovalDrop != nil
     }
+    private var appGridHighlightedGroupName: String? {
+        appGridInteraction.tagNavigationHoveredGroupName
+            ?? (isColorlessContainerMode ? filledColorlessContainer : nil)
+    }
     private let rightSidebarFloatingClearance: CGFloat = 44
+    private let tagNavigationHoverScrollInterval: TimeInterval = 0.22
 
     private var floatingButtonSurfaceColor: Color {
         colorScheme == .dark
@@ -929,8 +937,8 @@
             onActivate: { tagID in
                 activateTagNavigation(tagID)
             },
-            onHover: { tagID in
-                handleTagNavigationHover(tagID)
+            onHoverChange: { tagID, active in
+                handleTagNavigationHover(tagID, active: active)
             }
         )
     }
@@ -949,9 +957,7 @@
                     .zIndex(tagNavDragItem == tag.name ? 1 : 0)
                     .highPriorityGesture(tagNavReorderGesture(for: tag.name))
                     .onHover { hovering in
-                        if hovering {
-                            handleTagNavigationHover(tag.id)
-                        }
+                        handleTagNavigationHover(tag.id, active: hovering)
                     }
                 }
             }
@@ -978,9 +984,7 @@
                     .zIndex(tagNavDragItem == tag.name ? 1 : 0)
                     .highPriorityGesture(tagNavReorderGesture(for: tag.name))
                     .onHover { hovering in
-                        if hovering {
-                            handleTagNavigationHover(tag.id)
-                        }
+                        handleTagNavigationHover(tag.id, active: hovering)
                     }
                 }
             }
@@ -1011,7 +1015,7 @@
                     showNames: !hideAppNames,
                     bubbleDisabled: appBubbleDisabled,
                     showUncommonAppBubbles: showUncommonAppBubbles,
-                    highlightedGroupName: filledColorlessContainer,
+                    highlightedGroupName: appGridHighlightedGroupName,
                     contentRevision: groupLayoutVersion,
                     scrollTargetID: appGridScrollTargetID,
                     scrollRequestToken: appGridScrollRequestToken,
@@ -1945,8 +1949,28 @@
         scrollTo(id)
     }
 
-    private func handleTagNavigationHover(_ id: String) {
+    private func handleTagNavigationHover(_ id: String, active: Bool) {
+        guard active else {
+            if appGridInteraction.tagNavigationHoveredGroupName == id {
+                appGridInteraction.tagNavigationHoveredGroupName = nil
+            }
+            return
+        }
+
+        appGridInteraction.tagNavigationHoveredGroupName = id
         fillColorlessContainer(id)
+        scrollToTagFromHover(id)
+    }
+
+    private func scrollToTagFromHover(_ id: String) {
+        let now = Date()
+        if appGridInteraction.tagNavigationLastHoverScrollID == id,
+           let lastScrollAt = appGridInteraction.tagNavigationLastHoverScrollAt,
+           now.timeIntervalSince(lastScrollAt) < tagNavigationHoverScrollInterval {
+            return
+        }
+        appGridInteraction.tagNavigationLastHoverScrollID = id
+        appGridInteraction.tagNavigationLastHoverScrollAt = now
         scrollTo(id)
     }
 
diff --git a/Apptag/TagNavigationView.swift b/Apptag/TagNavigationView.swift
index 95f7915..2736f53 100644
--- a/Apptag/TagNavigationView.swift
+++ b/Apptag/TagNavigationView.swift
@@ -17,7 +17,7 @@
     let orientation: Orientation
     let contentInsets: NSEdgeInsets
     let onActivate: (String) -> Void
-    let onHover: (String) -> Void
+    let onHoverChange: (String, Bool) -> Void
 
     func makeNSView(context: Context) -> TagNavigationHostView {
         let view = TagNavigationHostView()
@@ -26,7 +26,7 @@
             orientation: orientation,
             contentInsets: contentInsets,
             onActivate: onActivate,
-            onHover: onHover
+            onHoverChange: onHoverChange
         )
         return view
     }
@@ -37,7 +37,7 @@
             orientation: orientation,
             contentInsets: contentInsets,
             onActivate: onActivate,
-            onHover: onHover
+            onHoverChange: onHoverChange
         )
     }
 }
@@ -63,14 +63,14 @@
         orientation: TagNavigationView.Orientation,
         contentInsets: NSEdgeInsets,
         onActivate: @escaping (String) -> Void,
-        onHover: @escaping (String) -> Void
+        onHoverChange: @escaping (String, Bool) -> Void
     ) {
         documentView.update(
             items: items,
             orientation: orientation,
             contentInsets: contentInsets,
             onActivate: onActivate,
-            onHover: onHover
+            onHoverChange: onHoverChange
         )
         scrollView.hasHorizontalScroller = orientation == .horizontal
         scrollView.hasVerticalScroller = orientation == .vertical
@@ -109,7 +109,7 @@
     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 }
+    private var onHoverChange: (String, Bool) -> Void = { _, _ in }
 
     override var isFlipped: Bool { true }
 
@@ -118,14 +118,14 @@
         orientation: TagNavigationView.Orientation,
         contentInsets: NSEdgeInsets,
         onActivate: @escaping (String) -> Void,
-        onHover: @escaping (String) -> Void
+        onHoverChange: @escaping (String, Bool) -> Void
     ) {
         let needsRebuild = self.items != items || self.orientation != orientation
         self.items = items
         self.orientation = orientation
         self.contentInsets = contentInsets
         self.onActivate = onActivate
-        self.onHover = onHover
+        self.onHoverChange = onHoverChange
 
         if needsRebuild {
             rebuildButtons()
@@ -170,7 +170,7 @@
         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) }
+            button.onHoverChange = { [weak self] tagID, active in self?.onHoverChange(tagID, active) }
             addSubview(button)
             return button
         }
@@ -200,11 +200,12 @@
 
 final class TagNavigationButton: NSButton {
     var onActivate: (String) -> Void = { _ in }
-    var onHover: (String) -> Void = { _ in }
+    var onHoverChange: (String, Bool) -> Void = { _, _ in }
 
     private var item: TagNavigationItem
     private var orientation: TagNavigationView.Orientation
     private var trackingAreaRef: NSTrackingArea?
+    private var isMouseInside = false
 
     var preferredSize: NSSize {
         let font = NSFont.systemFont(ofSize: 13, weight: .medium)
@@ -262,7 +263,16 @@
 
     override func mouseEntered(with event: NSEvent) {
         super.mouseEntered(with: event)
-        onHover(item.id)
+        guard !isMouseInside else { return }
+        isMouseInside = true
+        onHoverChange(item.id, true)
+    }
+
+    override func mouseExited(with event: NSEvent) {
+        super.mouseExited(with: event)
+        guard isMouseInside else { return }
+        isMouseInside = false
+        onHoverChange(item.id, false)
     }
 
     @objc private func performActivation() {
diff --git a/TODO.md b/TODO.md
index d940cd0..6d5eebd 100644
--- a/TODO.md
+++ b/TODO.md
@@ -11,21 +11,14 @@
 
 ## In Progress
 
-- [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 与容器高亮。
+  - 分支: `codex/post-freeze-new-requirement`
   - 目标: hover 标签时高亮对应容器,保留 colorless container 的填充/取消逻辑。
   - 验收: hover 触发频率、滚动不过度、视觉不闪烁、全屏/Split View 不跳 Space。
+  - QA: 构建、签名、本地化 JSON、hover 静态 QA、colorless grid 屏幕 hover/点击复核、窗口 8 逻辑 QA 已通过;待用户最终体验验收后移动到 `Done`。
+  - 结果: 标签 hover 进入/离开已接入 AppKit;hover 高亮只刷新可见运行态,不触发 App Grid reload;重复 hover 滚动已去抖。
+
+## Todo
 
 - [2026-05-28] 第二批 AppKit 化第 4 项:标签栏 AppKit 化第三阶段,拖拽排序。
   - 目标: 用 AppKit hit-testing 替换 SwiftUI `GeometryReader + preference` 的标签排序命中逻辑。
@@ -61,6 +54,11 @@
 
 ## Done
 
+- [2026-05-28] 第二批 AppKit 化第 2 项:标签栏 AppKit 化第一阶段,只读导航。
+  - 提交: `b4a1ba5`
+  - 结果: 默认启用 AppKit 标签导航,顶部/左侧/右侧标签栏显示和点击滚动接入完成;`useAppKitTagNavigation=false` 可回滚到旧 SwiftUI 标签栏。
+  - 验证: 构建、签名、本地化 JSON、三种位置屏幕点击复核、SwiftUI 回滚开关、窗口 8 逻辑 QA 全部通过;用户已确认第 2 项 OK。
+
 - [2026-05-28] 第二批 AppKit 化第 1 项:清理 `ContentView` 的 App Grid 交互职责。
   - 提交: `561541e`
   - 结果: `ContentView` 中 App Grid 的 bubble、drag、drop、refresh toast 临时状态已收口到 `AppGridInteractionState`。

--
Gitblit v1.9.3