From be83948fab7b643634ede2403abdca27e6b45f29 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Thu, 04 Jun 2026 01:09:14 +0800
Subject: [PATCH] Fix quick search early mouse dismiss

---
 Apptag/QuickSearch.swift |  382 ++++++++++++++++++++++++++++++++++++++++++++++++++---
 1 files changed, 356 insertions(+), 26 deletions(-)

diff --git a/Apptag/QuickSearch.swift b/Apptag/QuickSearch.swift
index bac6813..b933fd5 100644
--- a/Apptag/QuickSearch.swift
+++ b/Apptag/QuickSearch.swift
@@ -15,6 +15,13 @@
     static let globalVisible = "globalVisible"
 }
 
+enum QuickSearchDismissSource {
+    static let keyboard = "keyboard"
+    static let mouse = "mouse"
+    static let backdrop = "backdrop"
+    static let programmatic = "programmatic"
+}
+
 // MARK: - Hotkeys
 
 struct LauncherHotkey: Equatable {
@@ -729,6 +736,271 @@
     case dismiss
 }
 
+enum QuickSearchPanelMetrics {
+    static let width: CGFloat = 760
+    static let shadowOutset: CGFloat = 56
+    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 contentSize = NSSize(width: QuickSearchPanelMetrics.width, height: panelHeight)
+        let windowSize = NSSize(
+            width: contentSize.width + QuickSearchPanelMetrics.shadowOutset * 2,
+            height: contentSize.height + QuickSearchPanelMetrics.shadowOutset * 2
+        )
+        let rootView = QuickSearchPanelWindowContent(
+            windowSize: windowSize,
+            contentSize: contentSize,
+            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,
+                windowSize: windowSize,
+                contentSize: contentSize,
+                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,
+                contentSize: configuration.contentSize,
+                windowSize: configuration.windowSize
+            )
+
+            hostingView?.rootView = configuration.rootView
+            hostingView?.frame = NSRect(origin: .zero, size: configuration.windowSize)
+            panel.contentView?.frame = NSRect(origin: .zero, size: configuration.windowSize)
+            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,
+            contentSize: NSSize,
+            windowSize: NSSize
+        ) -> NSRect {
+            let parentFrame = parentWindow.frame
+            return NSRect(
+                x: parentFrame.minX + (parentFrame.width - contentSize.width) / 2 - QuickSearchPanelMetrics.shadowOutset,
+                y: parentFrame.maxY - panelTopY - contentSize.height - QuickSearchPanelMetrics.shadowOutset,
+                width: windowSize.width,
+                height: windowSize.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 windowSize: NSSize
+    let contentSize: NSSize
+    let rootView: AnyView
+}
+
+private struct QuickSearchPanelWindowContent: View {
+    let windowSize: NSSize
+    let contentSize: NSSize
+    let content: AnyView
+
+    var body: some View {
+        VStack(spacing: 0) {
+            content
+                .frame(width: contentSize.width, height: contentSize.height, alignment: .top)
+            Spacer(minLength: 0)
+        }
+        .padding(.top, QuickSearchPanelMetrics.shadowOutset)
+        .padding(.horizontal, QuickSearchPanelMetrics.shadowOutset)
+        .padding(.bottom, QuickSearchPanelMetrics.shadowOutset)
+        .frame(width: windowSize.width, height: windowSize.height, alignment: .top)
+        .background(Color.clear)
+    }
+}
+
 struct QuickSearchOverlayView: View {
     @Binding var query: String
     let results: [QuickSearchResult]
@@ -744,13 +1016,17 @@
 
     @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
             ? Color(red: 0.105, green: 0.110, blue: 0.125).opacity(0.97)
             : Color.white.opacity(0.97)
+    }
+
+    private var panelShape: RoundedRectangle {
+        RoundedRectangle(cornerRadius: 34, style: .continuous)
     }
 
     var body: some View {
@@ -806,19 +1082,24 @@
                     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)
         .background(
-            RoundedRectangle(cornerRadius: 34, style: .continuous)
-                .fill(panelBackgroundColor)
-                .shadow(color: .black.opacity(0.18), radius: 36, y: 18)
+            panelShape.fill(panelBackgroundColor)
         )
+        .clipShape(panelShape)
         .overlay(
-            RoundedRectangle(cornerRadius: 34, style: .continuous)
-                .stroke(Color.primary.opacity(0.10), lineWidth: 1)
+            panelShape.stroke(Color.primary.opacity(0.10), lineWidth: 1)
         )
+        .compositingGroup()
+        .shadow(color: .black.opacity(colorScheme == .dark ? 0.46 : 0.26), radius: 30, x: 0, y: 18)
+        .shadow(color: .black.opacity(colorScheme == .dark ? 0.24 : 0.12), radius: 8, x: 0, y: 3)
         .accessibilityElement(children: .contain)
         .accessibilityLabel(tr("quickSearch.title"))
     }
@@ -1023,6 +1304,18 @@
 }
 
 final class QuickSearchResultRowView: NSView {
+    private enum TagBadgeLayout {
+        static let columnWidth: CGFloat = 196
+        static let minWidth: CGFloat = 80
+        static let maxWidth: CGFloat = 196
+        static let height: CGFloat = 32
+        static let horizontalInset: CGFloat = 16
+        static let labelSafetyWidth: CGFloat = 8
+        static let trailingInset: CGFloat = 18
+        static let textGap: CGFloat = 18
+        static let minimumMainTextWidth: CGFloat = 260
+    }
+
     private let iconView = NSImageView()
     private let titleLabel = NSTextField(labelWithString: "")
     private let detailLabel = NSTextField(labelWithString: "")
@@ -1082,25 +1375,44 @@
             height: iconSize
         )
 
