From 279b3a07eb06f86b8825aa0a53e71cd3137e43a7 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Fri, 29 May 2026 11:52:56 +0800
Subject: [PATCH] Reduce app grid scroll churn

---
 Apptag/QuickSearch.swift | 1048 ++++++++++++++++++++++++++++++++++++++++++++++-----------
 1 files changed, 841 insertions(+), 207 deletions(-)

diff --git a/Apptag/QuickSearch.swift b/Apptag/QuickSearch.swift
index e94a87d..aab4e8f 100644
--- a/Apptag/QuickSearch.swift
+++ b/Apptag/QuickSearch.swift
@@ -202,6 +202,64 @@
 
 // MARK: - Search Documents
 
+private enum QuickSearchFieldKind: Int {
+    case name = 0
+    case tag = 1
+    case note = 2
+    case bundleIdentifier = 3
+    case internalBundleName = 4
+
+    var weight: Double {
+        switch self {
+        case .name: return 100
+        case .tag: return 70
+        case .note: return 45
+        case .internalBundleName: return 30
+        case .bundleIdentifier: return 20
+        }
+    }
+}
+
+private enum QuickSearchMatchKind {
+    case exact
+    case prefix
+    case substring
+    case acronym
+    case fuzzy
+
+    var weight: Double {
+        switch self {
+        case .exact: return 100
+        case .prefix: return 80
+        case .substring: return 60
+        case .acronym: return 55
+        case .fuzzy: return 35
+        }
+    }
+}
+
+private struct QuickSearchIndexedField {
+    let kind: QuickSearchFieldKind
+    let text: String
+    let normalized: String
+    let acronym: String
+    let pinyinCandidates: [String]
+    let allowPinyinSubstring: Bool
+    let allowPinyinFuzzySubsequence: Bool
+}
+
+private struct QuickSearchMatchOptions {
+    let allowSubstring: Bool
+    let allowFuzzySubsequence: Bool
+}
+
+private struct QuickSearchTokenMatch {
+    let score: Double
+    let fieldRank: Int
+    let fieldKind: QuickSearchFieldKind
+    let originalText: String
+}
+
 struct QuickSearchDocument: Identifiable {
     var id: URL { app.id }
     let app: AppInfo
@@ -212,6 +270,7 @@
     let bundleIdentifier: String
     let lastOpenedAt: Date?
     let openCount: Int
+    fileprivate let searchableFields: [QuickSearchIndexedField]
 }
 
 struct QuickSearchResult: Identifiable {
@@ -227,73 +286,30 @@
 }
 
 enum QuickSearchEngine {
-    private enum FieldKind: Int {
-        case name = 0
-        case tag = 1
-        case note = 2
-        case bundleIdentifier = 3
-        case internalBundleName = 4
-
-        var weight: Double {
-            switch self {
-            case .name: return 100
-            case .tag: return 70
-            case .note: return 45
-            case .internalBundleName: return 30
-            case .bundleIdentifier: return 20
-            }
-        }
-    }
-
-    private enum MatchKind {
-        case exact
-        case prefix
-        case substring
-        case acronym
-        case fuzzy
-
-        var weight: Double {
-            switch self {
-            case .exact: return 100
-            case .prefix: return 80
-            case .substring: return 60
-            case .acronym: return 55
-            case .fuzzy: return 35
-            }
-        }
-    }
-
-    private struct Field {
-        let kind: FieldKind
-        let text: String
-        let normalized: String
-        let acronym: String
-        let pinyinCandidates: [String]
-    }
-
-    private struct MatchOptions {
-        let allowSubstring: Bool
-        let allowFuzzySubsequence: Bool
-    }
-
-    private struct TokenMatch {
-        let score: Double
-        let fieldRank: Int
-        let fieldKind: FieldKind
-        let originalText: String
-    }
-
     static func makeDocuments(apps: [AppInfo], store: TagDatabase.Store) -> [QuickSearchDocument] {
         apps.map { app in
-            QuickSearchDocument(
-                app: app,
-                localizedNames: localizedNames(for: app),
-                internalBundleNames: internalBundleNames(for: app),
+            let localizedNames = uniqueOrdered(app.localizedNames)
+            let internalBundleNames = internalBundleNames(for: app)
+            let note = store.appNotes[app.path.path] ?? app.note ?? ""
+            let bundleIdentifier = app.bundleIdentifier ?? ""
+            let searchableFields = makeSearchableFields(
+                appName: app.name,
+                localizedNames: localizedNames,
+                internalBundleNames: internalBundleNames,
                 tagNames: app.tags,
-                note: store.appNotes[app.path.path] ?? app.note ?? "",
-                bundleIdentifier: app.bundleIdentifier ?? "",
+                note: note,
+                bundleIdentifier: bundleIdentifier
+            )
+            return QuickSearchDocument(
+                app: app,
+                localizedNames: localizedNames,
+                internalBundleNames: internalBundleNames,
+                tagNames: app.tags,
+                note: note,
+                bundleIdentifier: bundleIdentifier,
                 lastOpenedAt: store.appLastOpenedAt[app.path.path],
-                openCount: store.appOpenCounts[app.path.path] ?? 0
+                openCount: store.appOpenCounts[app.path.path] ?? 0,
+                searchableFields: searchableFields
             )
         }
     }
@@ -320,7 +336,7 @@
     }
 
     private static func result(for document: QuickSearchDocument, tokens: [String]) -> QuickSearchResult? {
-        let fields = searchableFields(for: document)
+        let fields = document.searchableFields
         var textScore: Double = 0
         var bestFieldRank = Int.max
         var matchedTagName: String?
@@ -353,61 +369,78 @@
         )
     }
 
