feat: prioritize word starts and app acronyms in search

This commit is contained in:
2026-10-11 21:16:18 +02:00
parent e9ac609884
commit 3714fc516b
3 changed files with 73 additions and 8 deletions
+3 -1
View File
@@ -33,7 +33,9 @@ Open `Package.swift` in Xcode to edit the project. Use the bundled app above to
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, prefix, 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. Ties are alphabetical. 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.
With an empty search, applications are ordered by successful launch count, then most recent launch, then alphabetically. Usage is stored locally in the app's preferences and survives restarts. Only launches through Conjure count; failed launches do not. Applications without history remain alphabetical.
+31 -3
View File
@@ -6,6 +6,7 @@ public struct Application: Identifiable, Sendable {
public let name: String
fileprivate let searchableName: String
fileprivate let searchableCharacters: [Character]
fileprivate let searchableWordStarts: [Int]
public init(url: URL, name: String) {
self.init(canonicalURL: url.standardizedFileURL.resolvingSymlinksInPath(), name: name)
@@ -16,7 +17,16 @@ public struct Application: Identifiable, Sendable {
? canonicalURL : URL(fileURLWithPath: canonicalURL.path, isDirectory: true)
self.name = name
searchableName = folded(name)
searchableCharacters = Array(searchableName)
let characters = Array(searchableName)
searchableCharacters = characters
var starts: [Int] = []
var previousWasWordCharacter = false
for index in characters.indices {
let isWordCharacter = characters[index].isLetter || characters[index].isNumber
if isWordCharacter && !previousWasWordCharacter { starts.append(index) }
previousWasWordCharacter = isWordCharacter
}
searchableWordStarts = starts
}
}
@@ -86,7 +96,15 @@ public enum ApplicationSearch {
let name = application.searchableName
if name == query { return (application, Rank(category: 0)) }
if name.hasPrefix(query) { return (application, Rank(category: 1)) }
if name.contains(query) { return (application, Rank(category: 2)) }
if application.searchableWordStarts.contains(where: {
$0 > 0 && application.searchableCharacters[$0...].starts(with: characters)
}) {
return (application, Rank(category: 2))
}
if matchesWordInitials(characters, in: application) {
return (application, Rank(category: 3))
}
if name.contains(query) { return (application, Rank(category: 4)) }
guard let rank = fuzzyRank(characters, in: application.searchableCharacters) else { return nil }
return (application, rank)
}
@@ -107,6 +125,16 @@ public enum ApplicationSearch {
}
}
private static func matchesWordInitials(_ query: [Character], in application: Application) -> Bool {
var matched = 0
for start in application.searchableWordStarts {
guard application.searchableCharacters[start] == query[matched] else { continue }
matched += 1
if matched == query.count { return true }
}
return false
}
// ponytail: quadratic in app-name length; use a linear matcher if long command text becomes searchable.
private static func fuzzyRank(_ query: [Character], in name: [Character]) -> Rank? {
guard query.count <= name.count else { return nil }
@@ -119,7 +147,7 @@ public enum ApplicationSearch {
if name[end] == query[matched] { matched += 1 }
}
guard matched == query.count else { continue }
let rank = Rank(category: 3, gaps: end - start + 1 - query.count, start: start)
let rank = Rank(category: 5, gaps: end - start + 1 - query.count, start: start)
if let best, best <= rank { continue }
best = rank
}
+39 -4
View File
@@ -2,16 +2,51 @@ import ConjureKit
import Foundation
import Testing
@Test func searchRanksExactPrefixSubstringAndFuzzyMatches() {
let names = ["Caramel", "My cl Tool", "Cloud", "Cold", "CL", "Unrelated"]
@Test func searchRanksExactPrefixWordPrefixInitialsSubstringAndFuzzyMatches() {
let names = ["Logic", "Agc Utility", "Great Console", "Google Chrome", "Tool GC Helper", "GCompiler", "GC", "Unrelated"]
let applications = names.map(application)
#expect(ApplicationSearch.results(for: "cl", in: applications).map(\.name) == [
"CL", "Cloud", "My cl Tool", "Cold", "Caramel",
#expect(ApplicationSearch.results(for: "gc", in: applications).map(\.name) == [
"GC", "GCompiler", "Tool GC Helper", "Google Chrome", "Great Console", "Agc Utility", "Logic",
])
#expect(ApplicationSearch.results(for: "zq", in: applications).isEmpty)
}
@Test func wordPrefixesIncludePartialPhrasesWithoutPromotingInteriorSubstrings() {
let applications = ["ADisk Utility", "Open Disk Utility", "Disk Utility"].map(application)
#expect(ApplicationSearch.results(for: "disk u", in: applications).map(\.name) == [
"Disk Utility", "Open Disk Utility", "ADisk Utility",
])
}
@Test func wordStartsHandlePunctuationDigitsAndAccentFolding() {
let cases = [
("ss", "System_Settings", "Mississippi"),
("2m", "Audio 2 MIDI", "A2M Tools"),
(" \n ÉN \t ", "Éditeur Numérique", "Aen Utility"),
("sys", "★ System Settings", "Asystem"),
]
for (query, name, interiorMatch) in cases {
let applications = [interiorMatch, name].map(application)
#expect(ApplicationSearch.results(for: query, in: applications).map(\.name) == [
name, interiorMatch,
])
}
}
@Test func wordInitialsMatchInOrderAndCanSkipWords() {
let applications = ["Vocal", "Avoid Chords", "Voice Control", "Visual Studio Code"].map(application)
#expect(ApplicationSearch.results(for: "vc", in: applications).map(\.name) == [
"Visual Studio Code", "Voice Control", "Vocal", "Avoid Chords",
])
#expect(ApplicationSearch.results(for: "vsc", in: applications).map(\.name) == [
"Visual Studio Code",
])
#expect(ApplicationSearch.results(for: "cv", in: applications).isEmpty)
}
@Test func searchFoldsAccentsAndTrimsWhitespace() {
let applications = ["Decafé", "Caféteria", "Café", "Camera"].map(application)