| Apptag/ApptagApp.swift | ●●●●● patch | view | raw | blame | history | |
| Apptag/ContentView.swift | ●●●●● patch | view | raw | blame | history | |
| Apptag/QuickSearch.swift | ●●●●● patch | view | raw | blame | history | |
| Scripts/window_logic_qa.sh | ●●●●● patch | view | raw | blame | history | |
| TODO.md | ●●●●● patch | view | raw | blame | history |
Apptag/ApptagApp.swift
@@ -54,6 +54,7 @@ private var statusItem: NSStatusItem? private var overlayWindow: NSWindow? private var overlayKeyMonitor: Any? private var quickSearchLocalMouseMonitor: Any? private var quickSearchExternalMouseMonitor: Any? private var settingsWindow: NSWindow? // Track Settings window to keep it above overlay private var mainHotkeyRef: EventHotKeyRef? @@ -1279,6 +1280,7 @@ /// Settings must always appear centered over the current overlay view and float above it. private func prepareSettingsWindow(_ window: NSWindow) { dismissQuickSearchIfNeeded() let settingsSize = NSSize(width: 880, height: 460) window.identifier = NSUserInterfaceItemIdentifier("TagLauncherPreferencesWindow") window.minSize = settingsSize @@ -1518,6 +1520,7 @@ } private func installQuickSearchExternalMouseMonitor() { installQuickSearchLocalMouseMonitor() guard quickSearchExternalMouseMonitor == nil else { return } quickSearchExternalMouseMonitor = NSEvent.addGlobalMonitorForEvents( matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown] @@ -1529,7 +1532,25 @@ } } private func installQuickSearchLocalMouseMonitor() { guard quickSearchLocalMouseMonitor == nil else { return } quickSearchLocalMouseMonitor = NSEvent.addLocalMonitorForEvents( matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown] ) { [weak self] event in guard let self, self.isQuickSearchOpen else { return event } if event.window === self.overlayWindow { NotificationCenter.default.post(name: .tagLauncherQuickSearchDismissRequested, object: nil) return nil } return event } } private func removeQuickSearchExternalMouseMonitor() { if let quickSearchLocalMouseMonitor { NSEvent.removeMonitor(quickSearchLocalMouseMonitor) self.quickSearchLocalMouseMonitor = nil } if let quickSearchExternalMouseMonitor { NSEvent.removeMonitor(quickSearchExternalMouseMonitor) self.quickSearchExternalMouseMonitor = nil @@ -1571,6 +1592,7 @@ } @objc private func openPreferences(_ sender: Any? = nil) { dismissQuickSearchIfNeeded() TagDatabase.flushPendingCategorySchemeBackupBatch() if overlayAvoidsSpaceSwitch { refreshLauncherChromeState(activate: false, avoidSpaceSwitch: true) @@ -1602,6 +1624,14 @@ prepareSettingsWindow(window) } private func dismissQuickSearchIfNeeded() { guard isQuickSearchOpen else { return } isQuickSearchOpen = false removeQuickSearchExternalMouseMonitor() updateOverlayLevelForTextInput() NotificationCenter.default.post(name: .tagLauncherQuickSearchDismissRequested, object: nil) } @objc private func switchLanguage(_ sender: NSMenuItem) { guard let code = sender.representedObject as? String else { return } L10n.switchTo(code) Apptag/ContentView.swift
@@ -664,7 +664,7 @@ } .zIndex(899) QuickSearchOverlayView( QuickSearchPanelPresentationView( query: $quickSearchQuery, results: quickSearchResults, selectedID: quickSearchSelectedID, @@ -672,16 +672,15 @@ selectionScrollToken: quickSearchSelectionScrollToken, isLoading: quickSearchDocuments.isEmpty && refreshInProgress, maxVisibleRows: quickSearchMaxVisibleRows(in: proxy.size), panelTopY: quickSearchPanelTopY(in: proxy.size), panelHeight: quickSearchPanelContentHeight(in: proxy.size), errorMessage: quickSearchErrorMessage, onCommand: handleQuickSearchCommand, onHover: selectQuickSearchResult, onLaunch: launchQuickSearchResult ) .position( x: proxy.size.width / 2, y: quickSearchPanelCenterY(in: proxy.size) ) .transition(.scale(scale: 0.98).combined(with: .opacity)) .frame(width: 0, height: 0) .allowsHitTesting(false) .zIndex(900) } } @@ -689,10 +688,15 @@ .animation(.easeOut(duration: 0.12), value: quickSearchVisible) } private func quickSearchPanelCenterY(in size: CGSize) -> CGFloat { let visibleRows = max(1, min(quickSearchResults.isEmpty ? 1 : quickSearchResults.count, quickSearchMaxVisibleRows(in: size))) let estimatedPanelHeight = CGFloat(visibleRows) * 76 + 122 return quickSearchPanelTopY(in: size) + estimatedPanelHeight / 2 private func quickSearchPanelContentHeight(in size: CGSize) -> CGFloat { let hasResultList = !(quickSearchDocuments.isEmpty && refreshInProgress) && quickSearchErrorMessage == nil && !quickSearchResults.isEmpty let visibleRows = min(max(1, quickSearchResults.count), quickSearchMaxVisibleRows(in: size)) return QuickSearchPanelMetrics.contentHeight( hasResultList: hasResultList, visibleRows: visibleRows ) } private func quickSearchPanelTopY(in size: CGSize) -> CGFloat { @@ -701,8 +705,11 @@ private func quickSearchMaxVisibleRows(in size: CGSize) -> Int { let bottomClearance: CGFloat = 84 let chromeHeight: CGFloat = 122 let rowHeightWithSpacing: CGFloat = 76 let chromeHeight = QuickSearchPanelMetrics.headerHeight + QuickSearchPanelMetrics.dividerHeight + QuickSearchPanelMetrics.resultListVerticalInset * 2 let rowHeightWithSpacing = QuickSearchPanelMetrics.rowHeight + QuickSearchPanelMetrics.rowSpacing let availableHeight = max(0, size.height - quickSearchPanelTopY(in: size) - bottomClearance - chromeHeight) return max(1, min(8, Int(floor(availableHeight / rowHeightWithSpacing)))) } Apptag/QuickSearch.swift
@@ -729,6 +729,252 @@ case dismiss } enum QuickSearchPanelMetrics { static let width: CGFloat = 760 static let rowHeight: CGFloat = 74 static let rowSpacing: CGFloat = 2 static let resultListVerticalInset: CGFloat = 10 static let headerHeight: CGFloat = 84 static let dividerHeight: CGFloat = 1 static let messageRowHeight: CGFloat = 86 static func contentHeight(hasResultList: Bool, visibleRows: Int) -> CGFloat { if hasResultList { return headerHeight + dividerHeight + CGFloat(max(1, visibleRows)) * (rowHeight + rowSpacing) + resultListVerticalInset * 2 } return headerHeight + dividerHeight + messageRowHeight } } struct QuickSearchPanelPresentationView: NSViewRepresentable { @Binding var query: String let results: [QuickSearchResult] let selectedID: URL? let focusToken: Int let selectionScrollToken: Int let isLoading: Bool let maxVisibleRows: Int let panelTopY: CGFloat let panelHeight: CGFloat let errorMessage: String? let onCommand: (QuickSearchCommand) -> Void let onHover: (QuickSearchResult) -> Void let onLaunch: (QuickSearchResult) -> Void func makeNSView(context: Context) -> QuickSearchPanelAnchorView { QuickSearchPanelAnchorView() } func updateNSView(_ view: QuickSearchPanelAnchorView, context: Context) { let size = NSSize(width: QuickSearchPanelMetrics.width, height: panelHeight) let rootView = QuickSearchPanelWindowContent( size: size, content: AnyView( QuickSearchOverlayView( query: $query, results: results, selectedID: selectedID, focusToken: focusToken, selectionScrollToken: selectionScrollToken, isLoading: isLoading, maxVisibleRows: maxVisibleRows, errorMessage: errorMessage, onCommand: onCommand, onHover: onHover, onLaunch: onLaunch ) ) ) context.coordinator.apply( QuickSearchPanelConfiguration( panelTopY: panelTopY, size: size, rootView: AnyView(rootView) ), anchorView: view ) } static func dismantleNSView(_ nsView: QuickSearchPanelAnchorView, coordinator: Coordinator) { coordinator.closePanel() } func makeCoordinator() -> Coordinator { Coordinator() } final class Coordinator { private var panel: QuickSearchPanel? private var hostingView: NSHostingView<AnyView>? private weak var parentWindow: NSWindow? private var deferredApplyPending = false func apply(_ configuration: QuickSearchPanelConfiguration, anchorView: QuickSearchPanelAnchorView) { guard let parentWindow = anchorView.window else { scheduleDeferredApply(configuration, anchorView: anchorView) return } deferredApplyPending = false let panel = ensurePanel(parentWindow: parentWindow) let frame = windowFrame( parentWindow: parentWindow, panelTopY: configuration.panelTopY, size: configuration.size ) hostingView?.rootView = configuration.rootView hostingView?.frame = NSRect(origin: .zero, size: configuration.size) panel.contentView?.frame = NSRect(origin: .zero, size: configuration.size) panel.collectionBehavior = collectionBehavior(parentWindow: parentWindow) panel.level = parentWindow.level if panel.parent !== parentWindow { if let previousParent = panel.parent { previousParent.removeChildWindow(panel) } parentWindow.addChildWindow(panel, ordered: .above) self.parentWindow = parentWindow } if panel.frame != frame { panel.setFrame(frame, display: true) } panel.makeKeyAndOrderFront(nil) panel.orderFrontRegardless() } func closePanel() { if let panel { if let parent = panel.parent { parent.removeChildWindow(panel) } panel.orderOut(nil) } panel = nil hostingView = nil parentWindow = nil deferredApplyPending = false } private func ensurePanel(parentWindow: NSWindow) -> QuickSearchPanel { if let panel { return panel } let panel = QuickSearchPanel( contentRect: .zero, styleMask: [.borderless, .fullSizeContentView, .nonactivatingPanel], backing: .buffered, defer: false ) panel.isFloatingPanel = true panel.hidesOnDeactivate = false panel.isOpaque = false panel.backgroundColor = .clear panel.hasShadow = false panel.titlebarAppearsTransparent = true panel.titleVisibility = .hidden panel.isReleasedWhenClosed = false panel.collectionBehavior = collectionBehavior(parentWindow: parentWindow) let hostingView = NSHostingView(rootView: AnyView(EmptyView())) hostingView.wantsLayer = true hostingView.layer?.backgroundColor = NSColor.clear.cgColor panel.contentView = hostingView self.panel = panel self.hostingView = hostingView return panel } private func scheduleDeferredApply( _ configuration: QuickSearchPanelConfiguration, anchorView: QuickSearchPanelAnchorView ) { guard !deferredApplyPending else { return } deferredApplyPending = true DispatchQueue.main.async { [weak self, weak anchorView] in guard let self, let anchorView else { return } self.deferredApplyPending = false self.apply(configuration, anchorView: anchorView) } } private func windowFrame( parentWindow: NSWindow, panelTopY: CGFloat, size: NSSize ) -> NSRect { let parentFrame = parentWindow.frame return NSRect( x: parentFrame.minX + (parentFrame.width - size.width) / 2, y: parentFrame.maxY - panelTopY - size.height, width: size.width, height: size.height ) } private func collectionBehavior(parentWindow: NSWindow) -> NSWindow.CollectionBehavior { var behavior: NSWindow.CollectionBehavior = [ .fullScreenAuxiliary, .stationary, .transient, .ignoresCycle ] if parentWindow.collectionBehavior.contains(.canJoinAllSpaces) { behavior.insert(.canJoinAllSpaces) } return behavior } } } private final class QuickSearchPanel: NSPanel { override var canBecomeKey: Bool { true } override var canBecomeMain: Bool { false } override func constrainFrameRect(_ frameRect: NSRect, to screen: NSScreen?) -> NSRect { frameRect } } final class QuickSearchPanelAnchorView: NSView { override init(frame frameRect: NSRect) { super.init(frame: frameRect) setup() } required init?(coder: NSCoder) { super.init(coder: coder) setup() } private func setup() { wantsLayer = true layer?.backgroundColor = NSColor.clear.cgColor } } struct QuickSearchPanelConfiguration { let panelTopY: CGFloat let size: NSSize let rootView: AnyView } private struct QuickSearchPanelWindowContent: View { let size: NSSize let content: AnyView var body: some View { content .frame(width: size.width, height: size.height, alignment: .top) } } struct QuickSearchOverlayView: View { @Binding var query: String let results: [QuickSearchResult] @@ -744,8 +990,8 @@ @Environment(\.colorScheme) private var colorScheme private let panelWidth: CGFloat = 760 private let rowHeight: CGFloat = 74 private let panelWidth: CGFloat = QuickSearchPanelMetrics.width private let rowHeight: CGFloat = QuickSearchPanelMetrics.rowHeight private var panelBackgroundColor: Color { colorScheme == .dark @@ -806,7 +1052,11 @@ onHover: onHover, onLaunch: onLaunch ) .frame(height: CGFloat(min(results.count, maxVisibleRows)) * (rowHeight + 2) + 20) .frame( height: CGFloat(min(results.count, maxVisibleRows)) * (rowHeight + QuickSearchPanelMetrics.rowSpacing) + QuickSearchPanelMetrics.resultListVerticalInset * 2 ) } } .frame(width: panelWidth) Scripts/window_logic_qa.sh
@@ -381,6 +381,39 @@ } } func rect(_ window: WindowInfo) -> CGRect { CGRect( x: dimension(window.bounds, "X"), y: dimension(window.bounds, "Y"), width: dimension(window.bounds, "Width"), height: dimension(window.bounds, "Height") ) } func isOverlayWindow(_ window: WindowInfo) -> Bool { guard window.name.isEmpty else { return false } let windowFrame = rect(window) return NSScreen.screens.contains { screen in let screenFrame = screen.frame return abs(windowFrame.width - screenFrame.width) <= 12 && windowFrame.height >= screenFrame.height * 0.75 && abs(windowFrame.midX - screenFrame.midX) <= 12 } } func isQuickSearchWindow(_ window: WindowInfo) -> Bool { guard window.name.isEmpty, !isOverlayWindow(window) else { return false } let windowFrame = rect(window) return windowFrame.width >= 500 && windowFrame.width <= 900 && windowFrame.height >= 120 && windowFrame.height <= 850 } func isSettingsLikeWindow(_ window: WindowInfo) -> Bool { !window.name.isEmpty } func assertTagLayer(_ tag: [WindowInfo]) { for window in tag where window.layer != 23 { fail("TagLauncher window has unexpected layer \(window.layer); expected 23") @@ -395,6 +428,7 @@ case "overlay": guard tag.count == 1 else { fail("overlay expected 1 TagLauncher window, got \(tag.count)") } assertTagLayer(tag) guard isOverlayWindow(tag[0]) else { fail("overlay window was not full-screen sized: \(tag[0].bounds)") } let menubarLayer = windows.first { $0.owner == "Window Server" && $0.name == "Menubar" }?.layer guard menubarLayer == 24 else { fail("menubar layer expected 24, got \(String(describing: menubarLayer))") } let dockWindows = windows.filter { $0.owner == "Dock" } @@ -404,7 +438,12 @@ case "settings": guard tag.count == 2 else { fail("settings expected 2 TagLauncher windows, got \(tag.count)") } assertTagLayer(tag) guard !tag[0].name.isEmpty, tag[1].name.isEmpty else { let overlays = tag.filter(isOverlayWindow) let settings = tag.filter(isSettingsLikeWindow) guard overlays.count == 1, settings.count == 1 else { fail("settings stack wrong: names=\(tag.map(\.name)) bounds=\(tag.map(\.bounds))") } guard !tag[0].name.isEmpty else { fail("settings order wrong: \(tag.map(\.name))") } print("PASS settings over overlay: order=\(tag.map(\.name))") @@ -451,26 +490,33 @@ print("PASS fullscreen target: layer=\(target.layer) bounds=\(target.bounds)") case "fullscreen-overlay": guard tag.count == 1 else { fail("fullscreen overlay expected 1 TagLauncher window, got \(tag.count)") } guard tag.count == 1 || tag.count == 2 else { fail("fullscreen overlay expected 1 or 2 TagLauncher windows, got \(tag.count)") } assertTagLayer(tag) guard let target = windows.first(where: { $0.name == "TagLauncherFullscreenQATargetFullscreen" }) else { fail("fullscreen target disappeared; TagLauncher likely switched to another Space") } let overlayWidth = dimension(tag[0].bounds, "Width") let overlayHeight = dimension(tag[0].bounds, "Height") let overlayMidX = dimension(tag[0].bounds, "X") + overlayWidth / 2 guard let overlay = tag.first(where: isOverlayWindow) else { fail("fullscreen overlay TagLauncher window not found: \(tag.map(\.bounds))") } let quickSearch = tag.filter(isQuickSearchWindow) guard quickSearch.count == tag.count - 1 else { fail("fullscreen overlay stack has unexpected windows: \(tag.map(\.bounds))") } let overlayWidth = dimension(overlay.bounds, "Width") let overlayHeight = dimension(overlay.bounds, "Height") let overlayMidX = dimension(overlay.bounds, "X") + overlayWidth / 2 let targetWidth = dimension(target.bounds, "Width") let targetHeight = dimension(target.bounds, "Height") let targetMidX = dimension(target.bounds, "X") + targetWidth / 2 guard abs(overlayWidth - targetWidth) <= 12, overlayHeight >= targetHeight * 0.88, abs(overlayMidX - targetMidX) <= 12 else { fail("fullscreen overlay is not on the target fullscreen display: overlay=\(tag[0].bounds) target=\(target.bounds)") fail("fullscreen overlay is not on the target fullscreen display: overlay=\(overlay.bounds) target=\(target.bounds)") } guard tag[0].layer > target.layer else { fail("TagLauncher layer \(tag[0].layer) is not above fullscreen target layer \(target.layer)") guard tag.allSatisfy({ $0.layer > target.layer }) else { fail("TagLauncher stack is not above fullscreen target") } print("PASS fullscreen overlay above target: overlayLayer=\(tag[0].layer) targetLayer=\(target.layer)") print("PASS fullscreen overlay above target: tagLayers=\(tag.map(\.layer)) targetLayer=\(target.layer)") case "fullscreen-settings": guard tag.count == 2 else { fail("fullscreen settings expected 2 TagLauncher windows, got \(tag.count)") } @@ -478,7 +524,12 @@ guard let target = windows.first(where: { $0.name == "TagLauncherFullscreenQATargetFullscreen" }) else { fail("fullscreen target disappeared after opening settings; TagLauncher likely switched to another Space") } guard !tag[0].name.isEmpty, tag[1].name.isEmpty else { let overlays = tag.filter(isOverlayWindow) let settings = tag.filter(isSettingsLikeWindow) guard overlays.count == 1, settings.count == 1 else { fail("fullscreen settings stack wrong: names=\(tag.map(\.name)) bounds=\(tag.map(\.bounds))") } guard !tag[0].name.isEmpty else { fail("fullscreen settings order wrong: \(tag.map(\.name))") } guard tag.allSatisfy({ $0.layer > target.layer }) else { TODO.md
@@ -13,11 +13,6 @@ ## Todo - [2026-05-28] Quick Search 独立面板化。 - 目标: 从主 overlay 里拆出来,做 Spotlight 风格独立 `NSPanel`。 - 风险: 高,涉及焦点、Esc、外部点击、全局快捷键、窗口层级。 - 验收: 普通桌面、全屏、Split View、Settings 同时存在时都不跳 Space。 - [2026-05-28] OverlayWindowController 收口。 - 目标: 把 `AppDelegate` 里的 overlay 显示、隐藏、层级、屏幕选择拆出去。 - 时机: Quick Search 窗口边界稳定后再做。 @@ -33,6 +28,12 @@ ## Done - [2026-05-29] Quick Search 独立面板化。 - 提交: 本次提交 - 结果: Quick Search 面板已从主 overlay SwiftUI 层拆出,改为独立 `NSPanel` 并作为 overlay child window 显示;搜索状态、输入框、结果列表、启动逻辑保持原路径。 - 补强: Quick Search panel 与 overlay 保持同层级,避免压过菜单栏;打开 Settings 前统一关闭 Quick Search;本应用内外部点击可关闭 Quick Search 但保留 App Grid。 - 验证: `bash build.sh`、`codesign --verify --deep --strict`、`Scripts/window_logic_qa.sh` 全部通过;屏幕 QA 验证打开、输入 `sor`、上下键选中和 Esc 关闭均正常。 - [2026-05-29] Quick Search 结果列表 AppKit 化。 - 提交: 本次提交 - 结果: Quick Search 结果列表已替换为 AppKit `NSScrollView` + 自绘 row;输入框、搜索逻辑、快捷键命令、外层 overlay 保持原路径。