| | |
| | | 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 { |
| | |
| | | struct LauncherHotkey: Equatable { |
| | | let keyCode: UInt32 |
| | | let modifiers: UInt32 |
| | | |
| | | var serialized: String { |
| | | "\(keyCode):\(modifiers)" |
| | | } |
| | | |
| | | var displayString: String { |
| | | let ordered: [(UInt32, String)] = [ |
| | |
| | | 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 { |
| | |
| | | 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 |
| | | } |
| | | } |
| | | |
| | |
| | | } |
| | | } |
| | | |
| | | 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 } |
| | |
| | | let bundleIdentifier: String |
| | | let lastOpenedAt: Date? |
| | | let openCount: Int |
| | | fileprivate let searchableFields: [QuickSearchIndexedField] |
| | | } |
| | | |
| | | struct QuickSearchResult: Identifiable { |
| | |
| | | } |
| | | |
| | | 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 |
| | | ) |
| | | } |
| | | } |
| | |
| | | } |
| | | |
| | | 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? |
| | |
| | | ) |
| | | } |
| | | |
| | | 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, |
| | |
| | | ) |
| | | } |
| | | |
| | | 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, |
| | |
| | | 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) |
| | | } |
| | | } |
| | |
| | | } |
| | | } |
| | | |
| | | 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) |
| | |
| | | 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 |
| | | } |
| | |
| | | } |
| | | |
| | | 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) |
| | |
| | | .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 { |
| | |
| | | let results: [QuickSearchResult] |
| | | let selectedID: URL? |
| | | let focusToken: Int |
| | | let selectionScrollToken: Int |
| | | let isLoading: Bool |
| | | let maxVisibleRows: Int |
| | | let errorMessage: String? |
| | |
| | | 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) { |
| | |
| | | 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", |
| | |
| | | .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) |
| | | } |
| | |
| | | .frame(width: panelWidth) |
| | | .background( |
| | | RoundedRectangle(cornerRadius: 34, style: .continuous) |
| | | .fill(.regularMaterial) |
| | | .fill(panelBackgroundColor) |
| | | .shadow(color: .black.opacity(0.18), radius: 36, y: 18) |
| | | ) |
| | | .overlay( |
| | |
| | | .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) |
| | |
| | | } |
| | | |
| | | private var accessibilityText: String { |
| | | [result.app.name, detailText].compactMap { $0 }.joined(separator: ", ") |
| | | [result.app.displayName, detailText].compactMap { $0 }.joined(separator: ", ") |
| | | } |
| | | } |
| | | |
| | |
| | | field.onCommand = onCommand |
| | | field.setAccessibilityLabel(tr("quickSearch.inputAccessibility")) |
| | | context.coordinator.field = field |
| | | DispatchQueue.main.async { |
| | | field.window?.makeFirstResponder(field) |
| | | } |
| | | requestFocus(field) |
| | | return field |
| | | } |
| | | |
| | |
| | | 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) |
| | | } |
| | | } |
| | | |
| | |
| | | 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() |
| | | } |
| | | } |
| | | } |