Ariver
2026-05-28 091641ffff6c0f8252fbe44ea276a86c604c7663
Apptag/QuickSearch.swift
@@ -244,6 +244,8 @@
    let normalized: String
    let acronym: String
    let pinyinCandidates: [String]
    let allowPinyinSubstring: Bool
    let allowPinyinFuzzySubsequence: Bool
}
private struct QuickSearchMatchOptions {
@@ -286,7 +288,7 @@
enum QuickSearchEngine {
    static func makeDocuments(apps: [AppInfo], store: TagDatabase.Store) -> [QuickSearchDocument] {
        apps.map { app in
            let localizedNames = localizedNames(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 ?? ""
@@ -377,12 +379,14 @@
    ) -> [QuickSearchIndexedField] {
        let names = uniqueOrdered([appName] + localizedNames)
        let nameFields = names.map {
            QuickSearchIndexedField(
            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 = tagNames.map {
@@ -391,7 +395,9 @@
                text: $0,
                normalized: normalizeField($0),
                acronym: "",
                pinyinCandidates: pinyinCandidates(for: $0)
                pinyinCandidates: pinyinCandidates(for: $0),
                allowPinyinSubstring: false,
                allowPinyinFuzzySubsequence: false
            )
        }
        let noteFields = note.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? [] : [
@@ -400,7 +406,9 @@
                text: note,
                normalized: normalizeField(note),
                acronym: "",
                pinyinCandidates: pinyinCandidates(for: note)
                pinyinCandidates: pinyinCandidates(for: note),
                allowPinyinSubstring: true,
                allowPinyinFuzzySubsequence: false
            )
        ]
        let bundleFields = bundleIdentifier.isEmpty ? [] : [
@@ -409,7 +417,9 @@
                text: bundleIdentifier,
                normalized: normalizeField(bundleIdentifier),
                acronym: "",
                pinyinCandidates: []
                pinyinCandidates: [],
                allowPinyinSubstring: false,
                allowPinyinFuzzySubsequence: false
            )
        ]
        let internalBundleNameFields = internalBundleNames.map {
@@ -418,7 +428,9 @@
                text: $0,
                normalized: normalizeField($0),
                acronym: "",
                pinyinCandidates: []
                pinyinCandidates: [],
                allowPinyinSubstring: false,
                allowPinyinFuzzySubsequence: false
            )
        }
        return nameFields + tagFields + noteFields + bundleFields + internalBundleNameFields
@@ -453,7 +465,8 @@
            if let pinyinCandidate = matchPinyinCandidate(
                token: token,
                normalized: pinyin,
                allowSubstring: field.kind == .note
                allowSubstring: field.allowPinyinSubstring,
                allowFuzzySubsequence: field.allowPinyinFuzzySubsequence
            ) {
                candidates.append(pinyinCandidate)
            }
@@ -502,7 +515,8 @@
    private static func matchPinyinCandidate(
        token: String,
        normalized: String,
        allowSubstring: Bool = false
        allowSubstring: Bool = false,
        allowFuzzySubsequence: Bool = false
    ) -> (QuickSearchMatchKind, Int)? {
        guard !normalized.isEmpty else { return nil }
        if normalized == token {
@@ -513,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
    }
@@ -587,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)
@@ -606,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 {
@@ -692,6 +734,7 @@
    let results: [QuickSearchResult]
    let selectedID: URL?
    let focusToken: Int
    let selectionScrollToken: Int
    let isLoading: Bool
    let maxVisibleRows: Int
    let errorMessage: String?
@@ -699,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) {
@@ -725,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",
@@ -774,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)
                        }
@@ -786,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(
@@ -811,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)
@@ -875,7 +920,7 @@
    }
    private var accessibilityText: String {
        [result.app.name, detailText].compactMap { $0 }.joined(separator: ", ")
        [result.app.displayName, detailText].compactMap { $0 }.joined(separator: ", ")
    }
}
@@ -920,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
    }
@@ -934,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)
        }
    }
@@ -949,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>) {
@@ -959,6 +1012,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
            }
        }
    }
}