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