From 406c5898d103427193e589b4bbe615d0c739b2c5 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Tue, 02 Jun 2026 19:36:31 +0800
Subject: [PATCH] Prepare 7.8.9 tag navigation drop test package
---
Apptag/OverlayWindowController.swift | 16 +
Apptag/ApptagApp.swift | 83 ++++++++
Apptag/Diagnostics.swift | 44 ++++
Apptag/TagNavigationView.swift | 126 ++++++++++++-
Scripts/window_logic_qa.sh | 85 +++++++++
Apptag/ContentView.swift | 75 ++++++++
AGENTS.md | 13 +
Apptag/Info.plist | 4
Apptag/AppDragCoordinator.swift | 51 ++++-
CHANGELOG.md | 18 ++
Apptag/QuickSearch.swift | 4
11 files changed, 478 insertions(+), 41 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..1d17d55
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,13 @@
+# AGENTS.md - Taglauncher Agent 入口
+
+## 项目属性
+
+项目类型:研发项目
+主要产物:macOS App
+
+## 项目规则
+
+- 默认遵守 `/Users/ar/.codex/AGENTS.md`;项目本地文档、脚本和 release 规则优先。
+- 发布、分发或验证安装包前,必须读取并遵守 `~/.codex/rules/release-versioning.md`。
+- 可分发构建应先运行 `./build.sh` 生成 `.app`,再运行 `./make_dmg.sh` 生成 `.dmg`;只生成 `.app` 只能视为本地 QA 构建。
+- 版本、build 编号、`CHANGELOG.md`、`Release/` 资料和最终安装包文件必须保持一致。
diff --git a/Apptag/AppDragCoordinator.swift b/Apptag/AppDragCoordinator.swift
index e767f95..2dff55b 100644
--- a/Apptag/AppDragCoordinator.swift
+++ b/Apptag/AppDragCoordinator.swift
@@ -25,6 +25,7 @@
private var dragWindow: NSWindow?
private var dragImageSize: NSSize = .zero
private var activePayload = ""
+ private weak var hoveredTarget: AppDropTargetReceivingView?
private init() {}
@@ -129,15 +130,20 @@
CATransaction.setDisableActions(true)
dragLayer.position = contentPoint
CATransaction.commit()
+ updateHoverTarget(at: screenPoint)
return
}
- guard let dragWindow else { return }
+ guard let dragWindow else {
+ updateHoverTarget(at: screenPoint)
+ return
+ }
let origin = NSPoint(
x: screenPoint.x - dragImageSize.width / 2,
y: screenPoint.y - dragImageSize.height / 2
)
dragWindow.setFrameOrigin(origin)
+ updateHoverTarget(at: screenPoint)
}
func finishDrag(at screenPoint: NSPoint, copy: Bool) {
@@ -147,18 +153,7 @@
let source = parts.dropFirst().first ?? ""
pruneDeadTargets()
- let hitTarget = targets.values
- .compactMap { target -> (AppDropTargetReceivingView, CGFloat)? in
- guard let view = target.view,
- let frame = view.screenFrame(),
- frame.contains(screenPoint)
- else { return nil }
- return (view, frame.width * frame.height)
- }
- .sorted { $0.1 < $1.1 }
- .first?.0
-
- if let hitTarget {
+ if let hitTarget = dropTarget(at: screenPoint) {
hitTarget.performDrop(path: path, source: source, copy: copy)
return
}
@@ -191,6 +186,8 @@
}
private func endDragVisuals() {
+ hoveredTarget?.appDragHoverChanged(active: false)
+ hoveredTarget = nil
dragLayer?.removeFromSuperlayer()
dragLayer = nil
dragLayerHostView = nil
@@ -207,6 +204,29 @@
private func pruneDeadTargets() {
targets = targets.filter { $0.value.view != nil }
emptyTargets = emptyTargets.filter { $0.value.view != nil }
+ }
+
+ private func updateHoverTarget(at screenPoint: NSPoint) {
+ guard hasActiveDrag else { return }
+ pruneDeadTargets()
+ let nextTarget = dropTarget(at: screenPoint)
+ guard nextTarget !== hoveredTarget else { return }
+ hoveredTarget?.appDragHoverChanged(active: false)
+ nextTarget?.appDragHoverChanged(active: true)
+ hoveredTarget = nextTarget
+ }
+
+ private func dropTarget(at screenPoint: NSPoint) -> AppDropTargetReceivingView? {
+ targets.values
+ .compactMap { target -> (AppDropTargetReceivingView, CGFloat)? in
+ guard let view = target.view,
+ let frame = view.screenFrame(),
+ frame.contains(screenPoint)
+ else { return nil }
+ return (view, frame.width * frame.height)
+ }
+ .sorted { $0.1 < $1.1 }
+ .first?.0
}
private static func cgImage(from image: NSImage) -> CGImage? {
@@ -261,6 +281,7 @@
protocol AppDropTargetReceivingView: AnyObject {
func screenFrame() -> NSRect?
+ func appDragHoverChanged(active: Bool)
func performDrop(path: String, source: String, copy: Bool)
}
@@ -277,6 +298,10 @@
}
}
+extension AppDropTargetReceivingView {
+ func appDragHoverChanged(active: Bool) {}
+}
+
extension AppEmptyDropReceivingView where Self: NSView {
func screenFrame() -> NSRect? {
guard let window else { return nil }
diff --git a/Apptag/ApptagApp.swift b/Apptag/ApptagApp.swift
index 7008544..c35007f 100644
--- a/Apptag/ApptagApp.swift
+++ b/Apptag/ApptagApp.swift
@@ -62,6 +62,7 @@
private var lastShowDockIcon: Bool?
private var statusMenuScreenForNextOverlay: NSScreen?
private var suppressReopenUntil = Date.distantPast
+ private var overlayOpenedByQuickSearchOnly = false
private lazy var overlayController = OverlayWindowController(
dependencies: OverlayWindowController.Dependencies(
@@ -106,6 +107,11 @@
self?.removeOverlayKeyMonitor()
},
removeQuickSearchMouseMonitor: { [weak self] in
+ Diagnostics.log("app.quickSearch.removeMouseMonitor", [
+ "quickSearchOpen": self?.isQuickSearchOpen,
+ "quickSearchOnlyOverlaySession": self?.quickSearchOnlyOverlaySession,
+ "quickSearchShouldHideOverlayOnClose": self?.quickSearchShouldHideOverlayOnClose
+ ])
self?.removeQuickSearchExternalMouseMonitor()
self?.quickSearchShouldHideOverlayOnClose = false
self?.quickSearchOnlyOverlaySession = false
@@ -122,7 +128,8 @@
onWillHide: {
TagDatabase.flushPendingCategorySchemeBackupBatch()
},
- onDidHide: {
+ onDidHide: { [weak self] in
+ self?.overlayOpenedByQuickSearchOnly = false
NotificationCenter.default.post(name: .tagLauncherOverlayDidHide, object: nil)
},
onDidShow: {
@@ -220,9 +227,22 @@
return false
}
guard UserDefaults.standard.bool(forKey: Self.showDockIconKey) else { return false }
- guard isPointerNearDockArea() else { return false }
+ guard isRecentUserClickNearDockArea() else { return false }
showOrFocusOverlay()
return false // Suppress default "unhide all windows" behavior
+ }
+
+ private func isRecentUserClickNearDockArea() -> Bool {
+ let lastMouseDown = CGEventSource.secondsSinceLastEventType(
+ .combinedSessionState,
+ eventType: .leftMouseDown
+ )
+ let lastMouseUp = CGEventSource.secondsSinceLastEventType(
+ .combinedSessionState,
+ eventType: .leftMouseUp
+ )
+ let recentMouseClick = min(lastMouseDown, lastMouseUp) < 1.2
+ return recentMouseClick && isPointerNearDockArea()
}
private func isPointerNearDockArea() -> Bool {
@@ -881,10 +901,12 @@
}
private func performToggleOverlay(preferredScreen: NSScreen?) {
+ overlayOpenedByQuickSearchOnly = false
overlayController.toggle(preferredScreen: preferredScreen)
}
private func showOrFocusOverlay(preferredScreen: NSScreen? = nil) {
+ overlayOpenedByQuickSearchOnly = false
overlayController.showOrFocus(preferredScreen: preferredScreen)
}
@@ -893,10 +915,20 @@
preferredScreen: NSScreen? = nil,
stagedForAllSpaces: Bool = false
) {
+ Diagnostics.log("app.overlay.showRequest", [
+ "initialQuickSearchSource": initialQuickSearchSource,
+ "stagedForAllSpaces": stagedForAllSpaces,
+ "isQuickSearchOpen": isQuickSearchOpen,
+ "quickSearchOnlyOverlaySession": quickSearchOnlyOverlaySession,
+ "overlayOpenedByQuickSearchOnly": overlayOpenedByQuickSearchOnly
+ ])
if initialQuickSearchSource == QuickSearchOpenSource.globalHidden {
isQuickSearchOpen = true
quickSearchShouldHideOverlayOnClose = true
quickSearchOnlyOverlaySession = true
+ overlayOpenedByQuickSearchOnly = true
+ } else if initialQuickSearchSource == nil {
+ overlayOpenedByQuickSearchOnly = false
}
overlayController.show(
initialQuickSearchSource: initialQuickSearchSource,
@@ -959,8 +991,9 @@
if shouldHideOverlayAfterDismiss {
quickSearchShouldHideOverlayOnClose = false
quickSearchOnlyOverlaySession = false
+ overlayOpenedByQuickSearchOnly = true
DispatchQueue.main.async { [weak self] in
- self?.hideOverlay(force: true)
+ self?.hideOverlay(force: true, discardWindow: true)
}
}
}
@@ -971,6 +1004,9 @@
|| source == QuickSearchOpenSource.globalHidden
quickSearchOnlyOverlaySession = quickSearchOnlyOverlaySession
|| source == QuickSearchOpenSource.globalHidden
+ if source == QuickSearchOpenSource.globalHidden {
+ overlayOpenedByQuickSearchOnly = true
+ }
promoteOverlayToForegroundInput()
updateOverlayLevelForTextInput()
installQuickSearchExternalMouseMonitor()
@@ -1014,6 +1050,14 @@
}
private func hideOverlay(force: Bool = false, discardWindow: Bool = false) {
+ Diagnostics.log("app.overlay.hideRequest", [
+ "force": force,
+ "discardWindow": discardWindow,
+ "isQuickSearchOpen": isQuickSearchOpen,
+ "quickSearchOnlyOverlaySession": quickSearchOnlyOverlaySession,
+ "quickSearchShouldHideOverlayOnClose": quickSearchShouldHideOverlayOnClose,
+ "overlayOpenedByQuickSearchOnly": overlayOpenedByQuickSearchOnly
+ ])
overlayController.hide(force: force, discardWindow: discardWindow)
}
@@ -1106,6 +1150,13 @@
}
private func handleHotkeyEvent(id: UInt32) {
+ Diagnostics.log("app.hotkey", [
+ "id": id,
+ "isOverlayVisible": isOverlayVisible,
+ "isQuickSearchOpen": isQuickSearchOpen,
+ "quickSearchOnlyOverlaySession": quickSearchOnlyOverlaySession,
+ "overlayOpenedByQuickSearchOnly": overlayOpenedByQuickSearchOnly
+ ])
if id == LauncherHotkeyKind.quickSearch.eventID {
showQuickSearchFromGlobalHotkey()
} else {
@@ -1115,9 +1166,14 @@
private func showQuickSearchFromGlobalHotkey() {
let now = Date()
- if overlayWindow?.isVisible == true, isQuickSearchOpen || quickSearchOnlyOverlaySession {
+ if overlayWindow?.isVisible == true,
+ isQuickSearchOpen || quickSearchOnlyOverlaySession || overlayOpenedByQuickSearchOnly {
lastQuickSearchHotkeyAt = now
- dismissQuickSearchFromKeyboard()
+ if isQuickSearchOpen || quickSearchOnlyOverlaySession {
+ dismissQuickSearchFromKeyboard()
+ } else {
+ hideOverlay(force: true)
+ }
return
}
@@ -1411,10 +1467,23 @@
guard let self else { return }
let active = (notification.userInfo?["active"] as? Bool) ?? false
let hideOverlayOnClose = (notification.userInfo?["hideOverlayOnClose"] as? Bool) ?? false
+ let wasQuickSearchOnlyOverlay = self.quickSearchOnlyOverlaySession
+ || self.quickSearchShouldHideOverlayOnClose
+ || self.overlayOpenedByQuickSearchOnly
+ Diagnostics.log("app.quickSearch.visibilityChanged", [
+ "active": active,
+ "hideOverlayOnClose": hideOverlayOnClose,
+ "isQuickSearchOpenBefore": self.isQuickSearchOpen,
+ "quickSearchOnlyOverlaySessionBefore": self.quickSearchOnlyOverlaySession,
+ "quickSearchShouldHideOverlayOnCloseBefore": self.quickSearchShouldHideOverlayOnClose,
+ "overlayOpenedByQuickSearchOnlyBefore": self.overlayOpenedByQuickSearchOnly,
+ "wasQuickSearchOnlyOverlay": wasQuickSearchOnlyOverlay
+ ])
self.isQuickSearchOpen = active
if active, hideOverlayOnClose {
self.quickSearchShouldHideOverlayOnClose = true
self.quickSearchOnlyOverlaySession = true
+ self.overlayOpenedByQuickSearchOnly = true
}
if self.isQuickSearchOpen {
self.promoteOverlayToForegroundInput()
@@ -1424,10 +1493,10 @@
self.installQuickSearchExternalMouseMonitor()
} else {
self.removeQuickSearchExternalMouseMonitor()
- if hideOverlayOnClose || self.quickSearchShouldHideOverlayOnClose {
+ if hideOverlayOnClose || wasQuickSearchOnlyOverlay {
self.quickSearchShouldHideOverlayOnClose = false
self.quickSearchOnlyOverlaySession = false
- self.hideOverlay(force: true)
+ self.hideOverlay(force: true, discardWindow: wasQuickSearchOnlyOverlay)
}
}
}
diff --git a/Apptag/ContentView.swift b/Apptag/ContentView.swift
index 9b6ce7c..f29d986 100644
--- a/Apptag/ContentView.swift
+++ b/Apptag/ContentView.swift
@@ -318,6 +318,7 @@
var appDragResetToken = 0
var bubbleDraftNote = ""
var tagNavigationHoveredGroupName: String? = nil
+ var tagNavigationAppDropTargetName: String? = nil
var tagNavigationLastHoverScrollID: String? = nil
var tagNavigationLastHoverScrollAt: Date? = nil
}
@@ -948,6 +949,7 @@
private func appKitTagNavigation(orientation: TagNavigationView.Orientation) -> some View {
let isHorizontal = orientation == .horizontal
+ let appDropTargetID = appGridInteraction.tagNavigationAppDropTargetName
return TagNavigationView(
items: tagLabels,
orientation: orientation,
@@ -956,6 +958,8 @@
: NSEdgeInsets(top: 12, left: 12, bottom: 12, right: 12),
dragModeActive: tagNavDragModeActive,
draggingItemID: tagNavDragItem,
+ appDragModeActive: appGridInteraction.appDragModeActive,
+ appDropTargetID: appDropTargetID,
onActivate: { tagID in
activateTagNavigation(tagID)
},
@@ -965,6 +969,9 @@
canReorder: { tagID in
canReorderTag(tagID)
},
+ canAcceptAppDrop: { tagID in
+ canAssignDroppedAppToTag(tagID)
+ },
onReorderBegan: { tagID in
beginTagNavReorder(tagID)
},
@@ -973,6 +980,12 @@
},
onReorderEnded: {
endTagNavReorder()
+ },
+ onAppDropHoverChange: { tagID, active in
+ handleTagNavigationAppDropHover(tagID, active: active)
+ },
+ onAppDrop: { path, source, targetTag, copy in
+ dropAppOnTagNavigation(path: path, sourceTag: source, targetTag: targetTag, copy: copy)
}
)
}
@@ -1903,6 +1916,13 @@
}
private func handleTagNavigationHover(_ id: String, active: Bool) {
+ if appGridInteraction.appDragModeActive {
+ if !active, appGridInteraction.tagNavigationHoveredGroupName == id {
+ appGridInteraction.tagNavigationHoveredGroupName = nil
+ }
+ return
+ }
+
guard active else {
if appGridInteraction.tagNavigationHoveredGroupName == id {
appGridInteraction.tagNavigationHoveredGroupName = nil
@@ -1913,6 +1933,24 @@
appGridInteraction.tagNavigationHoveredGroupName = id
fillColorlessContainer(id)
scrollToTagFromHover(id)
+ }
+
+ private func handleTagNavigationAppDropHover(_ id: String, active: Bool) {
+ guard appGridInteraction.appDragModeActive else {
+ if appGridInteraction.tagNavigationAppDropTargetName == id {
+ appGridInteraction.tagNavigationAppDropTargetName = nil
+ }
+ return
+ }
+
+ guard active, canAssignDroppedAppToTag(id) else {
+ if appGridInteraction.tagNavigationAppDropTargetName == id {
+ appGridInteraction.tagNavigationAppDropTargetName = nil
+ }
+ return
+ }
+
+ appGridInteraction.tagNavigationAppDropTargetName = id
}
private func scrollToTagFromHover(_ id: String) {
@@ -2050,6 +2088,22 @@
)
showDropRefresh()
refreshApps(forceLayoutRefresh: true)
+ }
+
+ private func dropAppOnTagNavigation(path: String, sourceTag: String, targetTag: String, copy: Bool) {
+ resetTransientDragState()
+ guard canAssignDroppedAppToTag(targetTag) else { return }
+ guard let app = allApps.first(where: { $0.path.path == path }) else { return }
+ guard !appHasTag(app, tagName: targetTag) else { return }
+ TagEditor.appendTags([targetTag], to: [path])
+ showDropRefresh()
+ refreshApps(forceLayoutRefresh: true)
+ }
+
+ private func canAssignDroppedAppToTag(_ tagName: String) -> Bool {
+ tagColors[tagName] != nil
+ && !isAppleBuiltInDropTarget(tagName)
+ && !isUncategorizedDropTarget(tagName)
}
private func dropAppOutsideGroup(path: String, sourceTag: String, copy: Bool) {
@@ -2263,12 +2317,17 @@
if active {
endTagNavReorder()
clearAppBubbleState()
+ appGridInteraction.tagNavigationAppDropTargetName = nil
}
appGridInteraction.appDragModeActive = active
+ if !active {
+ appGridInteraction.tagNavigationAppDropTargetName = nil
+ }
if active {
DispatchQueue.main.asyncAfter(deadline: .now() + 8) {
if appGridInteraction.appDragModeActive && !AppDragCoordinator.shared.hasActiveDrag {
appGridInteraction.appDragModeActive = false
+ appGridInteraction.tagNavigationAppDropTargetName = nil
}
}
}
@@ -2282,6 +2341,9 @@
AppDragCoordinator.shared.cancelDrag()
if appGridInteraction.appDragModeActive {
appGridInteraction.appDragModeActive = false
+ }
+ if appGridInteraction.tagNavigationAppDropTargetName != nil {
+ appGridInteraction.tagNavigationAppDropTargetName = nil
}
if hadAppDragState {
appGridInteraction.appDragResetToken &+= 1
@@ -2355,6 +2417,13 @@
) {
appGridInteraction.appDragModeActive = false
endTagNavReorder()
+ let closeQuickSearchAndOverlayBeforeOpening = closeQuickSearchOnSuccess
+ && closeOverlayOnSuccess
+ && (quickSearchCloseHidesOverlay || quickSearchOnlySession)
+ if closeQuickSearchAndOverlayBeforeOpening {
+ closeQuickSearch(hideOverlayIfNeeded: true)
+ hideOverlay()
+ }
let configuration = NSWorkspace.OpenConfiguration()
NSWorkspace.shared.openApplication(at: app.path, configuration: configuration) { _, error in
DispatchQueue.main.async {
@@ -2366,10 +2435,10 @@
DispatchQueue.global(qos: .utility).async {
TagEditor.recordLauncherOpen(for: app.path.path)
}
- if closeQuickSearchOnSuccess {
- closeQuickSearch(hideOverlayIfNeeded: false)
+ if closeQuickSearchOnSuccess && !closeQuickSearchAndOverlayBeforeOpening {
+ closeQuickSearch(hideOverlayIfNeeded: closeOverlayOnSuccess)
}
- if closeOverlayOnSuccess {
+ if closeOverlayOnSuccess && !closeQuickSearchAndOverlayBeforeOpening {
hideOverlay()
}
}
diff --git a/Apptag/Diagnostics.swift b/Apptag/Diagnostics.swift
new file mode 100644
index 0000000..78536b1
--- /dev/null
+++ b/Apptag/Diagnostics.swift
@@ -0,0 +1,44 @@
+import Foundation
+
+enum Diagnostics {
+ private static let enabledKey = "diagnosticLoggingEnabled"
+ private static let queue = DispatchQueue(label: "com.taglauncher.app.diagnostics")
+
+ static var logURL: URL {
+ AppIdentity.applicationSupportDirectory
+ .appendingPathComponent("TagLauncher-diagnostics.log")
+ }
+
+ static func log(_ event: String, _ fields: [String: CustomStringConvertible?] = [:]) {
+ guard UserDefaults.standard.bool(forKey: enabledKey) else { return }
+ let timestamp = ISO8601DateFormatter().string(from: Date())
+ let suffix = fields
+ .sorted { $0.key < $1.key }
+ .map { key, value in "\(key)=\(value?.description ?? "nil")" }
+ .joined(separator: " ")
+ let line = suffix.isEmpty
+ ? "\(timestamp) \(event)\n"
+ : "\(timestamp) \(event) \(suffix)\n"
+
+ queue.async {
+ do {
+ try FileManager.default.createDirectory(
+ at: AppIdentity.applicationSupportDirectory,
+ withIntermediateDirectories: true
+ )
+ if !FileManager.default.fileExists(atPath: logURL.path) {
+ FileManager.default.createFile(atPath: logURL.path, contents: nil)
+ }
+ let handle = try FileHandle(forWritingTo: logURL)
+ defer { try? handle.close() }
+ try handle.seekToEnd()
+ if let data = line.data(using: .utf8) {
+ try handle.write(contentsOf: data)
+ }
+ } catch {
+ fputs("[TagLauncher] Diagnostics log failed: \(error)\n", stderr)
+ }
+ }
+ }
+
+}
diff --git a/Apptag/Info.plist b/Apptag/Info.plist
index 1d5c6b7..e72c52a 100644
--- a/Apptag/Info.plist
+++ b/Apptag/Info.plist
@@ -19,9 +19,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
- <string>7.8.7</string>
+ <string>7.8.9</string>
<key>CFBundleVersion</key>
- <string>20260531.0252</string>
+ <string>20260602.1929</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.utilities</string>
<key>LSMinimumSystemVersion</key>
diff --git a/Apptag/OverlayWindowController.swift b/Apptag/OverlayWindowController.swift
index 1472d2e..5ca9317 100644
--- a/Apptag/OverlayWindowController.swift
+++ b/Apptag/OverlayWindowController.swift
@@ -130,6 +130,7 @@
initialQuickSearchSource: initialQuickSearchSource
)
generation &+= 1
+ let showGeneration = generation
window = newWindow
dependencies.installOverlayKeyMonitor()
if shouldStageAsAccessory {
@@ -149,6 +150,7 @@
let placementFrame = placement.frame
let finishForegroundClaim: () -> Void = { [weak self, weak newWindow] in
guard let self, let newWindow, self.window === newWindow else { return }
+ guard self.generation == showGeneration, newWindow.isVisible else { return }
self.dependencies.refreshChromeState(!shouldAvoidSpaceSwitch, shouldAvoidSpaceSwitch)
if newWindow.frame != placementFrame {
newWindow.setFrame(placementFrame, display: true)
@@ -157,6 +159,7 @@
for delay in [0.2, 0.5] {
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self, weak newWindow] in
guard let self, let newWindow, self.window === newWindow else { return }
+ guard self.generation == showGeneration, newWindow.isVisible else { return }
guard newWindow.frame != placementFrame else { return }
newWindow.setFrame(placementFrame, display: true)
self.orderFront(newWindow)
@@ -182,15 +185,20 @@
if let settingsWindow = dependencies.settingsWindow(), settingsWindow.parent == window {
dependencies.detachSettingsWindow(settingsWindow)
}
- window?.orderOut(nil)
+ let windowToHide = window
+ windowToHide?.ignoresMouseEvents = true
+ windowToHide?.alphaValue = 0
+ windowToHide?.orderOut(nil)
dependencies.removeOverlayKeyMonitor()
dependencies.removeQuickSearchMouseMonitor()
+ windowToHide?.contentView = nil
+ windowToHide?.close()
+ if window === windowToHide {
+ window = nil
+ }
avoidsSpaceSwitch = false
dependencies.refreshChromeState(false, false)
dependencies.onDidHide()
- if discardWindow {
- window = nil
- }
}
func screenUnderMouse() -> NSScreen? {
diff --git a/Apptag/QuickSearch.swift b/Apptag/QuickSearch.swift
index e8eb530..5a09ef3 100644
--- a/Apptag/QuickSearch.swift
+++ b/Apptag/QuickSearch.swift
@@ -1385,7 +1385,9 @@
override func mouseDown(with event: NSEvent) {
if let result {
- onLaunch(result)
+ DispatchQueue.main.async { [onLaunch] in
+ onLaunch(result)
+ }
} else {
super.mouseDown(with: event)
}
diff --git a/Apptag/TagNavigationView.swift b/Apptag/TagNavigationView.swift
index 327ab7a..449895d 100644
--- a/Apptag/TagNavigationView.swift
+++ b/Apptag/TagNavigationView.swift
@@ -18,12 +18,17 @@
let contentInsets: NSEdgeInsets
let dragModeActive: Bool
let draggingItemID: String?
+ let appDragModeActive: Bool
+ let appDropTargetID: String?
let onActivate: (String) -> Void
let onHoverChange: (String, Bool) -> Void
let canReorder: (String) -> Bool
+ let canAcceptAppDrop: (String) -> Bool
let onReorderBegan: (String) -> Void
let onReorderMoved: (String, String) -> Void
let onReorderEnded: () -> Void
+ let onAppDropHoverChange: (String, Bool) -> Void
+ let onAppDrop: (String, String, String, Bool) -> Void
func makeNSView(context: Context) -> TagNavigationHostView {
let view = TagNavigationHostView()
@@ -33,12 +38,17 @@
contentInsets: contentInsets,
dragModeActive: dragModeActive,
draggingItemID: draggingItemID,
+ appDragModeActive: appDragModeActive,
+ appDropTargetID: appDropTargetID,
onActivate: onActivate,
onHoverChange: onHoverChange,
canReorder: canReorder,
+ canAcceptAppDrop: canAcceptAppDrop,
onReorderBegan: onReorderBegan,
onReorderMoved: onReorderMoved,
- onReorderEnded: onReorderEnded
+ onReorderEnded: onReorderEnded,
+ onAppDropHoverChange: onAppDropHoverChange,
+ onAppDrop: onAppDrop
)
return view
}
@@ -50,12 +60,17 @@
contentInsets: contentInsets,
dragModeActive: dragModeActive,
draggingItemID: draggingItemID,
+ appDragModeActive: appDragModeActive,
+ appDropTargetID: appDropTargetID,
onActivate: onActivate,
onHoverChange: onHoverChange,
canReorder: canReorder,
+ canAcceptAppDrop: canAcceptAppDrop,
onReorderBegan: onReorderBegan,
onReorderMoved: onReorderMoved,
- onReorderEnded: onReorderEnded
+ onReorderEnded: onReorderEnded,
+ onAppDropHoverChange: onAppDropHoverChange,
+ onAppDrop: onAppDrop
)
}
}
@@ -82,12 +97,17 @@
contentInsets: NSEdgeInsets,
dragModeActive: Bool,
draggingItemID: String?,
+ appDragModeActive: Bool,
+ appDropTargetID: String?,
onActivate: @escaping (String) -> Void,
onHoverChange: @escaping (String, Bool) -> Void,
canReorder: @escaping (String) -> Bool,
+ canAcceptAppDrop: @escaping (String) -> Bool,
onReorderBegan: @escaping (String) -> Void,
onReorderMoved: @escaping (String, String) -> Void,
- onReorderEnded: @escaping () -> Void
+ onReorderEnded: @escaping () -> Void,
+ onAppDropHoverChange: @escaping (String, Bool) -> Void,
+ onAppDrop: @escaping (String, String, String, Bool) -> Void
) {
documentView.update(
items: items,
@@ -95,15 +115,20 @@
contentInsets: contentInsets,
dragModeActive: dragModeActive,
draggingItemID: draggingItemID,
+ appDragModeActive: appDragModeActive,
+ appDropTargetID: appDropTargetID,
onActivate: onActivate,
onHoverChange: onHoverChange,
canReorder: canReorder,
+ canAcceptAppDrop: canAcceptAppDrop,
onReorderBegan: onReorderBegan,
onReorderMoved: onReorderMoved,
- onReorderEnded: onReorderEnded
+ onReorderEnded: onReorderEnded,
+ onAppDropHoverChange: onAppDropHoverChange,
+ onAppDrop: onAppDrop
)
- scrollView.hasHorizontalScroller = orientation == .horizontal
- scrollView.hasVerticalScroller = orientation == .vertical
+ scrollView.hasHorizontalScroller = false
+ scrollView.hasVerticalScroller = false
needsLayout = true
}
@@ -126,6 +151,8 @@
scrollView.borderType = .noBorder
scrollView.autohidesScrollers = true
scrollView.scrollerStyle = .overlay
+ scrollView.horizontalScrollElasticity = .allowed
+ scrollView.verticalScrollElasticity = .allowed
scrollView.hasHorizontalScroller = false
scrollView.hasVerticalScroller = false
scrollView.documentView = documentView
@@ -140,12 +167,17 @@
private var buttons: [TagNavigationButton] = []
private var dragModeActive = false
private var draggingItemID: String?
+ private var appDragModeActive = false
+ private var appDropTargetID: String?
private var onActivate: (String) -> Void = { _ in }
private var onHoverChange: (String, Bool) -> Void = { _, _ in }
private var canReorder: (String) -> Bool = { _ in false }
+ private var canAcceptAppDrop: (String) -> Bool = { _ in false }
private var onReorderBegan: (String) -> Void = { _ in }
private var onReorderMoved: (String, String) -> Void = { _, _ in }
private var onReorderEnded: () -> Void = {}
+ private var onAppDropHoverChange: (String, Bool) -> Void = { _, _ in }
+ private var onAppDrop: (String, String, String, Bool) -> Void = { _, _, _, _ in }
override var isFlipped: Bool { true }
@@ -155,12 +187,17 @@
contentInsets: NSEdgeInsets,
dragModeActive: Bool,
draggingItemID: String?,
+ appDragModeActive: Bool,
+ appDropTargetID: String?,
onActivate: @escaping (String) -> Void,
onHoverChange: @escaping (String, Bool) -> Void,
canReorder: @escaping (String) -> Bool,
+ canAcceptAppDrop: @escaping (String) -> Bool,
onReorderBegan: @escaping (String) -> Void,
onReorderMoved: @escaping (String, String) -> Void,
- onReorderEnded: @escaping () -> Void
+ onReorderEnded: @escaping () -> Void,
+ onAppDropHoverChange: @escaping (String, Bool) -> Void,
+ onAppDrop: @escaping (String, String, String, Bool) -> Void
) {
let oldNames = self.items.map(\.name)
let newNames = items.map(\.name)
@@ -170,12 +207,17 @@
self.contentInsets = contentInsets
self.dragModeActive = dragModeActive
self.draggingItemID = draggingItemID
+ self.appDragModeActive = appDragModeActive
+ self.appDropTargetID = appDropTargetID
self.onActivate = onActivate
self.onHoverChange = onHoverChange
self.canReorder = canReorder
+ self.canAcceptAppDrop = canAcceptAppDrop
self.onReorderBegan = onReorderBegan
self.onReorderMoved = onReorderMoved
self.onReorderEnded = onReorderEnded
+ self.onAppDropHoverChange = onAppDropHoverChange
+ self.onAppDrop = onAppDrop
if needsRebuild {
rebuildButtons()
@@ -226,6 +268,14 @@
self?.handleReorderMove(tagID: tagID, screenPoint: screenPoint)
}
button.onReorderEnded = { [weak self] in self?.onReorderEnded() }
+ button.canAcceptAppDrop = { [weak self] tagID in self?.canAcceptAppDrop(tagID) ?? false }
+ button.onAppDropHoverChange = { [weak self] tagID, active in
+ self?.setLocalAppDropHover(tagID, active: active)
+ self?.onAppDropHoverChange(tagID, active)
+ }
+ button.onAppDrop = { [weak self] path, source, targetTag, copy in
+ self?.onAppDrop(path, source, targetTag, copy)
+ }
addSubview(button)
return button
}
@@ -236,17 +286,30 @@
buttons = items.compactMap { item in
guard let button = existing[item.name] else { return nil }
button.configure(item: item, orientation: orientation)
+ button.refreshAppDropRegistration()
return button
}
}
private func updateButtonRuntimeState() {
for button in buttons {
+ let isAppDropTarget = appDropTargetID == button.itemName
+ let appDropVisualActive = appDropTargetID != nil
button.configureRuntime(
- dragModeActive: dragModeActive && canReorder(button.itemName),
- isDragging: draggingItemID == button.itemName
+ dragModeActive: (dragModeActive && canReorder(button.itemName)) || appDropVisualActive,
+ isDragging: draggingItemID == button.itemName || isAppDropTarget
)
+ button.refreshAppDropRegistration()
}
+ }
+
+ private func setLocalAppDropHover(_ tagID: String, active: Bool) {
+ if active {
+ appDropTargetID = tagID
+ } else if appDropTargetID == tagID {
+ appDropTargetID = nil
+ }
+ updateButtonRuntimeState()
}
private func handleReorderMove(tagID: String, screenPoint: NSPoint) {
@@ -283,13 +346,16 @@
}
}
-final class TagNavigationButton: NSButton {
+final class TagNavigationButton: NSButton, AppDropTargetReceivingView {
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 = {}
+ var canAcceptAppDrop: (String) -> Bool = { _ in false }
+ var onAppDropHoverChange: (String, Bool) -> Void = { _, _ in }
+ var onAppDrop: (String, String, String, Bool) -> Void = { _, _, _, _ in }
private var item: TagNavigationItem
private var orientation: TagNavigationView.Orientation
@@ -297,6 +363,8 @@
private var isMouseInside = false
private var dragModeActive = false
private var isDragging = false
+ private let appDropTargetID = UUID()
+ private var appDropRegistered = false
var itemName: String { item.name }
@@ -330,12 +398,16 @@
}
func configure(item: TagNavigationItem, orientation: TagNavigationView.Orientation) {
+ if self.item.name != item.name {
+ unregisterAppDropTarget()
+ }
self.item = item
self.orientation = orientation
title = item.name
alignment = orientation == .horizontal ? .center : .left
setAccessibilityLabel(item.name)
updateAppearance()
+ refreshAppDropRegistration()
needsLayout = true
}
@@ -343,6 +415,34 @@
self.dragModeActive = dragModeActive
self.isDragging = isDragging
updateAppearance()
+ }
+
+ func refreshAppDropRegistration() {
+ guard window != nil, canAcceptAppDrop(item.id) else {
+ unregisterAppDropTarget()
+ return
+ }
+ AppDragCoordinator.shared.register(id: appDropTargetID, view: self, tag: item.name)
+ appDropRegistered = true
+ }
+
+ override func viewDidMoveToWindow() {
+ super.viewDidMoveToWindow()
+ refreshAppDropRegistration()
+ }
+
+ func appDragHoverChanged(active: Bool) {
+ guard canAcceptAppDrop(item.id) || !active else { return }
+ onAppDropHoverChange(item.name, active)
+ }
+
+ func performDrop(path: String, source: String, copy: Bool) {
+ guard canAcceptAppDrop(item.id) else { return }
+ onAppDrop(path, source, item.name, copy)
+ }
+
+ deinit {
+ unregisterAppDropTarget()
}
override func updateTrackingAreas() {
@@ -421,6 +521,12 @@
onActivate(item.id)
}
+ private func unregisterAppDropTarget() {
+ guard appDropRegistered else { return }
+ AppDragCoordinator.shared.unregister(id: appDropTargetID)
+ appDropRegistered = false
+ }
+
private func updateAppearance() {
let background = TagColor.nsColor(for: item.colorIndex)
let dragScale: CGFloat = isDragging ? 1.04 : 1.0
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5b3936c..d0827d1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,23 @@
# TagLauncher Changelog
+## [7.8.9] — 2026-06-02
+
+- 新增 App Grid 拖拽到标签栏建立关联:在 5 种 App Grid 视图中,App 图标可拖到顶部、左侧或右侧标签名称上,放手后追加该标签关联
+- 拖到标签名称时复用标签排序的视觉反馈:目标标签浮起,其他标签变暗,帮助用户确认即将生效的标签
+- 标签栏 drop 只追加目标标签,不删除原有标签;已有关联时不重复写入
+- 复用现有 App 拖拽协调器并增加 drop-target hover 通知,避免新增平行拖拽系统
+- 版本号更新为 `7.8.9`,Build 更新为 `20260602.1929`
+
+## [7.8.8] — 2026-05-31
+
+- 修复全局 `Fn + Space` Quick Search-only 场景下,鼠标在候选区 hover 或点击结果后 App Grid / Overlay 可能残留或重新冒出的问题
+- Quick Search 结果点击改为在鼠标事件完成后的主队列启动,避免 AppKit `mouseDown` 与窗口关闭交叉导致背板露出
+- Overlay 关闭时不再复用已隐藏的旧 `NSPanel`,每次关闭都释放窗口实例,下一次显示重新绑定当前 Space,减少隐藏 Dock / 全屏 / Split View 场景下的幽灵窗口风险
+- 移除顶部 AppKit 标签栏下方多余的浅灰色滚动条,同时保留标签栏横向滚动能力
+- 加固 Dock 图标显式打开 App Grid 的判定:必须是最近真实鼠标点击且位于 Dock 区域,避免重复启动或程序化打开误触发 App Grid
+- 窗口 QA 增加隐藏 Dock 下 Quick Search hover、点击搜索结果不弹 App Grid 的专项回归,并继续覆盖全屏、Split View、Settings、文件面板、Dock 图标和滚动后键盘路由
+- 版本号更新为 `7.8.8`,Build 更新为 `20260531.1601`
+
## [7.8.7] — 2026-05-31
- 修复关闭“Show in Dock”后用主快捷键打开 App Grid 时 TagLauncher Dock 图标仍会闪现的问题;隐藏 Dock 图标设置现在严格生效
diff --git a/Scripts/window_logic_qa.sh b/Scripts/window_logic_qa.sh
index 4a333b0..5935181 100755
--- a/Scripts/window_logic_qa.sh
+++ b/Scripts/window_logic_qa.sh
@@ -97,6 +97,22 @@
osascript -e 'tell application "System Events" to keystroke space using {option down, shift down}'
}
+send_quick_search_hotkey() {
+ swift - <<'SWIFT' >/dev/null 2>&1
+import CoreGraphics
+import Foundation
+
+let source = CGEventSource(stateID: .hidSystemState)
+let down = CGEvent(keyboardEventSource: source, virtualKey: 49, keyDown: true)!
+down.flags = [.maskSecondaryFn]
+down.post(tap: .cghidEventTap)
+usleep(80_000)
+let up = CGEvent(keyboardEventSource: source, virtualKey: 49, keyDown: false)!
+up.flags = [.maskSecondaryFn]
+up.post(tap: .cghidEventTap)
+SWIFT
+}
+
send_cmd_comma() {
osascript -e 'tell application "System Events" to keystroke "," using {command down}'
}
@@ -767,6 +783,25 @@
let overlayWidth = dimension(overlayBounds, "Width")
let overlayHeight = dimension(overlayBounds, "Height")
print("\(Int(round(overlayX + overlayWidth / 2))) \(Int(round(overlayY + overlayHeight / 2)))")
+case "quick-search-result":
+ guard let quickSearch = tag.first(where: { window in
+ let name = (window[kCGWindowName as String] as? String) ?? ""
+ let bounds = window[kCGWindowBounds as String] as? NSDictionary ?? [:]
+ let width = dimension(bounds, "Width")
+ let height = dimension(bounds, "Height")
+ return name.isEmpty
+ && width >= 500
+ && width <= 900
+ && height >= 120
+ && height <= 850
+ }), let bounds = quickSearch[kCGWindowBounds as String] as? NSDictionary else {
+ fputs("FAIL: could not find quick search bounds\n", stderr)
+ exit(1)
+ }
+ let x = dimension(bounds, "X")
+ let y = dimension(bounds, "Y")
+ let width = dimension(bounds, "Width")
+ print("\(Int(round(x + width * 0.35))) \(Int(round(y + 125)))")
case "fullscreen-target-center":
guard let target = raw.first(where: { ($0[kCGWindowName as String] as? String) == "TagLauncherFullscreenQATargetFullscreen" }),
let bounds = target[kCGWindowBounds as String] as? NSDictionary else {
@@ -1026,6 +1061,23 @@
click_xy "$x" "$y"
}
+hover_quick_search_results() {
+ local coords x y
+ coords="$(swift "$coords_swift" quick-search-result)"
+ read -r x y <<<"$coords"
+ for offset in 0 42 84 126 84 42 0; do
+ move_xy "$x" "$((y + offset))"
+ sleep 0.08
+ done
+}
+
+click_quick_search_result() {
+ local coords x y
+ coords="$(swift "$coords_swift" quick-search-result)"
+ read -r x y <<<"$coords"
+ click_xy "$x" "$y"
+}
+
page_scroll_appgrid() {
local coords
coords="$(swift "$coords_swift" overlay-center)"
@@ -1147,6 +1199,36 @@
kill_all_taglauncher_instances
sleep 0.4
swift_assert no-overlay
+prepare_isolated_app_instance
+assert_no_dock_tile
+log "==> QA hidden Dock: Fn+Space Quick Search hover does not resurrect App Grid"
+send_quick_search_hotkey
+sleep 0.8
+wait_swift_assert quick-search
+hover_quick_search_results
+sleep 0.8
+wait_swift_assert quick-search
+assert_no_dock_tile
+send_quick_search_hotkey
+sleep 0.8
+wait_swift_assert no-overlay
+assert_no_dock_tile
+kill_all_taglauncher_instances
+sleep 0.4
+swift_assert no-overlay
+prepare_isolated_app_instance
+assert_no_dock_tile
+log "==> QA hidden Dock: clicking Quick Search result closes without showing App Grid"
+send_quick_search_hotkey
+sleep 0.8
+wait_swift_assert quick-search
+click_quick_search_result
+sleep 1.4
+wait_swift_assert no-overlay
+assert_no_dock_tile
+kill_all_taglauncher_instances
+sleep 0.4
+swift_assert no-overlay
log "==> QA split-view fullscreen geometry detection"
swift_assert split-geometry
@@ -1164,6 +1246,7 @@
log "==> QA fullscreen Space: quick search from appgrid does not switch Space"
send_keycode 49
sleep 0.4
+ wait_swift_assert quick-search
assert_fullscreen_overlay_stable
send_keycode 53
sleep 0.3
@@ -1209,7 +1292,7 @@
swift_assert file-panel
send_keycode 53
sleep 0.3
-send_cmd_w
+close_settings_window
sleep 0.9
swift_assert overlay
assert_frontmost_taglauncher
--
Gitblit v1.9.3