Ariver
2026-05-28 2d6bbe1773aaf2f74070a25e7e3032b811a3ab2b
Apptag/QuickSearch.swift
@@ -6,7 +6,7 @@
    static let tagLauncherQuickSearchRequested = Notification.Name("TagLauncherQuickSearchRequested")
    static let tagLauncherQuickSearchDismissRequested = Notification.Name("TagLauncherQuickSearchDismissRequested")
    static let tagLauncherQuickSearchVisibilityChanged = Notification.Name("TagLauncherQuickSearchVisibilityChanged")
    static let tagLauncherHotkeysChanged = Notification.Name("TagLauncherHotkeysChanged")
    static let tagLauncherHotkeyRegistrationChanged = Notification.Name("TagLauncherHotkeyRegistrationChanged")
}
enum QuickSearchOpenSource {
@@ -20,10 +20,6 @@
struct LauncherHotkey: Equatable {
    let keyCode: UInt32
    let modifiers: UInt32
    var serialized: String {
        "\(keyCode):\(modifiers)"
    }
    var displayString: String {
        let ordered: [(UInt32, String)] = [
@@ -40,37 +36,12 @@
        return modifierGlyphs + LauncherHotkey.keyDisplayName(for: keyCode)
    }
    static var defaultMain: LauncherHotkey {
    static var main: LauncherHotkey {
        LauncherHotkey(keyCode: UInt32(kVK_Space), modifiers: UInt32(shiftKey | optionKey))
    }
    static var defaultQuickSearch: LauncherHotkey {
    static var quickSearch: LauncherHotkey {
        LauncherHotkey(keyCode: UInt32(kVK_Space), modifiers: UInt32(kEventKeyModifierFnMask))
    }
    static func deserialize(_ value: String?) -> LauncherHotkey? {
        guard let value, !value.isEmpty else { return nil }
        let parts = value.split(separator: ":")
        guard parts.count == 2,
              let keyCode = UInt32(parts[0]),
              let modifiers = UInt32(parts[1])
        else { return nil }
        return LauncherHotkey(keyCode: keyCode, modifiers: modifiers)
    }
    static func from(event: NSEvent) -> LauncherHotkey? {
        let ignoredKeyCodes: Set<UInt16> = [54, 55, 56, 57, 58, 59, 60, 61, 62, 63]
        guard !ignoredKeyCodes.contains(event.keyCode) else { return nil }
        var carbonModifiers: UInt32 = 0
        if event.modifierFlags.contains(.shift) { carbonModifiers |= UInt32(shiftKey) }
        if event.modifierFlags.contains(.option) { carbonModifiers |= UInt32(optionKey) }
        if event.modifierFlags.contains(.control) { carbonModifiers |= UInt32(controlKey) }
        if event.modifierFlags.contains(.command) { carbonModifiers |= UInt32(cmdKey) }
        if event.modifierFlags.contains(.function) { carbonModifiers |= UInt32(kEventKeyModifierFnMask) }
        guard carbonModifiers != 0 else { return nil }
        return LauncherHotkey(keyCode: UInt32(event.keyCode), modifiers: carbonModifiers)
    }
    static func keyDisplayName(for keyCode: UInt32) -> String {
@@ -121,31 +92,31 @@
    case main
    case quickSearch
    var storageKey: String {
    var stateKey: String {
        switch self {
        case .main: return "mainHotkey"
        case .quickSearch: return "quickSearchHotkey"
        case .main: return LauncherHotkeyRegistrationStore.mainStateKey
        case .quickSearch: return LauncherHotkeyRegistrationStore.quickSearchStateKey
        }
    }
    var statusKey: String {
    var failureCodeKey: String {
        switch self {
        case .main: return "mainHotkeyStatus"
        case .quickSearch: return "quickSearchHotkeyStatus"
        case .main: return LauncherHotkeyRegistrationStore.mainFailureCodeKey
        case .quickSearch: return LauncherHotkeyRegistrationStore.quickSearchFailureCodeKey
        }
    }
    var conflictMessageKey: String {
    var attentionKey: String {
        switch self {
        case .main: return "mainHotkeyConflictMessage"
        case .quickSearch: return "quickSearchHotkeyConflictMessage"
        case .main: return LauncherHotkeyRegistrationStore.mainNeedsAttentionKey
        case .quickSearch: return LauncherHotkeyRegistrationStore.quickSearchNeedsAttentionKey
        }
    }
    var pendingStorageKey: String {
    var hotkey: LauncherHotkey {
        switch self {
        case .main: return "mainHotkeyPending"
        case .quickSearch: return "quickSearchHotkeyPending"
        case .main: return .main
        case .quickSearch: return .quickSearch
        }
    }
@@ -157,77 +128,137 @@
    }
}
enum LauncherHotkeyStatus: String {
    case active = "Active"
    case conflict = "Conflict"
    case disabled = "Disabled"
enum LauncherHotkeyRegistrationState: String {
    case active
    case failed
}
enum LauncherHotkeyStore {
    static func hotkey(for kind: LauncherHotkeyKind) -> LauncherHotkey? {
        if kind == .main {
            return .defaultMain
        }
        if kind == .quickSearch && !AppDefaults.hasStoredValue(for: kind.storageKey) {
            return .defaultQuickSearch
        }
        return LauncherHotkey.deserialize(UserDefaults.standard.string(forKey: kind.storageKey))
enum LauncherHotkeyRegistrationStore {
    static let mainStateKey = "mainHotkeyRegistrationState"
    static let quickSearchStateKey = "quickSearchHotkeyRegistrationState"
    static let mainFailureCodeKey = "mainHotkeyRegistrationFailureCode"
    static let quickSearchFailureCodeKey = "quickSearchHotkeyRegistrationFailureCode"
    static let mainNeedsAttentionKey = "mainHotkeyRegistrationNeedsAttention"
    static let quickSearchNeedsAttentionKey = "quickSearchHotkeyRegistrationNeedsAttention"
    static func state(for kind: LauncherHotkeyKind) -> LauncherHotkeyRegistrationState {
        let rawValue = UserDefaults.standard.string(forKey: kind.stateKey)
        return LauncherHotkeyRegistrationState(rawValue: rawValue ?? "") ?? .active
    }
    static func save(_ hotkey: LauncherHotkey?, for kind: LauncherHotkeyKind) {
        UserDefaults.standard.set(hotkey?.serialized ?? "", forKey: kind.storageKey)
        UserDefaults.standard.removeObject(forKey: kind.pendingStorageKey)
    static func failureCode(for kind: LauncherHotkeyKind) -> Int? {
        let defaults = UserDefaults.standard
        guard defaults.object(forKey: kind.failureCodeKey) != nil else { return nil }
        return defaults.integer(forKey: kind.failureCodeKey)
    }
    static func savePending(_ hotkey: LauncherHotkey, for kind: LauncherHotkeyKind) {
        UserDefaults.standard.set(hotkey.serialized, forKey: kind.pendingStorageKey)
    static func setActive(for kind: LauncherHotkeyKind) {
        setState(.active, failureCode: nil, for: kind)
    }
    static func pendingHotkey(for kind: LauncherHotkeyKind) -> LauncherHotkey? {
        LauncherHotkey.deserialize(UserDefaults.standard.string(forKey: kind.pendingStorageKey))
    static func setFailed(_ failureCode: OSStatus, for kind: LauncherHotkeyKind) {
        setState(.failed, failureCode: Int(failureCode), for: kind)
    }
    static func status(for kind: LauncherHotkeyKind) -> LauncherHotkeyStatus {
        let raw = UserDefaults.standard.string(forKey: kind.statusKey)
        return LauncherHotkeyStatus(rawValue: raw ?? "") ?? (hotkey(for: kind) == nil ? .disabled : .active)
    static func consumeNeedsAttention(for kind: LauncherHotkeyKind) -> Bool {
        let defaults = UserDefaults.standard
        let needsAttention = defaults.bool(forKey: kind.attentionKey)
        defaults.set(false, forKey: kind.attentionKey)
        return needsAttention
    }
    static func setStatus(_ status: LauncherHotkeyStatus, message: String?, for kind: LauncherHotkeyKind) {
        UserDefaults.standard.set(status.rawValue, forKey: kind.statusKey)
        if let message, !message.isEmpty {
            UserDefaults.standard.set(message, forKey: kind.conflictMessageKey)
    private static func setState(
        _ state: LauncherHotkeyRegistrationState,
        failureCode: Int?,
        for kind: LauncherHotkeyKind
    ) {
        let defaults = UserDefaults.standard
        let previousState = defaults.string(forKey: kind.stateKey)
        let previousFailureCode = defaults.object(forKey: kind.failureCodeKey) == nil
            ? nil
            : defaults.integer(forKey: kind.failureCodeKey)
        let stateChanged = previousState != state.rawValue || previousFailureCode != failureCode
        defaults.set(state.rawValue, forKey: kind.stateKey)
        if let failureCode {
            defaults.set(failureCode, forKey: kind.failureCodeKey)
        } else {
            UserDefaults.standard.removeObject(forKey: kind.conflictMessageKey)
            defaults.removeObject(forKey: kind.failureCodeKey)
        }
        NotificationCenter.default.post(name: .tagLauncherHotkeysChanged, object: nil)
    }
    static func conflictMessage(for kind: LauncherHotkeyKind) -> String {
        UserDefaults.standard.string(forKey: kind.conflictMessageKey) ?? ""
    }
    static func knownConflictMessage(for hotkey: LauncherHotkey, status: OSStatus) -> String? {
        guard status != noErr else { return nil }
        switch (hotkey.keyCode, hotkey.modifiers) {
        case (UInt32(kVK_Space), UInt32(cmdKey)):
            return tr("quickSearch.hotkeyConflict.spotlight")
        case (UInt32(kVK_Space), UInt32(optionKey | cmdKey)):
            return tr("quickSearch.hotkeyConflict.finderSearch")
        case (UInt32(kVK_Space), UInt32(controlKey)):
            return tr("quickSearch.hotkeyConflict.previousInput")
        case (UInt32(kVK_Space), UInt32(controlKey | optionKey)):
            return tr("quickSearch.hotkeyConflict.nextInput")
        case (UInt32(kVK_Space), UInt32(controlKey | cmdKey)):
            return tr("quickSearch.hotkeyConflict.emoji")
        case (UInt32(kVK_Space), UInt32(kEventKeyModifierFnMask)):
            return tr("quickSearch.hotkeyConflict.fnSpace")
        default:
            return nil
        if state == .active {
            defaults.set(false, forKey: kind.attentionKey)
        } else if stateChanged {
            defaults.set(true, forKey: kind.attentionKey)
        }
        NotificationCenter.default.post(
            name: .tagLauncherHotkeyRegistrationChanged,
            object: nil,
            userInfo: ["kind": kind.rawValue]
        )
    }
}
// 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 }
@@ -239,6 +270,7 @@
    let bundleIdentifier: String
    let lastOpenedAt: Date?
    let openCount: Int
    fileprivate let searchableFields: [QuickSearchIndexedField]
}
struct QuickSearchResult: Identifiable {
@@ -254,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
            )
        }
    }
@@ -347,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?
@@ -380,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,
@@ -442,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,
@@ -456,7 +462,12 @@
            candidates.append((.acronym, 0))
        }
        for pinyin in field.pinyinCandidates {
            if let pinyinCandidate = matchPinyinCandidate(token: token, normalized: pinyin) {
            if let pinyinCandidate = matchPinyinCandidate(
                token: token,
                normalized: pinyin,
                allowSubstring: field.allowPinyinSubstring,
                allowFuzzySubsequence: field.allowPinyinFuzzySubsequence
            ) {
                candidates.append(pinyinCandidate)
            }
        }
@@ -467,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: false)
            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)
@@ -501,13 +512,24 @@
        return nil
    }
    private static func matchPinyinCandidate(token: String, normalized: String) -> (MatchKind, Int)? {
    private static func matchPinyinCandidate(
        token: String,
        normalized: String,
        allowSubstring: Bool = false,
        allowFuzzySubsequence: Bool = false
    ) -> (QuickSearchMatchKind, Int)? {
        guard !normalized.isEmpty else { return nil }
        if normalized == token {
            return (.exact, 0)
        }
        if normalized.hasPrefix(token) {
            return (.prefix, 0)
        }
        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
    }
@@ -582,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)
@@ -601,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 {
@@ -687,6 +734,7 @@
    let results: [QuickSearchResult]
    let selectedID: URL?
    let focusToken: Int
    let selectionScrollToken: Int
    let isLoading: Bool
    let maxVisibleRows: Int
    let errorMessage: String?
@@ -694,8 +742,16 @@
    let onHover: (QuickSearchResult) -> Void
    let onLaunch: (QuickSearchResult) -> Void
    @Environment(\.colorScheme) private var colorScheme
    private let panelWidth: CGFloat = 760
    private let rowHeight: CGFloat = 74
    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) {
@@ -720,17 +776,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",
@@ -769,8 +819,8 @@
                        .padding(.vertical, 10)
                    }
                    .frame(height: CGFloat(min(results.count, maxVisibleRows)) * (rowHeight + 2) + 20)
                    .onChange(of: selectedID) { _, id in
                        guard let id else { return }
                    .onChange(of: selectionScrollToken) { _, _ in
                        guard let id = selectedID else { return }
                        withAnimation(.easeOut(duration: 0.08)) {
                            scrollProxy.scrollTo(id, anchor: .center)
                        }
@@ -781,7 +831,7 @@
        .frame(width: panelWidth)
        .background(
            RoundedRectangle(cornerRadius: 34, style: .continuous)
                .fill(.regularMaterial)
                .fill(panelBackgroundColor)
                .shadow(color: .black.opacity(0.18), radius: 36, y: 18)
        )
        .overlay(
@@ -806,7 +856,7 @@
                .cornerRadius(10)
            VStack(alignment: .leading, spacing: 4) {
                Text(result.app.name)
                Text(result.app.displayName)
                    .font(.system(size: 20, weight: .semibold))
                    .foregroundStyle(.primary)
                    .lineLimit(1)
@@ -870,7 +920,7 @@
    }
    private var accessibilityText: String {
        [result.app.name, detailText].compactMap { $0 }.joined(separator: ", ")
        [result.app.displayName, detailText].compactMap { $0 }.joined(separator: ", ")
    }
}
@@ -915,11 +965,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
    }
@@ -929,11 +978,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)
        }
    }
@@ -944,6 +1001,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>) {
@@ -953,6 +1011,28 @@
        func controlTextDidChange(_ obj: Notification) {
            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
            }
        }
    }
}
@@ -972,109 +1052,6 @@
            onCommand?(.dismiss)
        default:
            super.keyDown(with: event)
        }
    }
}
struct QuickSearchBackdropClickView: NSViewRepresentable {
    let onClick: () -> Void
    func makeNSView(context: Context) -> QuickSearchBackdropNSView {
        let view = QuickSearchBackdropNSView()
        view.onClick = onClick
        return view
    }
    func updateNSView(_ view: QuickSearchBackdropNSView, context: Context) {
        view.onClick = onClick
    }
}
final class QuickSearchBackdropNSView: NSView {
    var onClick: (() -> Void)?
    override func hitTest(_ point: NSPoint) -> NSView? {
        bounds.contains(point) ? self : nil
    }
    override func mouseDown(with event: NSEvent) {
        onClick?()
    }
}
// MARK: - Hotkey Capture UI
struct HotkeyCaptureView: NSViewRepresentable {
    let isActive: Bool
    let onCapture: (LauncherHotkey) -> Void
    let onCancel: () -> Void
    func makeNSView(context: Context) -> HotkeyCaptureNSView {
        let view = HotkeyCaptureNSView()
        view.onCapture = onCapture
        view.onCancel = onCancel
        return view
    }
    func updateNSView(_ view: HotkeyCaptureNSView, context: Context) {
        view.onCapture = onCapture
        view.onCancel = onCancel
        view.setActive(isActive)
    }
}
final class HotkeyCaptureNSView: NSView {
    var onCapture: ((LauncherHotkey) -> Void)?
    var onCancel: (() -> Void)?
    private var keyMonitor: Any?
    override var acceptsFirstResponder: Bool { true }
    deinit {
        removeKeyMonitor()
    }
    func setActive(_ active: Bool) {
        if active {
            installKeyMonitorIfNeeded()
            DispatchQueue.main.async { [weak self] in
                guard let self else { return }
                self.window?.makeFirstResponder(self)
            }
        } else {
            removeKeyMonitor()
        }
    }
    override func keyDown(with event: NSEvent) {
        handle(event)
    }
    private func installKeyMonitorIfNeeded() {
        guard keyMonitor == nil else { return }
        keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
            guard let self else { return event }
            self.handle(event)
            return nil
        }
    }
    private func removeKeyMonitor() {
        if let keyMonitor {
            NSEvent.removeMonitor(keyMonitor)
            self.keyMonitor = nil
        }
    }
    private func handle(_ event: NSEvent) {
        if event.keyCode == UInt16(kVK_Escape) {
            onCancel?()
            return
        }
        if let hotkey = LauncherHotkey.from(event: event) {
            onCapture?(hotkey)
        } else {
            NSSound.beep()
        }
    }
}