From 1797e722aa5453f646187ab61483f70f7e19fd1f Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Fri, 29 May 2026 04:58:31 +0800
Subject: [PATCH] Extract quick search panel
---
Apptag/ApptagApp.swift | 30 ++++
TODO.md | 11
Scripts/window_logic_qa.sh | 71 ++++++++-
Apptag/ContentView.swift | 31 ++-
Apptag/QuickSearch.swift | 256 ++++++++++++++++++++++++++++++++++++
5 files changed, 369 insertions(+), 30 deletions(-)
diff --git a/Apptag/ApptagApp.swift b/Apptag/ApptagApp.swift
index 81eca50..3a5f1d7 100644
--- a/Apptag/ApptagApp.swift
+++ b/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)
diff --git a/Apptag/ContentView.swift b/Apptag/ContentView.swift
index da98d57..900cf4d 100644
--- a/Apptag/ContentView.swift
+++ b/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))))
}
diff --git a/Apptag/QuickSearch.swift b/Apptag/QuickSearch.swift
index bac6813..aab4e8f 100644
--- a/Apptag/QuickSearch.swift
+++ b/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)
diff --git a/Scripts/window_logic_qa.sh b/Scripts/window_logic_qa.sh
index f93b3f2..05fd697 100755
--- a/Scripts/window_logic_qa.sh
+++ b/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 {
diff --git a/TODO.md b/TODO.md
index 2d13d3c..1eeeed5 100644
--- a/TODO.md
+++ b/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 保持原路径。
--
Gitblit v1.9.3