feat: prioritize word starts and app acronyms in search
This commit is contained in:
@@ -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.
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ public struct Application: Identifiable, Sendable {
|
|||||||
public let name: String
|
public let name: String
|
||||||
fileprivate let searchableName: String
|
fileprivate let searchableName: String
|
||||||
fileprivate let searchableCharacters: [Character]
|
fileprivate let searchableCharacters: [Character]
|
||||||
|
fileprivate let searchableWordStarts: [Int]
|
||||||
|
|
||||||
public init(url: URL, name: String) {
|
public init(url: URL, name: String) {
|
||||||
self.init(canonicalURL: url.standardizedFileURL.resolvingSymlinksInPath(), name: name)
|
self.init(canonicalURL: url.standardizedFileURL.resolvingSymlinksInPath(), name: name)
|
||||||
@@ -16,7 +17,16 @@ public struct Application: Identifiable, Sendable {
|
|||||||
? canonicalURL : URL(fileURLWithPath: canonicalURL.path, isDirectory: true)
|
? canonicalURL : URL(fileURLWithPath: canonicalURL.path, isDirectory: true)
|
||||||
self.name = name
|
self.name = name
|
||||||
searchableName = folded(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
|
let name = application.searchableName
|
||||||
if name == query { return (application, Rank(category: 0)) }
|
if name == query { return (application, Rank(category: 0)) }
|
||||||
if name.hasPrefix(query) { return (application, Rank(category: 1)) }
|
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 }
|
guard let rank = fuzzyRank(characters, in: application.searchableCharacters) else { return nil }
|
||||||
return (application, rank)
|
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.
|
// 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? {
|
private static func fuzzyRank(_ query: [Character], in name: [Character]) -> Rank? {
|
||||||
guard query.count <= name.count else { return nil }
|
guard query.count <= name.count else { return nil }
|
||||||
@@ -119,7 +147,7 @@ public enum ApplicationSearch {
|
|||||||
if name[end] == query[matched] { matched += 1 }
|
if name[end] == query[matched] { matched += 1 }
|
||||||
}
|
}
|
||||||
guard matched == query.count else { continue }
|
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 }
|
if let best, best <= rank { continue }
|
||||||
best = rank
|
best = rank
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,16 +2,51 @@ import ConjureKit
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import Testing
|
||||||
|
|
||||||
@Test func searchRanksExactPrefixSubstringAndFuzzyMatches() {
|
@Test func searchRanksExactPrefixWordPrefixInitialsSubstringAndFuzzyMatches() {
|
||||||
let names = ["Caramel", "My cl Tool", "Cloud", "Cold", "CL", "Unrelated"]
|
let names = ["Logic", "Agc Utility", "Great Console", "Google Chrome", "Tool GC Helper", "GCompiler", "GC", "Unrelated"]
|
||||||
let applications = names.map(application)
|
let applications = names.map(application)
|
||||||
|
|
||||||
#expect(ApplicationSearch.results(for: "cl", in: applications).map(\.name) == [
|
#expect(ApplicationSearch.results(for: "gc", in: applications).map(\.name) == [
|
||||||
"CL", "Cloud", "My cl Tool", "Cold", "Caramel",
|
"GC", "GCompiler", "Tool GC Helper", "Google Chrome", "Great Console", "Agc Utility", "Logic",
|
||||||
])
|
])
|
||||||
#expect(ApplicationSearch.results(for: "zq", in: applications).isEmpty)
|
#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() {
|
@Test func searchFoldsAccentsAndTrimsWhitespace() {
|
||||||
let applications = ["Decafé", "Caféteria", "Café", "Camera"].map(application)
|
let applications = ["Decafé", "Caféteria", "Café", "Camera"].map(application)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user