import SwiftUI
|
import AppKit
|
import Carbon
|
|
extension Notification.Name {
|
static let tagLauncherQuickSearchRequested = Notification.Name("TagLauncherQuickSearchRequested")
|
static let tagLauncherQuickSearchDismissRequested = Notification.Name("TagLauncherQuickSearchDismissRequested")
|
static let tagLauncherQuickSearchVisibilityChanged = Notification.Name("TagLauncherQuickSearchVisibilityChanged")
|
static let tagLauncherHotkeysChanged = Notification.Name("TagLauncherHotkeysChanged")
|
}
|
|
enum QuickSearchOpenSource {
|
static let mainOverlay = "mainOverlay"
|
static let globalHidden = "globalHidden"
|
static let globalVisible = "globalVisible"
|
}
|
|
// MARK: - Hotkeys
|
|
struct LauncherHotkey: Equatable {
|
let keyCode: UInt32
|
let modifiers: UInt32
|
|
var serialized: String {
|
"\(keyCode):\(modifiers)"
|
}
|
|
var displayString: String {
|
let ordered: [(UInt32, String)] = [
|
(UInt32(controlKey), "⌃"),
|
(UInt32(optionKey), "⌥"),
|
(UInt32(shiftKey), "⇧"),
|
(UInt32(cmdKey), "⌘"),
|
(UInt32(kEventKeyModifierFnMask), "Fn+")
|
]
|
let modifierGlyphs = ordered
|
.filter { modifiers & $0.0 != 0 }
|
.map(\.1)
|
.joined()
|
return modifierGlyphs + LauncherHotkey.keyDisplayName(for: keyCode)
|
}
|
|
static var defaultMain: LauncherHotkey {
|
LauncherHotkey(keyCode: UInt32(kVK_Space), modifiers: UInt32(shiftKey | optionKey))
|
}
|
|
static var defaultQuickSearch: 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 {
|
switch Int(keyCode) {
|
case kVK_Space: return "Space"
|
case kVK_Return: return "Return"
|
case kVK_Escape: return "Esc"
|
case kVK_Delete: return "Delete"
|
case kVK_Tab: return "Tab"
|
case kVK_LeftArrow: return "←"
|
case kVK_RightArrow: return "→"
|
case kVK_UpArrow: return "↑"
|
case kVK_DownArrow: return "↓"
|
case kVK_F1: return "F1"
|
case kVK_F2: return "F2"
|
case kVK_F3: return "F3"
|
case kVK_F4: return "F4"
|
case kVK_F5: return "F5"
|
case kVK_F6: return "F6"
|
case kVK_F7: return "F7"
|
case kVK_F8: return "F8"
|
case kVK_F9: return "F9"
|
case kVK_F10: return "F10"
|
case kVK_F11: return "F11"
|
case kVK_F12: return "F12"
|
default:
|
if let scalar = keyCodeToPrintableScalar[Int(keyCode)] {
|
return String(scalar)
|
}
|
return "Key \(keyCode)"
|
}
|
}
|
|
private static let keyCodeToPrintableScalar: [Int: Character] = [
|
kVK_ANSI_A: "A", kVK_ANSI_B: "B", kVK_ANSI_C: "C", kVK_ANSI_D: "D",
|
kVK_ANSI_E: "E", kVK_ANSI_F: "F", kVK_ANSI_G: "G", kVK_ANSI_H: "H",
|
kVK_ANSI_I: "I", kVK_ANSI_J: "J", kVK_ANSI_K: "K", kVK_ANSI_L: "L",
|
kVK_ANSI_M: "M", kVK_ANSI_N: "N", kVK_ANSI_O: "O", kVK_ANSI_P: "P",
|
kVK_ANSI_Q: "Q", kVK_ANSI_R: "R", kVK_ANSI_S: "S", kVK_ANSI_T: "T",
|
kVK_ANSI_U: "U", kVK_ANSI_V: "V", kVK_ANSI_W: "W", kVK_ANSI_X: "X",
|
kVK_ANSI_Y: "Y", kVK_ANSI_Z: "Z", kVK_ANSI_0: "0", kVK_ANSI_1: "1",
|
kVK_ANSI_2: "2", kVK_ANSI_3: "3", kVK_ANSI_4: "4", kVK_ANSI_5: "5",
|
kVK_ANSI_6: "6", kVK_ANSI_7: "7", kVK_ANSI_8: "8", kVK_ANSI_9: "9"
|
]
|
}
|
|
enum LauncherHotkeyKind: String {
|
case main
|
case quickSearch
|
|
var storageKey: String {
|
switch self {
|
case .main: return "mainHotkey"
|
case .quickSearch: return "quickSearchHotkey"
|
}
|
}
|
|
var statusKey: String {
|
switch self {
|
case .main: return "mainHotkeyStatus"
|
case .quickSearch: return "quickSearchHotkeyStatus"
|
}
|
}
|
|
var conflictMessageKey: String {
|
switch self {
|
case .main: return "mainHotkeyConflictMessage"
|
case .quickSearch: return "quickSearchHotkeyConflictMessage"
|
}
|
}
|
|
var pendingStorageKey: String {
|
switch self {
|
case .main: return "mainHotkeyPending"
|
case .quickSearch: return "quickSearchHotkeyPending"
|
}
|
}
|
|
var eventID: UInt32 {
|
switch self {
|
case .main: return 1
|
case .quickSearch: return 2
|
}
|
}
|
}
|
|
enum LauncherHotkeyStatus: String {
|
case active = "Active"
|
case conflict = "Conflict"
|
case disabled = "Disabled"
|
}
|
|
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))
|
}
|
|
static func save(_ hotkey: LauncherHotkey?, for kind: LauncherHotkeyKind) {
|
UserDefaults.standard.set(hotkey?.serialized ?? "", forKey: kind.storageKey)
|
UserDefaults.standard.removeObject(forKey: kind.pendingStorageKey)
|
}
|
|
static func savePending(_ hotkey: LauncherHotkey, for kind: LauncherHotkeyKind) {
|
UserDefaults.standard.set(hotkey.serialized, forKey: kind.pendingStorageKey)
|
}
|
|
static func pendingHotkey(for kind: LauncherHotkeyKind) -> LauncherHotkey? {
|
LauncherHotkey.deserialize(UserDefaults.standard.string(forKey: kind.pendingStorageKey))
|
}
|
|
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 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)
|
} else {
|
UserDefaults.standard.removeObject(forKey: kind.conflictMessageKey)
|
}
|
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
|
}
|
}
|
}
|
|
// MARK: - Search Documents
|
|
struct QuickSearchDocument: Identifiable {
|
var id: URL { app.id }
|
let app: AppInfo
|
let localizedNames: [String]
|
let internalBundleNames: [String]
|
let tagNames: [String]
|
let note: String
|
let bundleIdentifier: String
|
let lastOpenedAt: Date?
|
let openCount: Int
|
}
|
|
struct QuickSearchResult: Identifiable {
|
var id: URL { document.id }
|
let document: QuickSearchDocument
|
let finalScore: Double
|
let textScore: Double
|
let bestFieldRank: Int
|
let matchedTagName: String?
|
let noteSnippet: String?
|
|
var app: AppInfo { document.app }
|
}
|
|
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),
|
tagNames: app.tags,
|
note: store.appNotes[app.path.path] ?? app.note ?? "",
|
bundleIdentifier: app.bundleIdentifier ?? "",
|
lastOpenedAt: store.appLastOpenedAt[app.path.path],
|
openCount: store.appOpenCounts[app.path.path] ?? 0
|
)
|
}
|
}
|
|
static func search(_ query: String, documents: [QuickSearchDocument], limit: Int = 50) -> [QuickSearchResult] {
|
let normalizedQuery = normalizeQuery(query)
|
guard !normalizedQuery.isEmpty else {
|
return emptyQueryResults(documents: documents, limit: min(limit, 6))
|
}
|
|
let tokens = normalizedQuery.split(separator: " ").map(String.init)
|
let results = documents.compactMap { result(for: $0, tokens: tokens) }
|
return results.sorted(by: rank).prefix(limit).map { $0 }
|
}
|
|
static func normalizeQuery(_ value: String) -> String {
|
value
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
.components(separatedBy: .whitespacesAndNewlines)
|
.filter { !$0.isEmpty }
|
.joined(separator: " ")
|
.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current)
|
.lowercased()
|
}
|
|
private static func result(for document: QuickSearchDocument, tokens: [String]) -> QuickSearchResult? {
|
let fields = searchableFields(for: document)
|
var textScore: Double = 0
|
var bestFieldRank = Int.max
|
var matchedTagName: String?
|
var noteSnippet: String?
|
|
for token in tokens {
|
guard let tokenMatch = fields
|
.compactMap({ match(token: token, field: $0) })
|
.max(by: { $0.score < $1.score })
|
else {
|
return nil
|
}
|
textScore += tokenMatch.score
|
bestFieldRank = min(bestFieldRank, tokenMatch.fieldRank)
|
if tokenMatch.fieldKind == .tag {
|
matchedTagName = tokenMatch.originalText
|
} else if tokenMatch.fieldKind == .note {
|
noteSnippet = snippet(from: document.note, token: token)
|
}
|
}
|
|
let finalScore = textScore + behaviorBoost(for: document)
|
return QuickSearchResult(
|
document: document,
|
finalScore: finalScore,
|
textScore: textScore,
|
bestFieldRank: bestFieldRank,
|
matchedTagName: matchedTagName,
|
noteSnippet: noteSnippet
|
)
|
}
|
|
private static func searchableFields(for document: QuickSearchDocument) -> [Field] {
|
let names = uniqueOrdered([document.app.name] + document.localizedNames)
|
let nameFields = names.map {
|
Field(
|
kind: .name,
|
text: $0,
|
normalized: normalizeField($0),
|
acronym: acronym(for: $0),
|
pinyinCandidates: pinyinCandidates(for: $0)
|
)
|
}
|
let tagFields = document.tagNames.map {
|
Field(
|
kind: .tag,
|
text: $0,
|
normalized: normalizeField($0),
|
acronym: "",
|
pinyinCandidates: pinyinCandidates(for: $0)
|
)
|
}
|
let noteFields = document.note.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? [] : [
|
Field(
|
kind: .note,
|
text: document.note,
|
normalized: normalizeField(document.note),
|
acronym: "",
|
pinyinCandidates: pinyinCandidates(for: document.note)
|
)
|
]
|
let bundleFields = document.bundleIdentifier.isEmpty ? [] : [
|
Field(
|
kind: .bundleIdentifier,
|
text: document.bundleIdentifier,
|
normalized: normalizeField(document.bundleIdentifier),
|
acronym: "",
|
pinyinCandidates: []
|
)
|
]
|
let internalBundleNameFields = document.internalBundleNames.map {
|
Field(
|
kind: .internalBundleName,
|
text: $0,
|
normalized: normalizeField($0),
|
acronym: "",
|
pinyinCandidates: []
|
)
|
}
|
return nameFields + tagFields + noteFields + bundleFields + internalBundleNameFields
|
}
|
|
private static func match(token: String, field: Field) -> TokenMatch? {
|
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(
|
score: score,
|
fieldRank: field.kind.rawValue,
|
fieldKind: field.kind,
|
originalText: field.text
|
)
|
}
|
|
private static func bestMatchCandidate(token: String, field: Field) -> (MatchKind, Int)? {
|
var candidates: [(MatchKind, Int)] = []
|
|
if let textCandidate = matchCandidate(
|
token: token,
|
normalized: field.normalized,
|
options: matchOptions(for: field.kind)
|
) {
|
candidates.append(textCandidate)
|
}
|
if field.kind == .name && !field.acronym.isEmpty && field.acronym.hasPrefix(token) {
|
candidates.append((.acronym, 0))
|
}
|
for pinyin in field.pinyinCandidates {
|
if let pinyinCandidate = matchPinyinCandidate(token: token, normalized: pinyin) {
|
candidates.append(pinyinCandidate)
|
}
|
}
|
|
return candidates.max { lhs, rhs in
|
if lhs.0.weight != rhs.0.weight { return lhs.0.weight < rhs.0.weight }
|
return lhs.1 > rhs.1
|
}
|
}
|
|
private static func matchOptions(for fieldKind: FieldKind) -> MatchOptions {
|
switch fieldKind {
|
case .name:
|
return MatchOptions(allowSubstring: true, allowFuzzySubsequence: true)
|
case .tag:
|
return MatchOptions(allowSubstring: true, allowFuzzySubsequence: true)
|
case .note:
|
return MatchOptions(allowSubstring: true, allowFuzzySubsequence: false)
|
case .bundleIdentifier, .internalBundleName:
|
return MatchOptions(allowSubstring: false, allowFuzzySubsequence: false)
|
}
|
}
|
|
private static func matchCandidate(
|
token: String,
|
normalized: String,
|
options: MatchOptions
|
) -> (MatchKind, Int)? {
|
guard !normalized.isEmpty else { return nil }
|
if normalized == token {
|
return (.exact, 0)
|
}
|
if normalized.hasPrefix(token) {
|
return (.prefix, 0)
|
}
|
if options.allowSubstring, let range = normalized.range(of: token) {
|
return (.substring, normalized.distance(from: normalized.startIndex, to: range.lowerBound))
|
}
|
if options.allowFuzzySubsequence, token.count >= 4, isSubsequence(token, of: normalized) {
|
return (.fuzzy, 10)
|
}
|
return nil
|
}
|
|
private static func matchPinyinCandidate(token: String, normalized: String) -> (MatchKind, Int)? {
|
guard !normalized.isEmpty else { return nil }
|
if normalized == token {
|
return (.exact, 0)
|
}
|
if normalized.hasPrefix(token) {
|
return (.prefix, 0)
|
}
|
return nil
|
}
|
|
private static func emptyQueryResults(documents: [QuickSearchDocument], limit: Int) -> [QuickSearchResult] {
|
var used = Set<URL>()
|
let recent = documents
|
.filter { $0.lastOpenedAt != nil }
|
.sorted {
|
if ($0.lastOpenedAt ?? .distantPast) != ($1.lastOpenedAt ?? .distantPast) {
|
return ($0.lastOpenedAt ?? .distantPast) > ($1.lastOpenedAt ?? .distantPast)
|
}
|
return $0.app.name.localizedStandardCompare($1.app.name) == .orderedAscending
|
}
|
|
let frequent = documents
|
.filter { $0.openCount > 0 }
|
.sorted {
|
if $0.openCount != $1.openCount { return $0.openCount > $1.openCount }
|
return $0.app.name.localizedStandardCompare($1.app.name) == .orderedAscending
|
}
|
|
let ordered = (recent + frequent).filter { used.insert($0.id).inserted }
|
return ordered.prefix(limit).map {
|
QuickSearchResult(
|
document: $0,
|
finalScore: behaviorBoost(for: $0),
|
textScore: 0,
|
bestFieldRank: Int.max,
|
matchedTagName: nil,
|
noteSnippet: nil
|
)
|
}
|
}
|
|
private static func rank(_ lhs: QuickSearchResult, _ rhs: QuickSearchResult) -> Bool {
|
if lhs.finalScore != rhs.finalScore { return lhs.finalScore > rhs.finalScore }
|
if lhs.textScore != rhs.textScore { return lhs.textScore > rhs.textScore }
|
if lhs.bestFieldRank != rhs.bestFieldRank { return lhs.bestFieldRank < rhs.bestFieldRank }
|
let leftDate = lhs.document.lastOpenedAt ?? .distantPast
|
let rightDate = rhs.document.lastOpenedAt ?? .distantPast
|
if leftDate != rightDate { return leftDate > rightDate }
|
if lhs.document.openCount != rhs.document.openCount {
|
return lhs.document.openCount > rhs.document.openCount
|
}
|
if lhs.app.name.count != rhs.app.name.count {
|
return lhs.app.name.count < rhs.app.name.count
|
}
|
return lhs.app.name.localizedStandardCompare(rhs.app.name) == .orderedAscending
|
}
|
|
private static func behaviorBoost(for document: QuickSearchDocument) -> Double {
|
min(recentBoost(for: document.lastOpenedAt) + frequencyBoost(for: document.openCount), 20)
|
}
|
|
private static func recentBoost(for date: Date?) -> Double {
|
guard let date else { return 0 }
|
let age = Date().timeIntervalSince(date)
|
if age <= 24 * 60 * 60 { return 15 }
|
if age <= 7 * 24 * 60 * 60 { return 10 }
|
if age <= 30 * 24 * 60 * 60 { return 5 }
|
return 2
|
}
|
|
private static func frequencyBoost(for openCount: Int) -> Double {
|
min(Double(openCount), 10)
|
}
|
|
private static func normalizeField(_ value: String) -> String {
|
value
|
.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current)
|
.lowercased()
|
}
|
|
private static func pinyinCandidates(for value: String) -> [String] {
|
guard value.range(of: #"\p{Han}"#, options: .regularExpression) != nil else { return [] }
|
let mutable = NSMutableString(string: value)
|
CFStringTransform(mutable, nil, kCFStringTransformToLatin, false)
|
CFStringTransform(mutable, nil, kCFStringTransformStripCombiningMarks, false)
|
|
let spaced = normalizeField(mutable as String)
|
.components(separatedBy: CharacterSet.alphanumerics.inverted)
|
.filter { !$0.isEmpty }
|
.joined(separator: " ")
|
guard !spaced.isEmpty else { return [] }
|
|
let compact = spaced.replacingOccurrences(of: " ", with: "")
|
let initials = spaced
|
.split(separator: " ")
|
.compactMap(\.first)
|
.map(String.init)
|
.joined()
|
return uniqueOrdered([spaced, compact, initials].filter { !$0.isEmpty })
|
}
|
|
private static func isSubsequence(_ token: String, of value: String) -> Bool {
|
var searchStart = value.startIndex
|
for character in token {
|
guard let index = value[searchStart...].firstIndex(of: character) else { return false }
|
searchStart = value.index(after: index)
|
}
|
return true
|
}
|
|
private static func acronym(for value: String) -> String {
|
var parts: [Character] = []
|
var nextStartsWord = true
|
var previousWasLowercase = false
|
for character in value {
|
let current = String(character)
|
let isAlphanumeric = current.rangeOfCharacter(from: .alphanumerics) != nil
|
guard isAlphanumeric else {
|
nextStartsWord = true
|
previousWasLowercase = false
|
continue
|
}
|
|
let isUppercase = current.rangeOfCharacter(from: .uppercaseLetters) != nil
|
let isLowercase = current.rangeOfCharacter(from: .lowercaseLetters) != nil
|
if nextStartsWord || (isUppercase && previousWasLowercase) {
|
parts.append(character)
|
}
|
nextStartsWord = false
|
previousWasLowercase = isLowercase
|
}
|
return normalizeField(String(parts))
|
}
|
|
private static func localizedNames(for app: AppInfo) -> [String] {
|
guard let bundle = Bundle(url: app.path) else { return [] }
|
let values = [
|
bundle.localizedInfoDictionary?["CFBundleDisplayName"] as? String,
|
bundle.infoDictionary?["CFBundleDisplayName"] as? String,
|
FileManager.default.displayName(atPath: app.path.path).replacingOccurrences(of: ".app", with: "")
|
]
|
return uniqueOrdered(values.compactMap { $0 }.compactMap { value in
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
return trimmed.isEmpty || trimmed == app.name ? nil : trimmed
|
})
|
}
|
|
private static func internalBundleNames(for app: AppInfo) -> [String] {
|
guard let bundle = Bundle(url: app.path) else { return [] }
|
let values = [
|
bundle.localizedInfoDictionary?["CFBundleName"] as? String,
|
bundle.infoDictionary?["CFBundleName"] as? String
|
]
|
return uniqueOrdered(values.compactMap { $0 }.compactMap { value in
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
return trimmed.isEmpty || trimmed == app.name ? nil : trimmed
|
})
|
}
|
|
private static func snippet(from note: String, token: String) -> String {
|
let trimmed = note.trimmingCharacters(in: .whitespacesAndNewlines)
|
guard trimmed.count > 80 else { return trimmed }
|
return String(trimmed.prefix(77)) + "..."
|
}
|
|
private static func uniqueOrdered(_ values: [String]) -> [String] {
|
var seen = Set<String>()
|
return values.filter { seen.insert(normalizeField($0)).inserted }
|
}
|
}
|
|
// MARK: - Quick Search UI
|
|
enum QuickSearchCommand {
|
case moveUp
|
case moveDown
|
case submit
|
case dismiss
|
}
|
|
struct QuickSearchOverlayView: View {
|
@Binding var query: String
|
let results: [QuickSearchResult]
|
let selectedID: URL?
|
let focusToken: Int
|
let isLoading: Bool
|
let maxVisibleRows: Int
|
let errorMessage: String?
|
let onCommand: (QuickSearchCommand) -> Void
|
let onHover: (QuickSearchResult) -> Void
|
let onLaunch: (QuickSearchResult) -> Void
|
|
private let panelWidth: CGFloat = 760
|
private let rowHeight: CGFloat = 74
|
|
var body: some View {
|
VStack(alignment: .leading, spacing: 0) {
|
HStack(spacing: 18) {
|
Image(systemName: "magnifyingglass")
|
.font(.system(size: 29, weight: .regular))
|
.foregroundStyle(Color.primary.opacity(0.48))
|
.frame(width: 34)
|
|
QuickSearchTextField(
|
text: $query,
|
placeholder: tr("quickSearch.placeholder"),
|
focusToken: focusToken,
|
onCommand: onCommand
|
)
|
.frame(height: 44)
|
}
|
.padding(.horizontal, 28)
|
.padding(.top, 22)
|
.padding(.bottom, 18)
|
|
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)
|
} else if let errorMessage {
|
QuickSearchMessageRow(
|
systemImage: "exclamationmark.triangle.fill",
|
message: errorMessage,
|
tint: .orange
|
)
|
} else if results.isEmpty {
|
QuickSearchMessageRow(
|
systemImage: query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "keyboard" : "magnifyingglass",
|
message: query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
? tr("quickSearch.emptyPrompt")
|
: tr("quickSearch.noResults"),
|
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)
|
}
|
}
|
}
|
}
|
}
|
.frame(width: panelWidth)
|
.background(
|
RoundedRectangle(cornerRadius: 34, style: .continuous)
|
.fill(.regularMaterial)
|
.shadow(color: .black.opacity(0.18), radius: 36, y: 18)
|
)
|
.overlay(
|
RoundedRectangle(cornerRadius: 34, style: .continuous)
|
.stroke(Color.primary.opacity(0.10), lineWidth: 1)
|
)
|
.accessibilityElement(children: .contain)
|
.accessibilityLabel(tr("quickSearch.title"))
|
}
|
}
|
|
private struct QuickSearchResultRow: View {
|
let result: QuickSearchResult
|
let isSelected: Bool
|
@Environment(\.colorScheme) private var colorScheme
|
|
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)
|
)
|
.accessibilityLabel(accessibilityText)
|
}
|
|
private var selectedFill: Color {
|
colorScheme == .dark ? Color.white.opacity(0.14) : Color.black.opacity(0.075)
|
}
|
|
private var detailText: String? {
|
if let note = result.noteSnippet, !note.isEmpty {
|
return note
|
}
|
let note = result.document.note.trimmingCharacters(in: .whitespacesAndNewlines)
|
if !note.isEmpty {
|
return note.count > 72 ? String(note.prefix(69)) + "..." : note
|
}
|
return nil
|
}
|
|
private var rightTagName: 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 struct QuickSearchMessageRow: View {
|
let systemImage: String
|
let message: String
|
let tint: Color
|
|
var body: some View {
|
HStack(spacing: 10) {
|
Image(systemName: systemImage)
|
.font(.system(size: 20, weight: .regular))
|
.foregroundStyle(tint)
|
.frame(width: 28)
|
Text(message)
|
.font(.system(size: 15, weight: .medium))
|
.foregroundStyle(.secondary)
|
.lineLimit(2)
|
.fixedSize(horizontal: false, vertical: true)
|
Spacer(minLength: 0)
|
}
|
.padding(.horizontal, 28)
|
.padding(.vertical, 24)
|
.frame(minHeight: 86)
|
.accessibilityLabel(message)
|
}
|
}
|
|
private struct QuickSearchTextField: NSViewRepresentable {
|
@Binding var text: String
|
let placeholder: String
|
let focusToken: Int
|
let onCommand: (QuickSearchCommand) -> Void
|
|
func makeNSView(context: Context) -> QuickSearchNativeTextField {
|
let field = QuickSearchNativeTextField()
|
field.isBordered = false
|
field.isBezeled = false
|
field.drawsBackground = false
|
field.focusRingType = .none
|
field.font = NSFont.systemFont(ofSize: 28, weight: .regular)
|
field.placeholderString = placeholder
|
field.delegate = context.coordinator
|
field.onCommand = onCommand
|
field.setAccessibilityLabel(tr("quickSearch.inputAccessibility"))
|
context.coordinator.field = field
|
DispatchQueue.main.async {
|
field.window?.makeFirstResponder(field)
|
}
|
return field
|
}
|
|
func updateNSView(_ field: QuickSearchNativeTextField, context: Context) {
|
if field.stringValue != text {
|
field.stringValue = text
|
}
|
field.placeholderString = placeholder
|
field.onCommand = onCommand
|
if context.coordinator.lastFocusToken != focusToken {
|
context.coordinator.lastFocusToken = focusToken
|
DispatchQueue.main.async {
|
field.window?.makeFirstResponder(field)
|
}
|
}
|
}
|
|
func makeCoordinator() -> Coordinator {
|
Coordinator(text: $text)
|
}
|
|
final class Coordinator: NSObject, NSTextFieldDelegate {
|
var text: Binding<String>
|
var lastFocusToken = 0
|
weak var field: NSTextField?
|
|
init(text: Binding<String>) {
|
self.text = text
|
}
|
|
func controlTextDidChange(_ obj: Notification) {
|
guard let field = obj.object as? NSTextField else { return }
|
text.wrappedValue = field.stringValue
|
}
|
}
|
}
|
|
private final class QuickSearchNativeTextField: NSTextField {
|
var onCommand: ((QuickSearchCommand) -> Void)?
|
|
override func keyDown(with event: NSEvent) {
|
switch Int(event.keyCode) {
|
case kVK_UpArrow:
|
onCommand?(.moveUp)
|
case kVK_DownArrow:
|
onCommand?(.moveDown)
|
case kVK_Return:
|
onCommand?(.submit)
|
case kVK_Escape:
|
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()
|
}
|
}
|
}
|