-        let tagMaxWidth: CGFloat = 128
-        let tagHeight: CGFloat = 32
-        let trailingInset: CGFloat = 18
+        let textX = iconX + iconSize + 16
+        let trailingInset = TagBadgeLayout.trailingInset
+        let rightLimit = bounds.width - trailingInset
         var tagFrame = NSRect.zero
-        if !tagBackgroundView.isHidden {
-            let labelWidth = min(tagMaxWidth - 24, max(22, tagLabel.intrinsicContentSize.width))
-            let tagWidth = min(tagMaxWidth, labelWidth + 24)
+        let hasTag = !tagLabel.stringValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+        let availableBadgeColumnWidth = rightLimit - textX - TagBadgeLayout.minimumMainTextWidth - TagBadgeLayout.textGap
+        let tagColumnWidth = min(TagBadgeLayout.columnWidth, max(0, availableBadgeColumnWidth))
+        let canShowTag = hasTag && tagColumnWidth >= TagBadgeLayout.minWidth
+        tagBackgroundView.isHidden = !canShowTag
+
+        if canShowTag {
+            let measuredLabelWidth = measuredTagLabelWidth()
+            let measuredBadgeWidth = measuredLabelWidth
+                + TagBadgeLayout.horizontalInset * 2
+                + TagBadgeLayout.labelSafetyWidth
+            let tagWidth = min(
+                min(TagBadgeLayout.maxWidth, tagColumnWidth),
+                max(TagBadgeLayout.minWidth, measuredBadgeWidth)
+            )
             tagFrame = NSRect(
-                x: bounds.width - trailingInset - tagWidth,
-                y: (bounds.height - tagHeight) / 2,
+                x: rightLimit - tagWidth,
+                y: (bounds.height - TagBadgeLayout.height) / 2,
                 width: tagWidth,
-                height: tagHeight
+                height: TagBadgeLayout.height
             )
             tagBackgroundView.frame = tagFrame
-            tagLabel.frame = NSRect(x: 12, y: 6, width: tagWidth - 24, height: 20)
+            tagLabel.frame = NSRect(
+                x: TagBadgeLayout.horizontalInset,
+                y: 6,
+                width: tagWidth - TagBadgeLayout.horizontalInset * 2,
+                height: 20
+            )
+        } else {
+            tagBackgroundView.frame = .zero
+            tagLabel.frame = .zero
         }
 
-        let textX = iconX + iconSize + 16
-        let textRight = tagBackgroundView.isHidden ? bounds.width - trailingInset : tagFrame.minX - 16
+        let textRight = tagBackgroundView.isHidden ? bounds.width - trailingInset : tagFrame.minX - TagBadgeLayout.textGap
         let textWidth = max(40, textRight - textX)
         if detailLabel.isHidden {
             titleLabel.frame = NSRect(x: textX, y: (bounds.height - 26) / 2, width: textWidth, height: 26)
@@ -1109,6 +1421,11 @@
             titleLabel.frame = NSRect(x: textX, y: 14, width: textWidth, height: 25)
             detailLabel.frame = NSRect(x: textX, y: 42, width: textWidth, height: 21)
         }
+    }
+
+    private func measuredTagLabelWidth() -> CGFloat {
+        let font = tagLabel.font ?? NSFont.systemFont(ofSize: 14, weight: .semibold)
+        return ceil((tagLabel.stringValue as NSString).size(withAttributes: [.font: font]).width)
     }
 
     override func updateTrackingAreas() {
@@ -1135,7 +1452,9 @@
 
     override func mouseDown(with event: NSEvent) {
         if let result {
-            onLaunch(result)
+            DispatchQueue.main.async { [onLaunch] in
+                onLaunch(result)
+            }
         } else {
             super.mouseDown(with: event)
         }
@@ -1176,6 +1495,9 @@
         tagLabel.lineBreakMode = .byTruncatingTail
         tagLabel.maximumNumberOfLines = 1
         tagLabel.backgroundColor = .clear
+        tagLabel.allowsDefaultTighteningForTruncation = true
+        tagLabel.cell?.usesSingleLineMode = true
+        tagLabel.cell?.lineBreakMode = .byTruncatingTail
     }
 
     private func updateColors() {
@@ -1274,11 +1596,17 @@
     }
 
     private func requestFocus(_ field: QuickSearchNativeTextField) {
-        DispatchQueue.main.async { [weak field] in
-            guard let field,
-                  field.window?.firstResponder !== field
-            else { return }
-            field.window?.makeFirstResponder(field)
+        for delay in [0.0, 0.03, 0.08, 0.16] {
+            DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak field] in
+                guard let field,
+                      let window = field.window,
+                      window.isVisible,
+                      window.firstResponder !== field
+                else { return }
+                window.makeKeyAndOrderFront(nil)
+                window.orderFrontRegardless()
+                window.makeFirstResponder(field)
+            }
         }
     }
 
@@ -1328,6 +1656,8 @@
 private final class QuickSearchNativeTextField: NSTextField {
     var onCommand: ((QuickSearchCommand) -> Void)?
 
+    override var acceptsFirstResponder: Bool { true }
+
     override func keyDown(with event: NSEvent) {
         switch Int(event.keyCode) {
         case kVK_UpArrow:

--
Gitblit v1.9.3