From 6d1513601dee27efc79e592a21a230851df8b270 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Sat, 23 May 2026 21:54:18 +0800
Subject: [PATCH] Prepare TagLauncher 7.6.0 release

---
 Apptag/QuickSearch.swift |  314 ++++++++++++++++++++++++++++++++--------------------
 1 files changed, 193 insertions(+), 121 deletions(-)

diff --git a/Apptag/QuickSearch.swift b/Apptag/QuickSearch.swift
index e94a87d..6056150 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,79 @@
         )
     }
 
-    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(
+            let allowsRomanizedFuzzy = containsNonLatinLetter($0)
+            return QuickSearchIndexedField(
                 kind: .name,
                 text: $0,
                 normalized: normalizeField($0),
                 acronym: acronym(for: $0),
-                pinyinCandidates: pinyinCandidates(for: $0)
+                pinyinCandidates: pinyinCandidates(for: $0),
+                allowPinyinSubstring: allowsRomanizedFuzzy,
+                allowPinyinFuzzySubsequence: allowsRomanizedFuzzy
             )
         }
-        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 +449,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 +466,8 @@
             if let pinyinCandidate = matchPinyinCandidate(
                 token: token,
                 normalized: pinyin,
-                allowSubstring: field.kind == .note
+                allowSubstring: field.allowPinyinSubstring,
+                allowFuzzySubsequence: field.allowPinyinFuzzySubsequence
             ) {
                 candidates.append(pinyinCandidate)
             }
@@ -444,24 +479,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 +516,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 +528,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
     }
@@ -567,7 +606,7 @@
     }
 
     private static func pinyinCandidates(for value: String) -> [String] {
-        guard value.range(of: #"\p{Han}"#, options: .regularExpression) != nil else { return [] }
+        guard containsNonLatinLetter(value) else { return [] }
         let mutable = NSMutableString(string: value)
         CFStringTransform(mutable, nil, kCFStringTransformToLatin, false)
         CFStringTransform(mutable, nil, kCFStringTransformStripCombiningMarks, false)
@@ -585,6 +624,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 {
@@ -671,6 +735,7 @@
     let results: [QuickSearchResult]
     let selectedID: URL?
     let focusToken: Int
+    let selectionScrollToken: Int
     let isLoading: Bool
     let maxVisibleRows: Int
     let errorMessage: String?
@@ -678,8 +743,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) {
@@ -704,17 +777,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",
@@ -753,8 +820,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)
                         }
@@ -765,7 +832,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(
@@ -790,7 +857,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)
@@ -854,7 +921,7 @@
     }
 
     private var accessibilityText: String {
-        [result.app.name, detailText].compactMap { $0 }.joined(separator: ", ")
+        [result.app.displayName, detailText].compactMap { $0 }.joined(separator: ", ")
     }
 }
 
@@ -901,9 +968,7 @@
         field.onCommand = onCommand
         field.setAccessibilityLabel(tr("quickSearch.inputAccessibility"))
         context.coordinator.field = field
-        DispatchQueue.main.async {
-            field.window?.makeFirstResponder(field)
-        }
+        requestFocus(field)
         return field
     }
 
@@ -915,9 +980,16 @@
         field.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)
         }
     }
 

--
Gitblit v1.9.3