feat: use launch history to break search ranking ties
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user