| | |
| | | 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( |
| | | return QuickSearchIndexedField( |
| | | kind: .name, |
| | | text: $0, |
| | | normalized: normalizeField($0), |
| | | acronym: acronym(for: $0), |
| | | pinyinCandidates: pinyinCandidates(for: $0) |
| | | pinyinCandidates: pinyinCandidates(for: $0, includeLatin: true), |
| | | allowPinyinSubstring: true, |
| | | allowPinyinFuzzySubsequence: true |
| | | ) |
| | | } |
| | | let tagFields = 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 |
| | | } |
| | |
| | | .lowercased() |
| | | } |
| | | |
| | | private static func pinyinCandidates(for value: String) -> [String] { |
| | | guard value.range(of: #"\p{Han}"#, options: .regularExpression) != nil else { return [] } |
| | | private static func pinyinCandidates(for value: String, includeLatin: Bool = false) -> [String] { |
| | | guard includeLatin || containsNonLatinLetter(value) else { return [] } |
| | | let mutable = NSMutableString(string: value) |
| | | CFStringTransform(mutable, nil, kCFStringTransformToLatin, false) |
| | | CFStringTransform(mutable, nil, kCFStringTransformStripCombiningMarks, false) |
| | |
| | | .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 { |
| | |
| | | case dismiss |
| | | } |
| | | |
| | | enum QuickSearchPanelMetrics { |
| | | static let width: CGFloat = 760 |
| | | static let shadowOutset: CGFloat = 56 |
| | | static let rowHeight: CGFloat = 74 |
| | | static let rowSpacing: CGFloat = 2 |
| | | static let resultListVerticalInset: CGFloat = 10 |
| | | static let headerHeight: CGFloat = 84 |
| | | static let dividerHeight: CGFloat = 1 |
| | | static let messageRowHeight: CGFloat = 86 |
| | | |
| | | static func contentHeight(hasResultList: Bool, visibleRows: Int) -> CGFloat { |
| | | if hasResultList { |
| | | return headerHeight |
| | | + dividerHeight |
| | | + CGFloat(max(1, visibleRows)) * (rowHeight + rowSpacing) |
| | | + resultListVerticalInset * 2 |
| | | } |
| | | return headerHeight + dividerHeight + messageRowHeight |
| | | } |
| | | } |
| | | |
| | | struct QuickSearchPanelPresentationView: NSViewRepresentable { |
| | | @Binding var query: String |
| | | let results: [QuickSearchResult] |
| | | let selectedID: URL? |
| | | let focusToken: Int |
| | | let selectionScrollToken: Int |
| | | let isLoading: Bool |
| | | let maxVisibleRows: Int |
| | | let panelTopY: CGFloat |
| | | let panelHeight: CGFloat |
| | | let errorMessage: String? |
| | | let onCommand: (QuickSearchCommand) -> Void |
| | | let onHover: (QuickSearchResult) -> Void |
| | | let onLaunch: (QuickSearchResult) -> Void |
| | | |
| | | func makeNSView(context: Context) -> QuickSearchPanelAnchorView { |
| | | QuickSearchPanelAnchorView() |
| | | } |
| | | |
| | | func updateNSView(_ view: QuickSearchPanelAnchorView, context: Context) { |
| | | let contentSize = NSSize(width: QuickSearchPanelMetrics.width, height: panelHeight) |
| | | let windowSize = NSSize( |
| | | width: contentSize.width + QuickSearchPanelMetrics.shadowOutset * 2, |
| | | height: contentSize.height + QuickSearchPanelMetrics.shadowOutset * 2 |
| | | ) |
| | | let rootView = QuickSearchPanelWindowContent( |
| | | windowSize: windowSize, |
| | | contentSize: contentSize, |
| | | content: AnyView( |
| | | QuickSearchOverlayView( |
| | | query: $query, |
| | | results: results, |
| | | selectedID: selectedID, |
| | | focusToken: focusToken, |
| | | selectionScrollToken: selectionScrollToken, |
| | | isLoading: isLoading, |
| | | maxVisibleRows: maxVisibleRows, |
| | | errorMessage: errorMessage, |
| | | onCommand: onCommand, |
| | | onHover: onHover, |
| | | onLaunch: onLaunch |
| | | ) |
| | | ) |
| | | ) |
| | | context.coordinator.apply( |
| | | QuickSearchPanelConfiguration( |
| | | panelTopY: panelTopY, |
| | | windowSize: windowSize, |
| | | contentSize: contentSize, |
| | | rootView: AnyView(rootView) |
| | | ), |
| | | anchorView: view |
| | | ) |
| | | } |
| | | |
| | | static func dismantleNSView(_ nsView: QuickSearchPanelAnchorView, coordinator: Coordinator) { |
| | | coordinator.closePanel() |
| | | } |
| | | |
| | | func makeCoordinator() -> Coordinator { |
| | | Coordinator() |
| | | } |
| | | |
| | | final class Coordinator { |
| | | private var panel: QuickSearchPanel? |
| | | private var hostingView: NSHostingView<AnyView>? |
| | | private weak var parentWindow: NSWindow? |
| | | private var deferredApplyPending = false |
| | | |
| | | func apply(_ configuration: QuickSearchPanelConfiguration, anchorView: QuickSearchPanelAnchorView) { |
| | | guard let parentWindow = anchorView.window else { |
| | | scheduleDeferredApply(configuration, anchorView: anchorView) |
| | | return |
| | | } |
| | | |
| | | deferredApplyPending = false |
| | | let panel = ensurePanel(parentWindow: parentWindow) |
| | | let frame = windowFrame( |
| | | parentWindow: parentWindow, |
| | | panelTopY: configuration.panelTopY, |
| | | contentSize: configuration.contentSize, |
| | | windowSize: configuration.windowSize |
| | | ) |
| | | |
| | | hostingView?.rootView = configuration.rootView |
| | | hostingView?.frame = NSRect(origin: .zero, size: configuration.windowSize) |
| | | panel.contentView?.frame = NSRect(origin: .zero, size: configuration.windowSize) |
| | | panel.collectionBehavior = collectionBehavior(parentWindow: parentWindow) |
| | | panel.level = parentWindow.level |
| | | |
| | | if panel.parent !== parentWindow { |
| | | if let previousParent = panel.parent { |
| | | previousParent.removeChildWindow(panel) |
| | | } |
| | | parentWindow.addChildWindow(panel, ordered: .above) |
| | | self.parentWindow = parentWindow |
| | | } |
| | | |
| | | if panel.frame != frame { |
| | | panel.setFrame(frame, display: true) |
| | | } |
| | | panel.makeKeyAndOrderFront(nil) |
| | | panel.orderFrontRegardless() |
| | | } |
| | | |
| | | func closePanel() { |
| | | if let panel { |
| | | if let parent = panel.parent { |
| | | parent.removeChildWindow(panel) |
| | | } |
| | | panel.orderOut(nil) |
| | | } |
| | | panel = nil |
| | | hostingView = nil |
| | | parentWindow = nil |
| | | deferredApplyPending = false |
| | | } |
| | | |
| | | private func ensurePanel(parentWindow: NSWindow) -> QuickSearchPanel { |
| | | if let panel { |
| | | return panel |
| | | } |
| | | |
| | | let panel = QuickSearchPanel( |
| | | contentRect: .zero, |
| | | styleMask: [.borderless, .fullSizeContentView, .nonactivatingPanel], |
| | | backing: .buffered, |
| | | defer: false |
| | | ) |
| | | panel.isFloatingPanel = true |
| | | panel.hidesOnDeactivate = false |
| | | panel.isOpaque = false |
| | | panel.backgroundColor = .clear |
| | | panel.hasShadow = false |
| | | panel.titlebarAppearsTransparent = true |
| | | panel.titleVisibility = .hidden |
| | | panel.isReleasedWhenClosed = false |
| | | panel.collectionBehavior = collectionBehavior(parentWindow: parentWindow) |
| | | |
| | | let hostingView = NSHostingView(rootView: AnyView(EmptyView())) |
| | | hostingView.wantsLayer = true |
| | | hostingView.layer?.backgroundColor = NSColor.clear.cgColor |
| | | panel.contentView = hostingView |
| | | |
| | | self.panel = panel |
| | | self.hostingView = hostingView |
| | | return panel |
| | | } |
| | | |
| | | private func scheduleDeferredApply( |
| | | _ configuration: QuickSearchPanelConfiguration, |
| | | anchorView: QuickSearchPanelAnchorView |
| | | ) { |
| | | guard !deferredApplyPending else { return } |
| | | deferredApplyPending = true |
| | | DispatchQueue.main.async { [weak self, weak anchorView] in |
| | | guard let self, let anchorView else { return } |
| | | self.deferredApplyPending = false |
| | | self.apply(configuration, anchorView: anchorView) |
| | | } |
| | | } |
| | | |
| | | private func windowFrame( |
| | | parentWindow: NSWindow, |
| | | panelTopY: CGFloat, |
| | | contentSize: NSSize, |
| | | windowSize: NSSize |
| | | ) -> NSRect { |
| | | let parentFrame = parentWindow.frame |
| | | return NSRect( |
| | | x: parentFrame.minX + (parentFrame.width - contentSize.width) / 2 - QuickSearchPanelMetrics.shadowOutset, |
| | | y: parentFrame.maxY - panelTopY - contentSize.height - QuickSearchPanelMetrics.shadowOutset, |
| | | width: windowSize.width, |
| | | height: windowSize.height |
| | | ) |
| | | } |
| | | |
| | | private func collectionBehavior(parentWindow: NSWindow) -> NSWindow.CollectionBehavior { |
| | | var behavior: NSWindow.CollectionBehavior = [ |
| | | .fullScreenAuxiliary, |
| | | .stationary, |
| | | .transient, |
| | | .ignoresCycle |
| | | ] |
| | | if parentWindow.collectionBehavior.contains(.canJoinAllSpaces) { |
| | | behavior.insert(.canJoinAllSpaces) |
| | | } |
| | | return behavior |
| | | } |
| | | } |
| | | } |
| | | |
| | | private final class QuickSearchPanel: NSPanel { |
| | | override var canBecomeKey: Bool { true } |
| | | override var canBecomeMain: Bool { false } |
| | | |
| | | override func constrainFrameRect(_ frameRect: NSRect, to screen: NSScreen?) -> NSRect { |
| | | frameRect |
| | | } |
| | | } |
| | | |
| | | final class QuickSearchPanelAnchorView: NSView { |
| | | override init(frame frameRect: NSRect) { |
| | | super.init(frame: frameRect) |
| | | setup() |
| | | } |
| | | |
| | | required init?(coder: NSCoder) { |
| | | super.init(coder: coder) |
| | | setup() |
| | | } |
| | | |
| | | private func setup() { |
| | | wantsLayer = true |
| | | layer?.backgroundColor = NSColor.clear.cgColor |
| | | } |
| | | } |
| | | |
| | | struct QuickSearchPanelConfiguration { |
| | | let panelTopY: CGFloat |
| | | let windowSize: NSSize |
| | | let contentSize: NSSize |
| | | let rootView: AnyView |
| | | } |
| | | |
| | | private struct QuickSearchPanelWindowContent: View { |
| | | let windowSize: NSSize |
| | | let contentSize: NSSize |
| | | let content: AnyView |
| | | |
| | | var body: some View { |
| | | VStack(spacing: 0) { |
| | | content |
| | | .frame(width: contentSize.width, height: contentSize.height, alignment: .top) |
| | | Spacer(minLength: 0) |
| | | } |
| | | .padding(.top, QuickSearchPanelMetrics.shadowOutset) |
| | | .padding(.horizontal, QuickSearchPanelMetrics.shadowOutset) |
| | | .padding(.bottom, QuickSearchPanelMetrics.shadowOutset) |
| | | .frame(width: windowSize.width, height: windowSize.height, alignment: .top) |
| | | .background(Color.clear) |
| | | } |
| | | } |
| | | |
| | | struct QuickSearchOverlayView: View { |
| | | @Binding var query: String |
| | | 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 |
| | | |
| | | private let panelWidth: CGFloat = 760 |
| | | private let rowHeight: CGFloat = 74 |
| | | @Environment(\.colorScheme) private var colorScheme |
| | | |
| | | private let panelWidth: CGFloat = QuickSearchPanelMetrics.width |
| | | private let rowHeight: CGFloat = QuickSearchPanelMetrics.rowHeight |
| | | |
| | | private var panelBackgroundColor: Color { |
| | | colorScheme == .dark |
| | | ? Color(red: 0.105, green: 0.110, blue: 0.125).opacity(0.97) |
| | | : Color.white.opacity(0.97) |
| | | } |
| | | |
| | | private var panelShape: RoundedRectangle { |
| | | RoundedRectangle(cornerRadius: 34, style: .continuous) |
| | | } |
| | | |
| | | 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", |
| | |
| | | tint: .secondary |
| | | ) |
| | | } else { |
| | | ScrollViewReader { scrollProxy in |
| | | ScrollView(.vertical, showsIndicators: results.count > maxVisibleRows) { |
| | | LazyVStack(spacing: 2) { |
| | | ForEach(results) { result in |
| | | QuickSearchResultRow( |
| | | result: result, |
| | | isSelected: result.id == selectedID |
| | | ) |
| | | .frame(height: rowHeight) |
| | | .id(result.id) |
| | | .contentShape(Rectangle()) |
| | | .onHover { hovering in |
| | | if hovering { onHover(result) } |
| | | } |
| | | .onTapGesture { |
| | | onLaunch(result) |
| | | } |
| | | } |
| | | } |
| | | .padding(.horizontal, 10) |
| | | .padding(.vertical, 10) |
| | | } |
| | | .frame(height: CGFloat(min(results.count, maxVisibleRows)) * (rowHeight + 2) + 20) |
| | | .onChange(of: selectedID) { _, id in |
| | | guard let id else { return } |
| | | withAnimation(.easeOut(duration: 0.08)) { |
| | | scrollProxy.scrollTo(id, anchor: .center) |
| | | } |
| | | } |
| | | } |
| | | QuickSearchResultListView( |
| | | results: results, |
| | | selectedID: selectedID, |
| | | selectionScrollToken: selectionScrollToken, |
| | | maxVisibleRows: maxVisibleRows, |
| | | rowHeight: rowHeight, |
| | | isDarkMode: colorScheme == .dark, |
| | | onHover: onHover, |
| | | onLaunch: onLaunch |
| | | ) |
| | | .frame( |
| | | height: CGFloat(min(results.count, maxVisibleRows)) |
| | | * (rowHeight + QuickSearchPanelMetrics.rowSpacing) |
| | | + QuickSearchPanelMetrics.resultListVerticalInset * 2 |
| | | ) |
| | | } |
| | | } |
| | | .frame(width: panelWidth) |
| | | .background( |
| | | RoundedRectangle(cornerRadius: 34, style: .continuous) |
| | | .fill(.regularMaterial) |
| | | .shadow(color: .black.opacity(0.18), radius: 36, y: 18) |
| | | panelShape.fill(panelBackgroundColor) |
| | | ) |
| | | .clipShape(panelShape) |
| | | .overlay( |
| | | RoundedRectangle(cornerRadius: 34, style: .continuous) |
| | | .stroke(Color.primary.opacity(0.10), lineWidth: 1) |
| | | panelShape.stroke(Color.primary.opacity(0.10), lineWidth: 1) |
| | | ) |
| | | .compositingGroup() |
| | | .shadow(color: .black.opacity(colorScheme == .dark ? 0.46 : 0.26), radius: 30, x: 0, y: 18) |
| | | .shadow(color: .black.opacity(colorScheme == .dark ? 0.24 : 0.12), radius: 8, x: 0, y: 3) |
| | | .accessibilityElement(children: .contain) |
| | | .accessibilityLabel(tr("quickSearch.title")) |
| | | } |
| | | } |
| | | |
| | | private struct QuickSearchResultRow: View { |
| | | let result: QuickSearchResult |
| | | let isSelected: Bool |
| | | @Environment(\.colorScheme) private var colorScheme |
| | | private struct QuickSearchResultListView: NSViewRepresentable { |
| | | let results: [QuickSearchResult] |
| | | let selectedID: URL? |
| | | let selectionScrollToken: Int |
| | | let maxVisibleRows: Int |
| | | let rowHeight: CGFloat |
| | | let isDarkMode: Bool |
| | | let onHover: (QuickSearchResult) -> Void |
| | | let onLaunch: (QuickSearchResult) -> Void |
| | | |
| | | var body: some View { |
| | | HStack(spacing: 16) { |
| | | Image(nsImage: result.app.icon) |
| | | .resizable() |
| | | .frame(width: 46, height: 46) |
| | | .cornerRadius(10) |
| | | |
| | | VStack(alignment: .leading, spacing: 4) { |
| | | Text(result.app.name) |
| | | .font(.system(size: 20, weight: .semibold)) |
| | | .foregroundStyle(.primary) |
| | | .lineLimit(1) |
| | | .truncationMode(.tail) |
| | | |
| | | if let detailText { |
| | | Text(detailText) |
| | | .font(.system(size: 16, weight: .medium)) |
| | | .foregroundStyle(Color.primary.opacity(0.38)) |
| | | .lineLimit(1) |
| | | .truncationMode(.tail) |
| | | } |
| | | } |
| | | .layoutPriority(1) |
| | | |
| | | Spacer(minLength: 8) |
| | | |
| | | if let tagName = rightTagName { |
| | | Text(tagName) |
| | | .font(.system(size: 14, weight: .semibold)) |
| | | .foregroundStyle(Color.primary.opacity(0.46)) |
| | | .lineLimit(1) |
| | | .truncationMode(.tail) |
| | | .padding(.horizontal, 12) |
| | | .frame(height: 32) |
| | | .frame(maxWidth: 128) |
| | | .background( |
| | | Capsule(style: .continuous) |
| | | .fill(Color.primary.opacity(colorScheme == .dark ? 0.12 : 0.07)) |
| | | ) |
| | | } |
| | | } |
| | | .padding(.horizontal, 18) |
| | | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) |
| | | .background( |
| | | RoundedRectangle(cornerRadius: 18, style: .continuous) |
| | | .fill(isSelected ? selectedFill : Color.clear) |
| | | func makeNSView(context: Context) -> QuickSearchResultListHostView { |
| | | let view = QuickSearchResultListHostView() |
| | | view.update( |
| | | results: results, |
| | | selectedID: selectedID, |
| | | selectionScrollToken: selectionScrollToken, |
| | | maxVisibleRows: maxVisibleRows, |
| | | rowHeight: rowHeight, |
| | | isDarkMode: isDarkMode, |
| | | onHover: onHover, |
| | | onLaunch: onLaunch |
| | | ) |
| | | .accessibilityLabel(accessibilityText) |
| | | return view |
| | | } |
| | | |
| | | private var selectedFill: Color { |
| | | colorScheme == .dark ? Color.white.opacity(0.14) : Color.black.opacity(0.075) |
| | | func updateNSView(_ view: QuickSearchResultListHostView, context: Context) { |
| | | view.update( |
| | | results: results, |
| | | selectedID: selectedID, |
| | | selectionScrollToken: selectionScrollToken, |
| | | maxVisibleRows: maxVisibleRows, |
| | | rowHeight: rowHeight, |
| | | isDarkMode: isDarkMode, |
| | | onHover: onHover, |
| | | onLaunch: onLaunch |
| | | ) |
| | | } |
| | | } |
| | | |
| | | final class QuickSearchResultListHostView: NSView { |
| | | private let scrollView = NSScrollView() |
| | | private let documentView = QuickSearchResultListDocumentView() |
| | | private var selectedID: URL? |
| | | private var selectionScrollToken = 0 |
| | | private var didInstall = false |
| | | |
| | | override var isFlipped: Bool { true } |
| | | |
| | | override init(frame frameRect: NSRect) { |
| | | super.init(frame: frameRect) |
| | | setup() |
| | | } |
| | | |
| | | private var detailText: String? { |
| | | required init?(coder: NSCoder) { |
| | | super.init(coder: coder) |
| | | setup() |
| | | } |
| | | |
| | | func update( |
| | | results: [QuickSearchResult], |
| | | selectedID: URL?, |
| | | selectionScrollToken: Int, |
| | | maxVisibleRows: Int, |
| | | rowHeight: CGFloat, |
| | | isDarkMode: Bool, |
| | | onHover: @escaping (QuickSearchResult) -> Void, |
| | | onLaunch: @escaping (QuickSearchResult) -> Void |
| | | ) { |
| | | let shouldScrollSelection = selectionScrollToken != self.selectionScrollToken |
| | | self.selectedID = selectedID |
| | | self.selectionScrollToken = selectionScrollToken |
| | | scrollView.hasVerticalScroller = results.count > maxVisibleRows |
| | | documentView.update( |
| | | results: results, |
| | | selectedID: selectedID, |
| | | rowHeight: rowHeight, |
| | | isDarkMode: isDarkMode, |
| | | onHover: onHover, |
| | | onLaunch: onLaunch |
| | | ) |
| | | needsLayout = true |
| | | layoutSubtreeIfNeeded() |
| | | if shouldScrollSelection, let selectedID { |
| | | scrollResultToCenter(selectedID) |
| | | } |
| | | } |
| | | |
| | | override func layout() { |
| | | super.layout() |
| | | scrollView.frame = bounds |
| | | let visibleSize = scrollView.contentView.bounds.size |
| | | documentView.frame = NSRect( |
| | | origin: .zero, |
| | | size: NSSize( |
| | | width: max(visibleSize.width, bounds.width), |
| | | height: documentView.preferredHeight |
| | | ) |
| | | ) |
| | | documentView.needsLayout = true |
| | | } |
| | | |
| | | private func setup() { |
| | | guard !didInstall else { return } |
| | | didInstall = true |
| | | wantsLayer = true |
| | | layer?.backgroundColor = NSColor.clear.cgColor |
| | | |
| | | scrollView.drawsBackground = false |
| | | scrollView.borderType = .noBorder |
| | | scrollView.autohidesScrollers = true |
| | | scrollView.scrollerStyle = .overlay |
| | | scrollView.hasHorizontalScroller = false |
| | | scrollView.hasVerticalScroller = false |
| | | scrollView.documentView = documentView |
| | | addSubview(scrollView) |
| | | } |
| | | |
| | | private func scrollResultToCenter(_ id: URL) { |
| | | guard let frame = documentView.frameForResult(id) else { return } |
| | | let visibleBounds = scrollView.contentView.bounds |
| | | let maxY = max(0, documentView.bounds.height - visibleBounds.height) |
| | | let targetY = min(max(0, frame.midY - visibleBounds.height / 2), maxY) |
| | | scrollView.contentView.scroll(to: NSPoint(x: 0, y: targetY)) |
| | | scrollView.reflectScrolledClipView(scrollView.contentView) |
| | | } |
| | | } |
| | | |
| | | final class QuickSearchResultListDocumentView: NSView { |
| | | private var rowViews: [QuickSearchResultRowView] = [] |
| | | private var rowIDs: [URL] = [] |
| | | private var rowHeight: CGFloat = 74 |
| | | private let rowSpacing: CGFloat = 2 |
| | | private let horizontalInset: CGFloat = 10 |
| | | private let verticalInset: CGFloat = 10 |
| | | |
| | | override var isFlipped: Bool { true } |
| | | |
| | | var preferredHeight: CGFloat { |
| | | verticalInset * 2 |
| | | + CGFloat(rowViews.count) * rowHeight |
| | | + CGFloat(max(0, rowViews.count - 1)) * rowSpacing |
| | | } |
| | | |
| | | func update( |
| | | results: [QuickSearchResult], |
| | | selectedID: URL?, |
| | | rowHeight: CGFloat, |
| | | isDarkMode: Bool, |
| | | onHover: @escaping (QuickSearchResult) -> Void, |
| | | onLaunch: @escaping (QuickSearchResult) -> Void |
| | | ) { |
| | | self.rowHeight = rowHeight |
| | | let nextIDs = results.map(\.id) |
| | | if nextIDs != rowIDs { |
| | | rebuildRows(for: results) |
| | | rowIDs = nextIDs |
| | | } |
| | | |
| | | for (index, row) in rowViews.enumerated() { |
| | | guard index < results.count else { continue } |
| | | row.configure( |
| | | result: results[index], |
| | | isSelected: results[index].id == selectedID, |
| | | isDarkMode: isDarkMode, |
| | | onHover: onHover, |
| | | onLaunch: onLaunch |
| | | ) |
| | | } |
| | | needsLayout = true |
| | | } |
| | | |
| | | func frameForResult(_ id: URL) -> NSRect? { |
| | | guard let index = rowIDs.firstIndex(of: id), |
| | | index < rowViews.count |
| | | else { return nil } |
| | | return rowViews[index].frame |
| | | } |
| | | |
| | | override func layout() { |
| | | super.layout() |
| | | let width = max(0, bounds.width - horizontalInset * 2) |
| | | var y = verticalInset |
| | | for row in rowViews { |
| | | row.frame = NSRect(x: horizontalInset, y: y, width: width, height: rowHeight) |
| | | y += rowHeight + rowSpacing |
| | | } |
| | | } |
| | | |
| | | private func rebuildRows(for results: [QuickSearchResult]) { |
| | | rowViews.forEach { $0.removeFromSuperview() } |
| | | rowViews = results.map { _ in |
| | | let row = QuickSearchResultRowView() |
| | | addSubview(row) |
| | | return row |
| | | } |
| | | } |
| | | } |
| | | |
| | | final class QuickSearchResultRowView: NSView { |
| | | private let iconView = NSImageView() |
| | | private let titleLabel = NSTextField(labelWithString: "") |
| | | private let detailLabel = NSTextField(labelWithString: "") |
| | | private let tagBackgroundView = NSView() |
| | | private let tagLabel = NSTextField(labelWithString: "") |
| | | private var trackingAreaRef: NSTrackingArea? |
| | | private var result: QuickSearchResult? |
| | | private var isSelected = false |
| | | private var isDarkMode = false |
| | | private var onHover: (QuickSearchResult) -> Void = { _ in } |
| | | private var onLaunch: (QuickSearchResult) -> Void = { _ in } |
| | | |
| | | override var isFlipped: Bool { true } |
| | | |
| | | override init(frame frameRect: NSRect) { |
| | | super.init(frame: frameRect) |
| | | setup() |
| | | } |
| | | |
| | | required init?(coder: NSCoder) { |
| | | super.init(coder: coder) |
| | | setup() |
| | | } |
| | | |
| | | func configure( |
| | | result: QuickSearchResult, |
| | | isSelected: Bool, |
| | | isDarkMode: Bool, |
| | | onHover: @escaping (QuickSearchResult) -> Void, |
| | | onLaunch: @escaping (QuickSearchResult) -> Void |
| | | ) { |
| | | self.result = result |
| | | self.isSelected = isSelected |
| | | self.isDarkMode = isDarkMode |
| | | self.onHover = onHover |
| | | self.onLaunch = onLaunch |
| | | |
| | | iconView.image = result.app.icon |
| | | titleLabel.stringValue = result.app.displayName |
| | | detailLabel.stringValue = detailText(for: result) ?? "" |
| | | detailLabel.isHidden = detailLabel.stringValue.isEmpty |
| | | tagLabel.stringValue = rightTagName(for: result) ?? "" |
| | | tagBackgroundView.isHidden = tagLabel.stringValue.isEmpty |
| | | setAccessibilityLabel(accessibilityText(for: result)) |
| | | updateColors() |
| | | needsLayout = true |
| | | } |
| | | |
| | | override func layout() { |
| | | super.layout() |
| | | let iconSize: CGFloat = 46 |
| | | let iconX: CGFloat = 18 |
| | | iconView.frame = NSRect( |
| | | x: iconX, |
| | | y: (bounds.height - iconSize) / 2, |
| | | width: iconSize, |
| | | height: iconSize |
| | | ) |
| | | |
| | | let tagMaxWidth: CGFloat = 128 |
| | | let tagHeight: CGFloat = 32 |
| | | let trailingInset: CGFloat = 18 |
| | | var tagFrame = NSRect.zero |
| | | if !tagBackgroundView.isHidden { |
| | | let labelWidth = min(tagMaxWidth - 24, max(22, tagLabel.intrinsicContentSize.width)) |
| | | let tagWidth = min(tagMaxWidth, labelWidth + 24) |
| | | tagFrame = NSRect( |
| | | x: bounds.width - trailingInset - tagWidth, |
| | | y: (bounds.height - tagHeight) / 2, |
| | | width: tagWidth, |
| | | height: tagHeight |
| | | ) |
| | | tagBackgroundView.frame = tagFrame |
| | | tagLabel.frame = NSRect(x: 12, y: 6, width: tagWidth - 24, height: 20) |
| | | } |
| | | |
| | | let textX = iconX + iconSize + 16 |
| | | let textRight = tagBackgroundView.isHidden ? bounds.width - trailingInset : tagFrame.minX - 16 |
| | | let textWidth = max(40, textRight - textX) |
| | | if detailLabel.isHidden { |
| | | titleLabel.frame = NSRect(x: textX, y: (bounds.height - 26) / 2, width: textWidth, height: 26) |
| | | detailLabel.frame = .zero |
| | | } else { |
| | | titleLabel.frame = NSRect(x: textX, y: 14, width: textWidth, height: 25) |
| | | detailLabel.frame = NSRect(x: textX, y: 42, width: textWidth, height: 21) |
| | | } |
| | | } |
| | | |
| | | override func updateTrackingAreas() { |
| | | super.updateTrackingAreas() |
| | | if let trackingAreaRef { |
| | | removeTrackingArea(trackingAreaRef) |
| | | } |
| | | let area = NSTrackingArea( |
| | | rect: bounds, |
| | | options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect], |
| | | owner: self, |
| | | userInfo: nil |
| | | ) |
| | | addTrackingArea(area) |
| | | trackingAreaRef = area |
| | | } |
| | | |
| | | override func mouseEntered(with event: NSEvent) { |
| | | super.mouseEntered(with: event) |
| | | if let result { |
| | | onHover(result) |
| | | } |
| | | } |
| | | |
| | | override func mouseDown(with event: NSEvent) { |
| | | if let result { |
| | | DispatchQueue.main.async { [onLaunch] in |
| | | onLaunch(result) |
| | | } |
| | | } else { |
| | | super.mouseDown(with: event) |
| | | } |
| | | } |
| | | |
| | | private func setup() { |
| | | wantsLayer = true |
| | | layer?.cornerRadius = 18 |
| | | layer?.cornerCurve = .continuous |
| | | |
| | | iconView.imageScaling = .scaleProportionallyUpOrDown |
| | | iconView.wantsLayer = true |
| | | iconView.layer?.cornerRadius = 10 |
| | | iconView.layer?.cornerCurve = .continuous |
| | | iconView.layer?.masksToBounds = true |
| | | addSubview(iconView) |
| | | |
| | | titleLabel.font = NSFont.systemFont(ofSize: 20, weight: .semibold) |
| | | titleLabel.lineBreakMode = .byTruncatingTail |
| | | titleLabel.maximumNumberOfLines = 1 |
| | | titleLabel.backgroundColor = .clear |
| | | addSubview(titleLabel) |
| | | |
| | | detailLabel.font = NSFont.systemFont(ofSize: 16, weight: .medium) |
| | | detailLabel.lineBreakMode = .byTruncatingTail |
| | | detailLabel.maximumNumberOfLines = 1 |
| | | detailLabel.backgroundColor = .clear |
| | | addSubview(detailLabel) |
| | | |
| | | tagBackgroundView.wantsLayer = true |
| | | tagBackgroundView.layer?.cornerRadius = 16 |
| | | tagBackgroundView.layer?.cornerCurve = .continuous |
| | | tagBackgroundView.addSubview(tagLabel) |
| | | addSubview(tagBackgroundView) |
| | | |
| | | tagLabel.font = NSFont.systemFont(ofSize: 14, weight: .semibold) |
| | | tagLabel.alignment = .center |
| | | tagLabel.lineBreakMode = .byTruncatingTail |
| | | tagLabel.maximumNumberOfLines = 1 |
| | | tagLabel.backgroundColor = .clear |
| | | } |
| | | |
| | | private func updateColors() { |
| | | layer?.backgroundColor = isSelected |
| | | ? NSColor.labelColor.withAlphaComponent(isDarkMode ? 0.14 : 0.075).cgColor |
| | | : NSColor.clear.cgColor |
| | | titleLabel.textColor = .labelColor |
| | | detailLabel.textColor = NSColor.labelColor.withAlphaComponent(0.38) |
| | | tagLabel.textColor = NSColor.labelColor.withAlphaComponent(0.46) |
| | | tagBackgroundView.layer?.backgroundColor = NSColor.labelColor |
| | | .withAlphaComponent(isDarkMode ? 0.12 : 0.07) |
| | | .cgColor |
| | | } |
| | | |
| | | private func detailText(for result: QuickSearchResult) -> String? { |
| | | if let note = result.noteSnippet, !note.isEmpty { |
| | | return note |
| | | } |
| | |
| | | return nil |
| | | } |
| | | |
| | | private var rightTagName: String? { |
| | | private func rightTagName(for result: QuickSearchResult) -> String? { |
| | | let tag = result.matchedTagName ?? result.document.tagNames.first |
| | | guard let tag, !tag.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil } |
| | | return tag |
| | | } |
| | | |
| | | private var accessibilityText: String { |
| | | [result.app.name, detailText].compactMap { $0 }.joined(separator: ", ") |
| | | private func accessibilityText(for result: QuickSearchResult) -> String { |
| | | [result.app.displayName, detailText(for: result)].compactMap { $0 }.joined(separator: ", ") |
| | | } |
| | | } |
| | | |
| | |
| | | field.placeholderString = placeholder |
| | | field.delegate = context.coordinator |
| | | field.onCommand = onCommand |
| | | context.coordinator.onCommand = onCommand |
| | | field.setAccessibilityLabel(tr("quickSearch.inputAccessibility")) |
| | | context.coordinator.field = field |
| | | DispatchQueue.main.async { |
| | | field.window?.makeFirstResponder(field) |
| | | } |
| | | requestFocus(field) |
| | | return field |
| | | } |
| | | |
| | |
| | | } |
| | | field.placeholderString = placeholder |
| | | field.onCommand = onCommand |
| | | context.coordinator.onCommand = onCommand |
| | | if context.coordinator.lastFocusToken != focusToken { |
| | | context.coordinator.lastFocusToken = focusToken |
| | | DispatchQueue.main.async { |
| | | field.window?.makeFirstResponder(field) |
| | | requestFocus(field) |
| | | } |
| | | } |
| | | |
| | | private func requestFocus(_ field: QuickSearchNativeTextField) { |
| | | for delay in [0.0, 0.03, 0.08, 0.16] { |
| | | DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak field] in |
| | | guard let field, |
| | | let window = field.window, |
| | | window.isVisible, |
| | | window.firstResponder !== field |
| | | else { return } |
| | | window.makeKeyAndOrderFront(nil) |
| | | window.orderFrontRegardless() |
| | | window.makeFirstResponder(field) |
| | | } |
| | | } |
| | | } |
| | |
| | | final class Coordinator: NSObject, NSTextFieldDelegate { |
| | | var text: Binding<String> |
| | | var lastFocusToken = 0 |
| | | var onCommand: ((QuickSearchCommand) -> Void)? |
| | | weak var field: NSTextField? |
| | | |
| | | init(text: Binding<String>) { |
| | |
| | | guard let field = obj.object as? NSTextField else { return } |
| | | text.wrappedValue = field.stringValue |
| | | } |
| | | |
| | | func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { |
| | | switch commandSelector { |
| | | case #selector(NSResponder.moveUp(_:)): |
| | | onCommand?(.moveUp) |
| | | return true |
| | | case #selector(NSResponder.moveDown(_:)): |
| | | onCommand?(.moveDown) |
| | | return true |
| | | case #selector(NSResponder.insertNewline(_:)): |
| | | onCommand?(.submit) |
| | | return true |
| | | case #selector(NSResponder.insertNewlineIgnoringFieldEditor(_:)): |
| | | onCommand?(.submit) |
| | | return true |
| | | case #selector(NSResponder.cancelOperation(_:)): |
| | | onCommand?(.dismiss) |
| | | return true |
| | | default: |
| | | return false |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | private final class QuickSearchNativeTextField: NSTextField { |
| | | var onCommand: ((QuickSearchCommand) -> Void)? |
| | | |
| | | override var acceptsFirstResponder: Bool { true } |
| | | |
| | | override func keyDown(with event: NSEvent) { |
| | | switch Int(event.keyCode) { |
| | |
| | | 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() |
| | | } |
| | | } |
| | | } |