-    private static func searchableFields(for document: QuickSearchDocument) -> [Field] {
-        let names = uniqueOrdered([document.app.name] + document.localizedNames)
+    private static func makeSearchableFields(
+        appName: String,
+        localizedNames: [String],
+        internalBundleNames: [String],
+        tagNames: [String],
+        note: String,
+        bundleIdentifier: String
+    ) -> [QuickSearchIndexedField] {
+        let names = uniqueOrdered([appName] + localizedNames)
         let nameFields = names.map {
-            Field(
+            return QuickSearchIndexedField(
                 kind: .name,
                 text: $0,
                 normalized: normalizeField($0),
                 acronym: acronym(for: $0),
-                pinyinCandidates: pinyinCandidates(for: $0)
+                pinyinCandidates: pinyinCandidates(for: $0, includeLatin: true),
+                allowPinyinSubstring: true,
+                allowPinyinFuzzySubsequence: true
             )
         }
-        let tagFields = document.tagNames.map {
-            Field(
+        let tagFields = tagNames.map {
+            QuickSearchIndexedField(
                 kind: .tag,
                 text: $0,
                 normalized: normalizeField($0),
                 acronym: "",
-                pinyinCandidates: pinyinCandidates(for: $0)
+                pinyinCandidates: pinyinCandidates(for: $0),
+                allowPinyinSubstring: false,
+                allowPinyinFuzzySubsequence: false
             )
         }
-        let noteFields = document.note.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? [] : [
-            Field(
+        let noteFields = note.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? [] : [
+            QuickSearchIndexedField(
                 kind: .note,
-                text: document.note,
-                normalized: normalizeField(document.note),
+                text: note,
+                normalized: normalizeField(note),
                 acronym: "",
-                pinyinCandidates: pinyinCandidates(for: document.note)
+                pinyinCandidates: pinyinCandidates(for: note),
+                allowPinyinSubstring: true,
+                allowPinyinFuzzySubsequence: false
             )
         ]
-        let bundleFields = document.bundleIdentifier.isEmpty ? [] : [
-            Field(
+        let bundleFields = bundleIdentifier.isEmpty ? [] : [
+            QuickSearchIndexedField(
                 kind: .bundleIdentifier,
-                text: document.bundleIdentifier,
-                normalized: normalizeField(document.bundleIdentifier),
+                text: bundleIdentifier,
+                normalized: normalizeField(bundleIdentifier),
                 acronym: "",
-                pinyinCandidates: []
+                pinyinCandidates: [],
+                allowPinyinSubstring: false,
+                allowPinyinFuzzySubsequence: false
             )
         ]
-        let internalBundleNameFields = document.internalBundleNames.map {
-            Field(
+        let internalBundleNameFields = internalBundleNames.map {
+            QuickSearchIndexedField(
                 kind: .internalBundleName,
                 text: $0,
                 normalized: normalizeField($0),
                 acronym: "",
-                pinyinCandidates: []
+                pinyinCandidates: [],
+                allowPinyinSubstring: false,
+                allowPinyinFuzzySubsequence: false
             )
         }
         return nameFields + tagFields + noteFields + bundleFields + internalBundleNameFields
     }
 
-    private static func match(token: String, field: Field) -> TokenMatch? {
+    private static func match(token: String, field: QuickSearchIndexedField) -> QuickSearchTokenMatch? {
         guard let candidate = bestMatchCandidate(token: token, field: field) else { return nil }
         let positionBoost = candidate.0 == .exact ? 0 : max(0, 10 - min(candidate.1, 10))
         let score = field.kind.weight + candidate.0.weight + Double(positionBoost)
-        return TokenMatch(
+        return QuickSearchTokenMatch(
             score: score,
             fieldRank: field.kind.rawValue,
             fieldKind: field.kind,
@@ -415,8 +448,8 @@
         )
     }
 
-    private static func bestMatchCandidate(token: String, field: Field) -> (MatchKind, Int)? {
-        var candidates: [(MatchKind, Int)] = []
+    private static func bestMatchCandidate(token: String, field: QuickSearchIndexedField) -> (QuickSearchMatchKind, Int)? {
+        var candidates: [(QuickSearchMatchKind, Int)] = []
 
         if let textCandidate = matchCandidate(
             token: token,
@@ -432,7 +465,8 @@
             if let pinyinCandidate = matchPinyinCandidate(
                 token: token,
                 normalized: pinyin,
-                allowSubstring: field.kind == .note
+                allowSubstring: field.allowPinyinSubstring,
+                allowFuzzySubsequence: field.allowPinyinFuzzySubsequence
             ) {
                 candidates.append(pinyinCandidate)
             }
@@ -444,24 +478,24 @@
         }
     }
 
-    private static func matchOptions(for fieldKind: FieldKind) -> MatchOptions {
+    private static func matchOptions(for fieldKind: QuickSearchFieldKind) -> QuickSearchMatchOptions {
         switch fieldKind {
         case .name:
-            return MatchOptions(allowSubstring: true, allowFuzzySubsequence: true)
+            return QuickSearchMatchOptions(allowSubstring: true, allowFuzzySubsequence: true)
         case .tag:
-            return MatchOptions(allowSubstring: true, allowFuzzySubsequence: true)
+            return QuickSearchMatchOptions(allowSubstring: true, allowFuzzySubsequence: true)
         case .note:
-            return MatchOptions(allowSubstring: true, allowFuzzySubsequence: true)
+            return QuickSearchMatchOptions(allowSubstring: true, allowFuzzySubsequence: true)
         case .bundleIdentifier, .internalBundleName:
-            return MatchOptions(allowSubstring: false, allowFuzzySubsequence: false)
+            return QuickSearchMatchOptions(allowSubstring: false, allowFuzzySubsequence: false)
         }
     }
 
     private static func matchCandidate(
         token: String,
         normalized: String,
-        options: MatchOptions
-    ) -> (MatchKind, Int)? {
+        options: QuickSearchMatchOptions
+    ) -> (QuickSearchMatchKind, Int)? {
         guard !normalized.isEmpty else { return nil }
         if normalized == token {
             return (.exact, 0)
@@ -481,8 +515,9 @@
     private static func matchPinyinCandidate(
         token: String,
         normalized: String,
-        allowSubstring: Bool = false
-    ) -> (MatchKind, Int)? {
+        allowSubstring: Bool = false,
+        allowFuzzySubsequence: Bool = false
+    ) -> (QuickSearchMatchKind, Int)? {
         guard !normalized.isEmpty else { return nil }
         if normalized == token {
             return (.exact, 0)
@@ -492,6 +527,9 @@
         }
         if allowSubstring, let range = normalized.range(of: token) {
             return (.substring, normalized.distance(from: normalized.startIndex, to: range.lowerBound))
+        }
+        if allowFuzzySubsequence, token.count >= 3, isSubsequence(token, of: normalized) {
+            return (.fuzzy, 10)
         }
         return nil
     }
@@ -566,8 +604,8 @@
             .lowercased()
     }
 
-    private static func pinyinCandidates(for value: String) -> [String] {
-        guard value.range(of: #"\p{Han}"#, options: .regularExpression) != nil else { return [] }
+    private static func pinyinCandidates(for value: String, includeLatin: Bool = false) -> [String] {
+        guard includeLatin || containsNonLatinLetter(value) else { return [] }
         let mutable = NSMutableString(string: value)
         CFStringTransform(mutable, nil, kCFStringTransformToLatin, false)
         CFStringTransform(mutable, nil, kCFStringTransformStripCombiningMarks, false)
@@ -585,6 +623,31 @@
             .map(String.init)
             .joined()
         return uniqueOrdered([spaced, compact, initials].filter { !$0.isEmpty })
+    }
+
+    private static func containsNonLatinLetter(_ value: String) -> Bool {
+        value.unicodeScalars.contains { scalar in
+            CharacterSet.letters.contains(scalar) && !isLatinScriptLetter(scalar)
+        }
+    }
+
+    private static func isLatinScriptLetter(_ scalar: UnicodeScalar) -> Bool {
+        switch scalar.value {
+        case 0x0041...0x005A, // Basic Latin uppercase
+             0x0061...0x007A, // Basic Latin lowercase
+             0x00AA,
+             0x00BA,
+             0x00C0...0x024F, // Latin-1 Supplement, Extended-A/B
+             0x1E00...0x1EFF, // Latin Extended Additional
+             0x2C60...0x2C7F, // Latin Extended-C
+             0xA720...0xA7FF, // Latin Extended-D
+             0xAB30...0xAB6F, // Latin Extended-E
+             0xFF21...0xFF3A, // Fullwidth Latin uppercase
+             0xFF41...0xFF5A: // Fullwidth Latin lowercase
+            return true
+        default:
+            return false
+        }
     }
 
     private static func isSubsequence(_ token: String, of value: String) -> Bool {
@@ -666,11 +729,258 @@
     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]
     let selectedID: URL?
     let focusToken: Int
+    let selectionScrollToken: Int
     let isLoading: Bool
     let maxVisibleRows: Int
     let errorMessage: String?
@@ -678,8 +988,16 @@
     let onHover: (QuickSearchResult) -> Void
     let onLaunch: (QuickSearchResult) -> Void
 
-    private let panelWidth: CGFloat = 760
-    private let rowHeight: CGFloat = 74
+    @Environment(\.colorScheme) private var colorScheme
+
+    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)
+    }
 
     var body: some View {
         VStack(alignment: .leading, spacing: 0) {
@@ -704,17 +1022,11 @@
             Divider().opacity(0.35)
 
             if isLoading {
-                HStack(spacing: 10) {
-                    ProgressView()
-                        .controlSize(.small)
-                    Text(tr("quickSearch.loading"))
-                        .font(.system(size: 15, weight: .medium))
-                        .foregroundStyle(.secondary)
-                    Spacer(minLength: 0)
-                }
-                .padding(.horizontal, 28)
-                .padding(.vertical, 24)
-                .frame(minHeight: 86)
+                QuickSearchMessageRow(
+                    systemImage: "hourglass",
+                    message: tr("quickSearch.loading"),
+                    tint: .secondary
+                )
             } else if let errorMessage {
                 QuickSearchMessageRow(
                     systemImage: "exclamationmark.triangle.fill",
@@ -730,42 +1042,27 @@
                     tint: .secondary
                 )
             } else {
-                ScrollViewReader { scrollProxy in
-                    ScrollView(.vertical, showsIndicators: results.count > maxVisibleRows) {
-                        LazyVStack(spacing: 2) {
-                            ForEach(results) { result in
-                                QuickSearchResultRow(
-                                    result: result,
-                                    isSelected: result.id == selectedID
-                                )
-                                .frame(height: rowHeight)
-                                .id(result.id)
-                                .contentShape(Rectangle())
-                                .onHover { hovering in
-                                    if hovering { onHover(result) }
-                                }
-                                .onTapGesture {
-                                    onLaunch(result)
-                                }
-                            }
-                        }
-                        .padding(.horizontal, 10)
-                        .padding(.vertical, 10)
-                    }
-                    .frame(height: CGFloat(min(results.count, maxVisibleRows)) * (rowHeight + 2) + 20)
-                    .onChange(of: selectedID) { _, id in
-                        guard let id else { return }
-                        withAnimation(.easeOut(duration: 0.08)) {
-                            scrollProxy.scrollTo(id, anchor: .center)
-                        }
-                    }
-                }
+                QuickSearchResultListView(
+                    results: results,
+                    selectedID: selectedID,
+                    selectionScrollToken: selectionScrollToken,
+                    maxVisibleRows: maxVisibleRows,
+                    rowHeight: rowHeight,
+                    isDarkMode: colorScheme == .dark,
+                    onHover: onHover,
+                    onLaunch: onLaunch
+                )
+                .frame(
+                    height: CGFloat(min(results.count, maxVisibleRows))
+                        * (rowHeight + QuickSearchPanelMetrics.rowSpacing)
+                        + QuickSearchPanelMetrics.resultListVerticalInset * 2
+                )
             }
         }
         .frame(width: panelWidth)
         .background(
             RoundedRectangle(cornerRadius: 34, style: .continuous)
-                .fill(.regularMaterial)
+                .fill(panelBackgroundColor)
                 .shadow(color: .black.opacity(0.18), radius: 36, y: 18)
         )
         .overlay(
@@ -777,66 +1074,373 @@
     }
 }
 
-private struct QuickSearchResultRow: View {
-    let result: QuickSearchResult
-    let isSelected: Bool
-    @Environment(\.colorScheme) private var colorScheme
+private struct QuickSearchResultListView: NSViewRepresentable {
+    let results: [QuickSearchResult]
+    let selectedID: URL?
+    let selectionScrollToken: Int
+    let maxVisibleRows: Int
+    let rowHeight: CGFloat
+    let isDarkMode: Bool
+    let onHover: (QuickSearchResult) -> Void
+    let onLaunch: (QuickSearchResult) -> Void
 
-    var body: some View {
-        HStack(spacing: 16) {
-            Image(nsImage: result.app.icon)
-                .resizable()
-                .frame(width: 46, height: 46)
-                .cornerRadius(10)
-
-            VStack(alignment: .leading, spacing: 4) {
-                Text(result.app.name)
-                    .font(.system(size: 20, weight: .semibold))
-                    .foregroundStyle(.primary)
-                    .lineLimit(1)
-                    .truncationMode(.tail)
-
-                if let detailText {
-                    Text(detailText)
-                        .font(.system(size: 16, weight: .medium))
-                        .foregroundStyle(Color.primary.opacity(0.38))
-                        .lineLimit(1)
-                        .truncationMode(.tail)
-                }
-            }
-            .layoutPriority(1)
-
-            Spacer(minLength: 8)
-
-            if let tagName = rightTagName {
-                Text(tagName)
-                    .font(.system(size: 14, weight: .semibold))
-                    .foregroundStyle(Color.primary.opacity(0.46))
-                    .lineLimit(1)
-                    .truncationMode(.tail)
-                    .padding(.horizontal, 12)
-                    .frame(height: 32)
-                    .frame(maxWidth: 128)
-                    .background(
-                        Capsule(style: .continuous)
-                            .fill(Color.primary.opacity(colorScheme == .dark ? 0.12 : 0.07))
-                    )
-            }
-        }
-        .padding(.horizontal, 18)
-        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
-        .background(
-            RoundedRectangle(cornerRadius: 18, style: .continuous)
-                .fill(isSelected ? selectedFill : Color.clear)
+    func makeNSView(context: Context) -> QuickSearchResultListHostView {
+        let view = QuickSearchResultListHostView()
+        view.update(
+            results: results,
+            selectedID: selectedID,
+            selectionScrollToken: selectionScrollToken,
+            maxVisibleRows: maxVisibleRows,
+            rowHeight: rowHeight,
+            isDarkMode: isDarkMode,
+            onHover: onHover,
+            onLaunch: onLaunch
         )
-        .accessibilityLabel(accessibilityText)
+        return view
     }
 
-    private var selectedFill: Color {
-        colorScheme == .dark ? Color.white.opacity(0.14) : Color.black.opacity(0.075)
+    func updateNSView(_ view: QuickSearchResultListHostView, context: Context) {
+        view.update(
+            results: results,
+            selectedID: selectedID,
+            selectionScrollToken: selectionScrollToken,
+            maxVisibleRows: maxVisibleRows,
+            rowHeight: rowHeight,
+            isDarkMode: isDarkMode,
+            onHover: onHover,
+            onLaunch: onLaunch
+        )
+    }
+}
+
+final class QuickSearchResultListHostView: NSView {
+    private let scrollView = NSScrollView()
+    private let documentView = QuickSearchResultListDocumentView()
+    private var selectedID: URL?
+    private var selectionScrollToken = 0
+    private var didInstall = false
+
+    override var isFlipped: Bool { true }
+
+    override init(frame frameRect: NSRect) {
+        super.init(frame: frameRect)
+        setup()
     }
 
-    private var detailText: String? {
+    required init?(coder: NSCoder) {
+        super.init(coder: coder)
+        setup()
+    }
+
+    func update(
+        results: [QuickSearchResult],
+        selectedID: URL?,
+        selectionScrollToken: Int,
+        maxVisibleRows: Int,
+        rowHeight: CGFloat,
+        isDarkMode: Bool,
+        onHover: @escaping (QuickSearchResult) -> Void,
+        onLaunch: @escaping (QuickSearchResult) -> Void
+    ) {
+        let shouldScrollSelection = selectionScrollToken != self.selectionScrollToken
+        self.selectedID = selectedID
+        self.selectionScrollToken = selectionScrollToken
+        scrollView.hasVerticalScroller = results.count > maxVisibleRows
+        documentView.update(
+            results: results,
+            selectedID: selectedID,
+            rowHeight: rowHeight,
+            isDarkMode: isDarkMode,
+            onHover: onHover,
+            onLaunch: onLaunch
+        )
+        needsLayout = true
+        layoutSubtreeIfNeeded()
+        if shouldScrollSelection, let selectedID {
+            scrollResultToCenter(selectedID)
+        }
+    }
+
+    override func layout() {
+        super.layout()
+        scrollView.frame = bounds
+        let visibleSize = scrollView.contentView.bounds.size
+        documentView.frame = NSRect(
+            origin: .zero,
+            size: NSSize(
+                width: max(visibleSize.width, bounds.width),
+                height: documentView.preferredHeight
+            )
+        )
+        documentView.needsLayout = true
+    }
+
+    private func setup() {
+        guard !didInstall else { return }
+        didInstall = true
+        wantsLayer = true
+        layer?.backgroundColor = NSColor.clear.cgColor
+
+        scrollView.drawsBackground = false
+        scrollView.borderType = .noBorder
+        scrollView.autohidesScrollers = true
+        scrollView.scrollerStyle = .overlay
+        scrollView.hasHorizontalScroller = false
+        scrollView.hasVerticalScroller = false
+        scrollView.documentView = documentView
+        addSubview(scrollView)
+    }
+
+    private func scrollResultToCenter(_ id: URL) {
+        guard let frame = documentView.frameForResult(id) else { return }
+        let visibleBounds = scrollView.contentView.bounds
+        let maxY = max(0, documentView.bounds.height - visibleBounds.height)
+        let targetY = min(max(0, frame.midY - visibleBounds.height / 2), maxY)
+        scrollView.contentView.scroll(to: NSPoint(x: 0, y: targetY))
+        scrollView.reflectScrolledClipView(scrollView.contentView)
+    }
+}
+
+final class QuickSearchResultListDocumentView: NSView {
+    private var rowViews: [QuickSearchResultRowView] = []
+    private var rowIDs: [URL] = []
+    private var rowHeight: CGFloat = 74
+    private let rowSpacing: CGFloat = 2
+    private let horizontalInset: CGFloat = 10
+    private let verticalInset: CGFloat = 10
+
+    override var isFlipped: Bool { true }
+
+    var preferredHeight: CGFloat {
+        verticalInset * 2
+            + CGFloat(rowViews.count) * rowHeight
+            + CGFloat(max(0, rowViews.count - 1)) * rowSpacing
+    }
+
+    func update(
+        results: [QuickSearchResult],
+        selectedID: URL?,
+        rowHeight: CGFloat,
+        isDarkMode: Bool,
+        onHover: @escaping (QuickSearchResult) -> Void,
+        onLaunch: @escaping (QuickSearchResult) -> Void
+    ) {
+        self.rowHeight = rowHeight
+        let nextIDs = results.map(\.id)
+        if nextIDs != rowIDs {
+            rebuildRows(for: results)
+            rowIDs = nextIDs
+        }
+
+        for (index, row) in rowViews.enumerated() {
+            guard index < results.count else { continue }
+            row.configure(
+                result: results[index],
+                isSelected: results[index].id == selectedID,
+                isDarkMode: isDarkMode,
+                onHover: onHover,
+                onLaunch: onLaunch
+            )
+        }
+        needsLayout = true
+    }
+
+    func frameForResult(_ id: URL) -> NSRect? {
+        guard let index = rowIDs.firstIndex(of: id),
+              index < rowViews.count
+        else { return nil }
+        return rowViews[index].frame
+    }
+
+    override func layout() {
+        super.layout()
+        let width = max(0, bounds.width - horizontalInset * 2)
+        var y = verticalInset
+        for row in rowViews {
+            row.frame = NSRect(x: horizontalInset, y: y, width: width, height: rowHeight)
+            y += rowHeight + rowSpacing
+        }
+    }
+
+    private func rebuildRows(for results: [QuickSearchResult]) {
+        rowViews.forEach { $0.removeFromSuperview() }
+        rowViews = results.map { _ in
+            let row = QuickSearchResultRowView()
+            addSubview(row)
+            return row
+        }
+    }
+}
+
+final class QuickSearchResultRowView: NSView {
+    private let iconView = NSImageView()
+    private let titleLabel = NSTextField(labelWithString: "")
+    private let detailLabel = NSTextField(labelWithString: "")
+    private let tagBackgroundView = NSView()
+    private let tagLabel = NSTextField(labelWithString: "")
+    private var trackingAreaRef: NSTrackingArea?
+    private var result: QuickSearchResult?
+    private var isSelected = false
+    private var isDarkMode = false
+    private var onHover: (QuickSearchResult) -> Void = { _ in }
+    private var onLaunch: (QuickSearchResult) -> Void = { _ in }
+
+    override var isFlipped: Bool { true }
+
+    override init(frame frameRect: NSRect) {
+        super.init(frame: frameRect)
+        setup()
+    }
+
+    required init?(coder: NSCoder) {
+        super.init(coder: coder)
+        setup()
+    }
+
+    func configure(
+        result: QuickSearchResult,
+        isSelected: Bool,
+        isDarkMode: Bool,
+        onHover: @escaping (QuickSearchResult) -> Void,
+        onLaunch: @escaping (QuickSearchResult) -> Void
+    ) {
+        self.result = result
+        self.isSelected = isSelected
+        self.isDarkMode = isDarkMode
+        self.onHover = onHover
+        self.onLaunch = onLaunch
+
+        iconView.image = result.app.icon
+        titleLabel.stringValue = result.app.displayName
+        detailLabel.stringValue = detailText(for: result) ?? ""
+        detailLabel.isHidden = detailLabel.stringValue.isEmpty
+        tagLabel.stringValue = rightTagName(for: result) ?? ""
+        tagBackgroundView.isHidden = tagLabel.stringValue.isEmpty
+        setAccessibilityLabel(accessibilityText(for: result))
+        updateColors()
+        needsLayout = true
+    }
+
+    override func layout() {
+        super.layout()
+        let iconSize: CGFloat = 46
+        let iconX: CGFloat = 18
+        iconView.frame = NSRect(
+            x: iconX,
+            y: (bounds.height - iconSize) / 2,
+            width: iconSize,
+            height: iconSize
+        )
+
+        let tagMaxWidth: CGFloat = 128
+        let tagHeight: CGFloat = 32
+        let trailingInset: CGFloat = 18
+        var tagFrame = NSRect.zero
+        if !tagBackgroundView.isHidden {
+            let labelWidth = min(tagMaxWidth - 24, max(22, tagLabel.intrinsicContentSize.width))
+            let tagWidth = min(tagMaxWidth, labelWidth + 24)
+            tagFrame = NSRect(
+                x: bounds.width - trailingInset - tagWidth,
+                y: (bounds.height - tagHeight) / 2,
+                width: tagWidth,
+                height: tagHeight
+            )
+            tagBackgroundView.frame = tagFrame
+            tagLabel.frame = NSRect(x: 12, y: 6, width: tagWidth - 24, height: 20)
+        }
+
+        let textX = iconX + iconSize + 16
+        let textRight = tagBackgroundView.isHidden ? bounds.width - trailingInset : tagFrame.minX - 16
+        let textWidth = max(40, textRight - textX)
+        if detailLabel.isHidden {
+            titleLabel.frame = NSRect(x: textX, y: (bounds.height - 26) / 2, width: textWidth, height: 26)
+            detailLabel.frame = .zero
+        } else {
+            titleLabel.frame = NSRect(x: textX, y: 14, width: textWidth, height: 25)
+            detailLabel.frame = NSRect(x: textX, y: 42, width: textWidth, height: 21)
+        }
+    }
+
+    override func updateTrackingAreas() {
+        super.updateTrackingAreas()
+        if let trackingAreaRef {
+            removeTrackingArea(trackingAreaRef)
+        }
+        let area = NSTrackingArea(
+            rect: bounds,
+            options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect],
+            owner: self,
+            userInfo: nil
+        )
+        addTrackingArea(area)
+        trackingAreaRef = area
+    }
+
+    override func mouseEntered(with event: NSEvent) {
+        super.mouseEntered(with: event)
+        if let result {
+            onHover(result)
+        }
+    }
+
+    override func mouseDown(with event: NSEvent) {
+        if let result {
+            onLaunch(result)
+        } else {
+            super.mouseDown(with: event)
+        }
+    }
+
+    private func setup() {
+        wantsLayer = true
+        layer?.cornerRadius = 18
+        layer?.cornerCurve = .continuous
+
+        iconView.imageScaling = .scaleProportionallyUpOrDown
+        iconView.wantsLayer = true
+        iconView.layer?.cornerRadius = 10
+        iconView.layer?.cornerCurve = .continuous
+        iconView.layer?.masksToBounds = true
+        addSubview(iconView)
+
+        titleLabel.font = NSFont.systemFont(ofSize: 20, weight: .semibold)
+        titleLabel.lineBreakMode = .byTruncatingTail
+        titleLabel.maximumNumberOfLines = 1
+        titleLabel.backgroundColor = .clear
+        addSubview(titleLabel)
+
+        detailLabel.font = NSFont.systemFont(ofSize: 16, weight: .medium)
+        detailLabel.lineBreakMode = .byTruncatingTail
+        detailLabel.maximumNumberOfLines = 1
+        detailLabel.backgroundColor = .clear
+        addSubview(detailLabel)
+
+        tagBackgroundView.wantsLayer = true
+        tagBackgroundView.layer?.cornerRadius = 16
+        tagBackgroundView.layer?.cornerCurve = .continuous
+        tagBackgroundView.addSubview(tagLabel)
+        addSubview(tagBackgroundView)
+
+        tagLabel.font = NSFont.systemFont(ofSize: 14, weight: .semibold)
+        tagLabel.alignment = .center
+        tagLabel.lineBreakMode = .byTruncatingTail
+        tagLabel.maximumNumberOfLines = 1
+        tagLabel.backgroundColor = .clear
+    }
+
+    private func updateColors() {
+        layer?.backgroundColor = isSelected
+            ? NSColor.labelColor.withAlphaComponent(isDarkMode ? 0.14 : 0.075).cgColor
+            : NSColor.clear.cgColor
+        titleLabel.textColor = .labelColor
+        detailLabel.textColor = NSColor.labelColor.withAlphaComponent(0.38)
+        tagLabel.textColor = NSColor.labelColor.withAlphaComponent(0.46)
+        tagBackgroundView.layer?.backgroundColor = NSColor.labelColor
+            .withAlphaComponent(isDarkMode ? 0.12 : 0.07)
+            .cgColor
+    }
+
+    private func detailText(for result: QuickSearchResult) -> String? {
         if let note = result.noteSnippet, !note.isEmpty {
             return note
         }
@@ -847,14 +1451,14 @@
         return nil
     }
 
-    private var rightTagName: String? {
+    private func rightTagName(for result: QuickSearchResult) -> String? {
         let tag = result.matchedTagName ?? result.document.tagNames.first
         guard let tag, !tag.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil }
         return tag
     }
 
-    private var accessibilityText: String {
-        [result.app.name, detailText].compactMap { $0 }.joined(separator: ", ")
+    private func accessibilityText(for result: QuickSearchResult) -> String {
+        [result.app.displayName, detailText(for: result)].compactMap { $0 }.joined(separator: ", ")
     }
 }
 
@@ -899,11 +1503,10 @@
         field.placeholderString = placeholder
         field.delegate = context.coordinator
         field.onCommand = onCommand
+        context.coordinator.onCommand = onCommand
         field.setAccessibilityLabel(tr("quickSearch.inputAccessibility"))
         context.coordinator.field = field
-        DispatchQueue.main.async {
-            field.window?.makeFirstResponder(field)
-        }
+        requestFocus(field)
         return field
     }
 
@@ -913,11 +1516,19 @@
         }
         field.placeholderString = placeholder
         field.onCommand = onCommand
+        context.coordinator.onCommand = onCommand
         if context.coordinator.lastFocusToken != focusToken {
             context.coordinator.lastFocusToken = focusToken
-            DispatchQueue.main.async {
-                field.window?.makeFirstResponder(field)
-            }
+            requestFocus(field)
+        }
+    }
+
+    private func requestFocus(_ field: QuickSearchNativeTextField) {
+        DispatchQueue.main.async { [weak field] in
+            guard let field,
+                  field.window?.firstResponder !== field
+            else { return }
+            field.window?.makeFirstResponder(field)
         }
     }
 
@@ -928,6 +1539,7 @@
     final class Coordinator: NSObject, NSTextFieldDelegate {
         var text: Binding<String>
         var lastFocusToken = 0
+        var onCommand: ((QuickSearchCommand) -> Void)?
         weak var field: NSTextField?
 
         init(text: Binding<String>) {
@@ -938,6 +1550,28 @@
             guard let field = obj.object as? NSTextField else { return }
             text.wrappedValue = field.stringValue
         }
+
+        func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
+            switch commandSelector {
+            case #selector(NSResponder.moveUp(_:)):
+                onCommand?(.moveUp)
+                return true
+            case #selector(NSResponder.moveDown(_:)):
+                onCommand?(.moveDown)
+                return true
+            case #selector(NSResponder.insertNewline(_:)):
+                onCommand?(.submit)
+                return true
+            case #selector(NSResponder.insertNewlineIgnoringFieldEditor(_:)):
+                onCommand?(.submit)
+                return true
+            case #selector(NSResponder.cancelOperation(_:)):
+                onCommand?(.dismiss)
+                return true
+            default:
+                return false
+            }
+        }
     }
 }
 

--
Gitblit v1.9.3