| | |
| | | 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] |
| | | } |
| | | |
| | | 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 = 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 |
| | | ) |
| | | } |
| | | } |
| | |
| | | } |
| | | |
| | | 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( |
| | | QuickSearchIndexedField( |
| | | kind: .name, |
| | | text: $0, |
| | | normalized: normalizeField($0), |
| | |
| | | pinyinCandidates: pinyinCandidates(for: $0) |
| | | ) |
| | | } |
| | | let tagFields = document.tagNames.map { |
| | | Field( |
| | | let tagFields = tagNames.map { |
| | | QuickSearchIndexedField( |
| | | kind: .tag, |
| | | text: $0, |
| | | normalized: normalizeField($0), |
| | |
| | | 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), |
| | |
| | | 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.kind == .note |
| | | ) { |
| | | 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 |
| | | ) -> (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 |
| | | } |
| | |
| | | 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() |
| | | } |
| | | } |
| | | } |