| AGENTS.md | ●●●●● patch | view | raw | blame | history | |
| Apptag/AppDragCoordinator.swift | ●●●●● patch | view | raw | blame | history | |
| Apptag/ApptagApp.swift | ●●●●● patch | view | raw | blame | history | |
| Apptag/ContentView.swift | ●●●●● patch | view | raw | blame | history | |
| Apptag/Diagnostics.swift | ●●●●● patch | view | raw | blame | history | |
| Apptag/Info.plist | ●●●●● patch | view | raw | blame | history | |
| Apptag/OverlayWindowController.swift | ●●●●● patch | view | raw | blame | history | |
| Apptag/QuickSearch.swift | ●●●●● patch | view | raw | blame | history | |
| Apptag/TagNavigationView.swift | ●●●●● patch | view | raw | blame | history | |
| CHANGELOG.md | ●●●●● patch | view | raw | blame | history | |
| Scripts/window_logic_qa.sh | ●●●●● patch | view | raw | blame | history |
AGENTS.md
New file @@ -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/` 资料和最终安装包文件必须保持一致。 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 } 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) } } } 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() } } Apptag/Diagnostics.swift
New file @@ -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) } } } } 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> 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? { 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) } 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 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 图标设置现在严格生效 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