From efbfc27e0c813c31b570d72fded40eb91d07f2db Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Thu, 28 May 2026 21:01:19 +0800
Subject: [PATCH] Add AppKit tag navigation reordering
---
TODO.md | 22 ++---
Apptag/TagNavigationView.swift | 165 ++++++++++++++++++++++++++++++++++++++--
Apptag/ContentView.swift | 25 +++++
3 files changed, 187 insertions(+), 25 deletions(-)
diff --git a/Apptag/ContentView.swift b/Apptag/ContentView.swift
index c060b8b..da98d57 100644
--- a/Apptag/ContentView.swift
+++ b/Apptag/ContentView.swift
@@ -934,11 +934,25 @@
contentInsets: isHorizontal
? NSEdgeInsets(top: 3, left: 24, bottom: 3, right: tagPosition == "top" ? floatingControlsReservedWidth : 24)
: NSEdgeInsets(top: 12, left: 12, bottom: 12, right: 12),
+ dragModeActive: tagNavDragModeActive,
+ draggingItemID: tagNavDragItem,
onActivate: { tagID in
activateTagNavigation(tagID)
},
onHoverChange: { tagID, active in
handleTagNavigationHover(tagID, active: active)
+ },
+ canReorder: { tagID in
+ canReorderTag(tagID)
+ },
+ onReorderBegan: { tagID in
+ beginTagNavReorder(tagID)
+ },
+ onReorderMoved: { tagID, targetID in
+ reorderTagNavItem(fromName: tagID, to: targetID)
+ },
+ onReorderEnded: {
+ endTagNavReorder()
}
)
}
@@ -2054,11 +2068,18 @@
private func reorderTagNavItem(at location: CGPoint) {
guard let fromName = tagNavDragItem,
let targetName = tagNavReorderFrames.first(where: { $0.value.contains(location) })?.key,
- fromName != targetName,
+ fromName != targetName
+ else { return }
+ reorderTagNavItem(fromName: fromName, to: targetName)
+ }
+
+ private func reorderTagNavItem(fromName: String, to targetName: String) {
+ guard fromName != targetName,
+ canReorderTag(fromName),
+ canReorderTag(targetName),
let fromIndex = draggedTagNames.firstIndex(of: fromName),
let toIndex = draggedTagNames.firstIndex(of: targetName)
else { return }
-
tagNavReorderDidMove = true
withAnimation(.spring(response: 0.22, dampingFraction: 0.82)) {
let destination = toIndex > fromIndex ? toIndex + 1 : toIndex
diff --git a/Apptag/TagNavigationView.swift b/Apptag/TagNavigationView.swift
index 2736f53..9f9941f 100644
--- a/Apptag/TagNavigationView.swift
+++ b/Apptag/TagNavigationView.swift
@@ -16,8 +16,14 @@
let items: [TagNavigationItem]
let orientation: Orientation
let contentInsets: NSEdgeInsets
+ let dragModeActive: Bool
+ let draggingItemID: String?
let onActivate: (String) -> Void
let onHoverChange: (String, Bool) -> Void
+ let canReorder: (String) -> Bool
+ let onReorderBegan: (String) -> Void
+ let onReorderMoved: (String, String) -> Void
+ let onReorderEnded: () -> Void
func makeNSView(context: Context) -> TagNavigationHostView {
let view = TagNavigationHostView()
@@ -25,8 +31,14 @@
items: items,
orientation: orientation,
contentInsets: contentInsets,
+ dragModeActive: dragModeActive,
+ draggingItemID: draggingItemID,
onActivate: onActivate,
- onHoverChange: onHoverChange
+ onHoverChange: onHoverChange,
+ canReorder: canReorder,
+ onReorderBegan: onReorderBegan,
+ onReorderMoved: onReorderMoved,
+ onReorderEnded: onReorderEnded
)
return view
}
@@ -36,8 +48,14 @@
items: items,
orientation: orientation,
contentInsets: contentInsets,
+ dragModeActive: dragModeActive,
+ draggingItemID: draggingItemID,
onActivate: onActivate,
- onHoverChange: onHoverChange
+ onHoverChange: onHoverChange,
+ canReorder: canReorder,
+ onReorderBegan: onReorderBegan,
+ onReorderMoved: onReorderMoved,
+ onReorderEnded: onReorderEnded
)
}
}
@@ -62,15 +80,27 @@
items: [TagNavigationItem],
orientation: TagNavigationView.Orientation,
contentInsets: NSEdgeInsets,
+ dragModeActive: Bool,
+ draggingItemID: String?,
onActivate: @escaping (String) -> Void,
- onHoverChange: @escaping (String, Bool) -> Void
+ onHoverChange: @escaping (String, Bool) -> Void,
+ canReorder: @escaping (String) -> Bool,
+ onReorderBegan: @escaping (String) -> Void,
+ onReorderMoved: @escaping (String, String) -> Void,
+ onReorderEnded: @escaping () -> Void
) {
documentView.update(
items: items,
orientation: orientation,
contentInsets: contentInsets,
+ dragModeActive: dragModeActive,
+ draggingItemID: draggingItemID,
onActivate: onActivate,
- onHoverChange: onHoverChange
+ onHoverChange: onHoverChange,
+ canReorder: canReorder,
+ onReorderBegan: onReorderBegan,
+ onReorderMoved: onReorderMoved,
+ onReorderEnded: onReorderEnded
)
scrollView.hasHorizontalScroller = orientation == .horizontal
scrollView.hasVerticalScroller = orientation == .vertical
@@ -108,8 +138,14 @@
private var orientation: TagNavigationView.Orientation = .horizontal
private var contentInsets = NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
private var buttons: [TagNavigationButton] = []
+ private var dragModeActive = false
+ private var draggingItemID: String?
private var onActivate: (String) -> Void = { _ in }
private var onHoverChange: (String, Bool) -> Void = { _, _ in }
+ private var canReorder: (String) -> Bool = { _ in false }
+ private var onReorderBegan: (String) -> Void = { _ in }
+ private var onReorderMoved: (String, String) -> Void = { _, _ in }
+ private var onReorderEnded: () -> Void = {}
override var isFlipped: Bool { true }
@@ -117,23 +153,36 @@
items: [TagNavigationItem],
orientation: TagNavigationView.Orientation,
contentInsets: NSEdgeInsets,
+ dragModeActive: Bool,
+ draggingItemID: String?,
onActivate: @escaping (String) -> Void,
- onHoverChange: @escaping (String, Bool) -> Void
+ onHoverChange: @escaping (String, Bool) -> Void,
+ canReorder: @escaping (String) -> Bool,
+ onReorderBegan: @escaping (String) -> Void,
+ onReorderMoved: @escaping (String, String) -> Void,
+ onReorderEnded: @escaping () -> Void
) {
- let needsRebuild = self.items != items || self.orientation != orientation
+ let oldNames = self.items.map(\.name)
+ let newNames = items.map(\.name)
+ let needsRebuild = Set(oldNames) != Set(newNames) || self.orientation != orientation
self.items = items
self.orientation = orientation
self.contentInsets = contentInsets
+ self.dragModeActive = dragModeActive
+ self.draggingItemID = draggingItemID
self.onActivate = onActivate
self.onHoverChange = onHoverChange
+ self.canReorder = canReorder
+ self.onReorderBegan = onReorderBegan
+ self.onReorderMoved = onReorderMoved
+ self.onReorderEnded = onReorderEnded
if needsRebuild {
rebuildButtons()
} else {
- for (button, item) in zip(buttons, items) {
- button.configure(item: item, orientation: orientation)
- }
+ reuseButtonsInCurrentOrder()
}
+ updateButtonRuntimeState()
needsLayout = true
}
@@ -171,9 +220,45 @@
let button = TagNavigationButton(item: item, orientation: orientation)
button.onActivate = { [weak self] tagID in self?.onActivate(tagID) }
button.onHoverChange = { [weak self] tagID, active in self?.onHoverChange(tagID, active) }
+ button.canReorder = { [weak self] tagID in self?.canReorder(tagID) ?? false }
+ button.onReorderBegan = { [weak self] tagID in self?.onReorderBegan(tagID) }
+ button.onReorderMoved = { [weak self] tagID, screenPoint in
+ self?.handleReorderMove(tagID: tagID, screenPoint: screenPoint)
+ }
+ button.onReorderEnded = { [weak self] in self?.onReorderEnded() }
addSubview(button)
return button
}
+ }
+
+ private func reuseButtonsInCurrentOrder() {
+ let existing = Dictionary(uniqueKeysWithValues: buttons.map { ($0.itemName, $0) })
+ buttons = items.compactMap { item in
+ guard let button = existing[item.name] else { return nil }
+ button.configure(item: item, orientation: orientation)
+ return button
+ }
+ }
+
+ private func updateButtonRuntimeState() {
+ for button in buttons {
+ button.configureRuntime(
+ dragModeActive: dragModeActive && canReorder(button.itemName),
+ isDragging: draggingItemID == button.itemName
+ )
+ }
+ }
+
+ private func handleReorderMove(tagID: String, screenPoint: NSPoint) {
+ guard let window else { return }
+ let windowPoint = window.convertPoint(fromScreen: screenPoint)
+ let point = convert(windowPoint, from: nil)
+ guard let target = buttons.first(where: { button in
+ button.itemName != tagID
+ && canReorder(button.itemName)
+ && button.frame.insetBy(dx: -4, dy: -4).contains(point)
+ }) else { return }
+ onReorderMoved(tagID, target.itemName)
}
private func layoutHorizontal() {
@@ -201,11 +286,19 @@
final class TagNavigationButton: NSButton {
var onActivate: (String) -> Void = { _ in }
var onHoverChange: (String, Bool) -> Void = { _, _ in }
+ var canReorder: (String) -> Bool = { _ in false }
+ var onReorderBegan: (String) -> Void = { _ in }
+ var onReorderMoved: (String, NSPoint) -> Void = { _, _ in }
+ var onReorderEnded: () -> Void = {}
private var item: TagNavigationItem
private var orientation: TagNavigationView.Orientation
private var trackingAreaRef: NSTrackingArea?
private var isMouseInside = false
+ private var dragModeActive = false
+ private var isDragging = false
+
+ var itemName: String { item.name }
var preferredSize: NSSize {
let font = NSFont.systemFont(ofSize: 13, weight: .medium)
@@ -246,6 +339,12 @@
needsLayout = true
}
+ func configureRuntime(dragModeActive: Bool, isDragging: Bool) {
+ self.dragModeActive = dragModeActive
+ self.isDragging = isDragging
+ updateAppearance()
+ }
+
override func updateTrackingAreas() {
super.updateTrackingAreas()
if let trackingAreaRef {
@@ -275,6 +374,49 @@
onHoverChange(item.id, false)
}
+ override func mouseDown(with event: NSEvent) {
+ guard canReorder(item.id) else {
+ onActivate(item.id)
+ return
+ }
+
+ let start = Date()
+ let longPressDuration: TimeInterval = 0.35
+ var reorderActive = false
+ var finished = false
+
+ while !finished {
+ if !reorderActive && Date().timeIntervalSince(start) >= longPressDuration {
+ reorderActive = true
+ onReorderBegan(item.id)
+ }
+
+ let nextEvent = window?.nextEvent(
+ matching: [.leftMouseDragged, .leftMouseUp],
+ until: Date().addingTimeInterval(0.04),
+ inMode: .eventTracking,
+ dequeue: true
+ )
+
+ guard let nextEvent else { continue }
+ switch nextEvent.type {
+ case .leftMouseDragged:
+ if reorderActive {
+ onReorderMoved(item.id, NSEvent.mouseLocation)
+ }
+ case .leftMouseUp:
+ if reorderActive {
+ onReorderEnded()
+ } else {
+ onActivate(item.id)
+ }
+ finished = true
+ default:
+ break
+ }
+ }
+ }
+
@objc private func performActivation() {
onActivate(item.id)
}
@@ -285,8 +427,9 @@
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)
+ layer?.shadowRadius = isDragging ? 8 : 3
+ layer?.shadowOffset = NSSize(width: 0, height: isDragging ? -4 : -1)
+ alphaValue = dragModeActive ? (isDragging ? 1.0 : 0.62) : 1.0
let textColor: NSColor = (item.colorIndex == 0 || item.colorIndex == 5) ? .labelColor : .white
let paragraph = NSMutableParagraphStyle()
diff --git a/TODO.md b/TODO.md
index 6d5eebd..0752b4b 100644
--- a/TODO.md
+++ b/TODO.md
@@ -11,19 +11,7 @@
## In Progress
-- [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` 的标签排序命中逻辑。
- - 持久化: 继续走 `TagEditor.reorderTags`。
- - 验收: 顶部/左/右三种方向拖拽排序;关闭重开后顺序保留;数据库顺序正确。
- [2026-05-28] 清理或改名 `AppGridItem.swift` 残留。
- 目标: 该文件现在主要是 shared metrics / bubble 类型,不再是旧浏览态 grid,名称容易误导。
@@ -54,6 +42,16 @@
## Done
+- [2026-05-28] 第二批 AppKit 化第 4 项:标签栏 AppKit 化第三阶段,拖拽排序。
+ - 提交: 本次提交
+ - 结果: AppKit 标签栏已支持长按拖拽排序;排序命中改为 AppKit button frame hit-testing;持久化继续走 `TagEditor.reorderTags`。
+ - 验证: 构建 `7.6.0 (20260528.2052)`、签名、静态 QA、顶部/左/右三种标签栏拖拽排序、`tags.json` 持久化、窗口 8 逻辑 QA 全部通过;QA 后已恢复用户标签顺序和语言自动设置。
+
+- [2026-05-28] 第二批 AppKit 化第 3 项:标签栏 AppKit 化第二阶段,hover 与容器高亮。
+ - 提交: `9d87dbe`
+ - 结果: 标签 hover 进入/离开已接入 AppKit;hover 高亮只刷新可见运行态,不触发 App Grid reload;重复 hover 滚动已去抖。
+ - 验证: 构建、签名、本地化 JSON、hover 静态 QA、colorless grid 屏幕 hover/点击复核、窗口 8 逻辑 QA 全部通过;用户已确认第 3 项 OK。
+
- [2026-05-28] 第二批 AppKit 化第 2 项:标签栏 AppKit 化第一阶段,只读导航。
- 提交: `b4a1ba5`
- 结果: 默认启用 AppKit 标签导航,顶部/左侧/右侧标签栏显示和点击滚动接入完成;`useAppKitTagNavigation=false` 可回滚到旧 SwiftUI 标签栏。
--
Gitblit v1.9.3