Ariver
2026-05-28 9d87dbed3f81d07a81b1484a1317d7f7c0258610
Stabilize tag navigation hover highlighting
4 files modified
109 ■■■■■ changed files
Apptag/AppGridCollectionView.swift 11 ●●●● patch | view | raw | blame | history
Apptag/ContentView.swift 44 ●●●● patch | view | raw | blame | history
Apptag/TagNavigationView.swift 32 ●●●●● patch | view | raw | blame | history
TODO.md 22 ●●●● patch | view | raw | blame | history
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
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)
    }
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() {
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`。