From 7c97841d07d4a596508f663c887d1f67d105faf6 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Wed, 20 May 2026 13:07:20 +0800
Subject: [PATCH] perf: reduce launcher scroll and search jank
---
Apptag/QuickSearch.swift | 484 +++++++++++++++++++++--------------------------------
1 files changed, 193 insertions(+), 291 deletions(-)
diff --git a/Apptag/QuickSearch.swift b/Apptag/QuickSearch.swift
index 679f4c6..4995618 100644
--- a/Apptag/QuickSearch.swift
+++ b/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,135 @@
}
}
-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]
+}
+
+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 +268,7 @@
let bundleIdentifier: String
let lastOpenedAt: Date?
let openCount: Int
+ fileprivate let searchableFields: [QuickSearchIndexedField]
}
struct QuickSearchResult: Identifiable {
@@ -254,73 +284,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 = localizedNames(for: app)
+ 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 +334,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,10 +367,17 @@
)
}
- 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(
+ QuickSearchIndexedField(
kind: .name,
text: $0,
normalized: normalizeField($0),
@@ -391,8 +385,8 @@
pinyinCandidates: pinyinCandidates(for: $0)
)
}
- let tagFields = document.tagNames.map {
- Field(
+ let tagFields = tagNames.map {
+ QuickSearchIndexedField(
kind: .tag,
text: $0,
normalized: normalizeField($0),
@@ -400,26 +394,26 @@
pinyinCandidates: pinyinCandidates(for: $0)
)
}
- 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)
)
]
- 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: []
)
]
- let internalBundleNameFields = document.internalBundleNames.map {
- Field(
+ let internalBundleNameFields = internalBundleNames.map {
+ QuickSearchIndexedField(
kind: .internalBundleName,
text: $0,
normalized: normalizeField($0),
@@ -430,11 +424,11 @@
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 +436,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 +450,11 @@
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.kind == .note
+ ) {
candidates.append(pinyinCandidate)
}
}
@@ -467,24 +465,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 +499,20 @@
return nil
}
- private static func matchPinyinCandidate(token: String, normalized: String) -> (MatchKind, Int)? {
+ private static func matchPinyinCandidate(
+ token: String,
+ normalized: String,
+ allowSubstring: 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))
}
return nil
}
@@ -972,109 +977,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()
}
}
}
--
Gitblit v1.9.3