Ariver
2026-05-28 b4a1ba5df0f073bf346c3f4407b613bf08d29f08
Add AppKit tag navigation
3 files modified
1 files added
409 ■■■■■ changed files
Apptag/AppDefaults.swift 2 ●●●●● patch | view | raw | blame | history
Apptag/ContentView.swift 92 ●●●● patch | view | raw | blame | history
Apptag/TagNavigationView.swift 295 ●●●●● patch | view | raw | blame | history
TODO.md 20 ●●●● patch | view | raw | blame | history
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
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
Apptag/TagNavigationView.swift
New file
@@ -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)
    }
}
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`