diff --git a/README.md b/README.md index 3255c31..4c13936 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ Conjure recognizes the native [login-launch Apple event](https://developer.apple Applications are discovered in `/Applications`, `/System/Applications`, `/System/Library/CoreServices/Applications`, and `~/Applications`, including nested folders. Discovery runs off the UI thread and refreshes whenever the launcher is presented. Hidden items, invalid bundles, and helpers inside application packages are excluded; symlinked applications are deduplicated by their canonical paths. -Search is case- and accent-insensitive. Ranking is exact name, full-name prefix, word prefix, ordered word initials, substring, then ordered-subsequence fuzzy matches. Fuzzy matches favor smaller gaps and earlier starts. Ties are alphabetical. Hover over a result to see its path. Launch failures appear in a native alert without dismissing the launcher. +Search is case- and accent-insensitive. Ranking is exact name, full-name prefix, word prefix, ordered word initials, substring, then ordered-subsequence fuzzy matches. Fuzzy matches favor smaller gaps and earlier starts. Equally good text matches are ordered by successful launch count, then most recent launch, then alphabetically. Usage never promotes a weaker text match above a stronger one or changes which applications match. Hover over a result to see its path. Launch failures appear in a native alert without dismissing the launcher. Words are runs of letters or numbers separated by spaces, punctuation, or symbols. For example, `settings` matches the second word of System Settings, `gc` matches Google Chrome, and `ss` matches System Settings. Initials must occur in order but can skip words: both `vsc` and `vc` match Visual Studio Code. diff --git a/Sources/Conjure/LauncherWindowController.swift b/Sources/Conjure/LauncherWindowController.swift index 863c4fe..94251b4 100644 --- a/Sources/Conjure/LauncherWindowController.swift +++ b/Sources/Conjure/LauncherWindowController.swift @@ -305,7 +305,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe if searchField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { results = history.suggestions(in: applications) } else { - results = ApplicationSearch.results(for: searchField.stringValue, in: applications) + results = history.results(for: searchField.stringValue, in: applications) } tableView.reloadData() if !results.isEmpty { diff --git a/Sources/ConjureKit/Applications.swift b/Sources/ConjureKit/Applications.swift index 6091e51..3b20467 100644 --- a/Sources/ConjureKit/Applications.swift +++ b/Sources/ConjureKit/Applications.swift @@ -89,8 +89,15 @@ public enum ApplicationCatalog { public enum ApplicationSearch { public static func results(for query: String, in applications: [Application]) -> [Application] { + results(for: query, in: applications, breakingTiesWith: alphabetical) + } + + static func results( + for query: String, in applications: [Application], + breakingTiesWith preferred: (Application, Application) -> Bool + ) -> [Application] { let query = folded(query.trimmingCharacters(in: .whitespacesAndNewlines)) - guard !query.isEmpty else { return applications.sorted(by: alphabetical) } + guard !query.isEmpty else { return applications.sorted(by: preferred) } let characters = Array(query) var ranked = applications.compactMap { application -> (Application, Rank)? in let name = application.searchableName @@ -110,7 +117,7 @@ public enum ApplicationSearch { } ranked.sort { lhs, rhs in if lhs.1 != rhs.1 { return lhs.1 < rhs.1 } - return alphabetical(lhs.0, rhs.0) + return preferred(lhs.0, rhs.0) } return ranked.map(\.0) } diff --git a/Sources/ConjureKit/LaunchHistory.swift b/Sources/ConjureKit/LaunchHistory.swift index 774344c..9f59c56 100644 --- a/Sources/ConjureKit/LaunchHistory.swift +++ b/Sources/ConjureKit/LaunchHistory.swift @@ -40,16 +40,22 @@ public final class LaunchHistory { } public func suggestions(in applications: [Application]) -> [Application] { - applications.sorted { lhs, rhs in - let left = usage[lhs.id] - let right = usage[rhs.id] - let leftCount = left?.count ?? 0 - let rightCount = right?.count ?? 0 - if leftCount != rightCount { return leftCount > rightCount } - if let left, let right, left.lastLaunch != right.lastLaunch { - return left.lastLaunch > right.lastLaunch - } - return alphabetical(lhs, rhs) + applications.sorted(by: preferred) + } + + public func results(for query: String, in applications: [Application]) -> [Application] { + ApplicationSearch.results(for: query, in: applications, breakingTiesWith: preferred) + } + + private func preferred(_ lhs: Application, _ rhs: Application) -> Bool { + let left = usage[lhs.id] + let right = usage[rhs.id] + let leftCount = left?.count ?? 0 + let rightCount = right?.count ?? 0 + if leftCount != rightCount { return leftCount > rightCount } + if let left, let right, left.lastLaunch != right.lastLaunch { + return left.lastLaunch > right.lastLaunch } + return alphabetical(lhs, rhs) } } diff --git a/Tests/ConjureKitTests/LaunchHistoryTests.swift b/Tests/ConjureKitTests/LaunchHistoryTests.swift index 1ad29c9..abac467 100644 --- a/Tests/ConjureKitTests/LaunchHistoryTests.swift +++ b/Tests/ConjureKitTests/LaunchHistoryTests.swift @@ -79,6 +79,36 @@ func applicationIdentityIgnoresDirectoryURLFormattingInLaunchHistory() { #expect(history.suggestions(in: [historyApplication("Alpha"), plain]).first?.id == plain.id) } +@Test @MainActor +func typedSearchUsesHistoryOnlyForEqualTextRanks() { + let suite = "ConjureHistoryTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + var instant = Date(timeIntervalSinceReferenceDate: 5_000) + let history = LaunchHistory(defaults: defaults, now: { instant }) + let apps = ["Code", "Code Alpha", "Code Recent", "Code Frequent", "Open Code", "Unrelated"].map(historyApplication) + history.recordLaunch(of: apps[3]) + history.recordLaunch(of: apps[3]) + instant += 10 + history.recordLaunch(of: apps[2]) + for _ in 0..<3 { history.recordLaunch(of: apps[4]) } + history.recordLaunch(of: apps[5]) + #expect(history.results(for: "code", in: apps).map(\.name) == [ + "Code", "Code Frequent", "Code Recent", "Code Alpha", "Open Code", + ]) + instant += 10 + history.recordLaunch(of: apps[1]) + #expect(history.results(for: "code", in: apps).map(\.name) == [ + "Code", "Code Frequent", "Code Alpha", "Code Recent", "Open Code", + ]) + let fuzzy = ["AyB", "AzB", "xAyB", "AxxxxB"].map(historyApplication) + history.recordLaunch(of: fuzzy[1]) + for _ in 0..<3 { history.recordLaunch(of: fuzzy[3]) } + #expect(history.results(for: "ab", in: fuzzy).map(\.name) == [ + "AzB", "AyB", "xAyB", "AxxxxB", + ]) +} + private func historyApplication(_ name: String) -> Application { Application(url: URL(fileURLWithPath: "/Applications/HistoryTests/\(name).app"), name: name) }