Ariver
2026-05-29 1797e722aa5453f646187ab61483f70f7e19fd1f
Extract quick search panel
5 files modified
399 ■■■■■ changed files
Apptag/ApptagApp.swift 30 ●●●●● patch | view | raw | blame | history
Apptag/ContentView.swift 31 ●●●●● patch | view | raw | blame | history
Apptag/QuickSearch.swift 256 ●●●●● patch | view | raw | blame | history
Scripts/window_logic_qa.sh 71 ●●●● patch | view | raw | blame | history
TODO.md 11 ●●●● 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 保持原路径。