feat: use searchable themed translation language picker
This commit is contained in:
@@ -11,9 +11,7 @@ public struct Application: Identifiable, Sendable {
|
||||
public var id: URL { url }
|
||||
public let url: URL
|
||||
public let name: String
|
||||
fileprivate let searchableName: String
|
||||
fileprivate let searchableCharacters: [Character]
|
||||
fileprivate let searchableWordStarts: [Int]
|
||||
fileprivate let searchableText: FuzzySearchText
|
||||
|
||||
public init(url: URL, name: String) {
|
||||
self.init(canonicalURL: url.standardizedFileURL.resolvingSymlinksInPath(), name: name)
|
||||
@@ -33,17 +31,7 @@ public struct Application: Identifiable, Sendable {
|
||||
url = command != nil || canonicalURL.hasDirectoryPath
|
||||
? canonicalURL : URL(fileURLWithPath: canonicalURL.path, isDirectory: true)
|
||||
self.name = name
|
||||
searchableName = folded(name)
|
||||
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
|
||||
searchableText = FuzzySearchText(name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,83 +102,17 @@ public enum ApplicationSearch {
|
||||
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: preferred) }
|
||||
let characters = Array(query)
|
||||
var ranked = applications.compactMap { application -> (Application, Rank)? in
|
||||
let name = application.searchableName
|
||||
if name == query { return (application, Rank(category: 0)) }
|
||||
if name.hasPrefix(query) { return (application, Rank(category: 1)) }
|
||||
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)
|
||||
}
|
||||
ranked.sort { lhs, rhs in
|
||||
if lhs.1 != rhs.1 { return lhs.1 < rhs.1 }
|
||||
return preferred(lhs.0, rhs.0)
|
||||
}
|
||||
return ranked.map(\.0)
|
||||
}
|
||||
|
||||
private struct Rank: Comparable {
|
||||
let category: Int
|
||||
var gaps: Int = 0
|
||||
var start: Int = 0
|
||||
|
||||
static func < (lhs: Rank, rhs: Rank) -> Bool {
|
||||
(lhs.category, lhs.gaps, lhs.start) < (rhs.category, rhs.gaps, rhs.start)
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
var best: Rank?
|
||||
for start in name.indices where name[start] == query[0] {
|
||||
var matched = 1
|
||||
var end = start
|
||||
while matched < query.count && end + 1 < name.count {
|
||||
end += 1
|
||||
if name[end] == query[matched] { matched += 1 }
|
||||
}
|
||||
guard matched == query.count else { continue }
|
||||
let rank = Rank(category: 5, gaps: end - start + 1 - query.count, start: start)
|
||||
if let best, best <= rank { continue }
|
||||
best = rank
|
||||
}
|
||||
return best
|
||||
FuzzySearchText.results(
|
||||
for: query, in: applications, text: { $0.searchableText }, breakingTiesWith: preferred
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private let searchLocale = Locale(identifier: "en_US_POSIX")
|
||||
|
||||
private func nonempty(_ name: String?) -> String? {
|
||||
guard let name, !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil }
|
||||
return name
|
||||
}
|
||||
|
||||
private func folded(_ string: String) -> String {
|
||||
string.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: searchLocale)
|
||||
}
|
||||
|
||||
func alphabetical(_ lhs: Application, _ rhs: Application) -> Bool {
|
||||
let order = lhs.name.localizedStandardCompare(rhs.name)
|
||||
if order != .orderedSame { return order == .orderedAscending }
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import Foundation
|
||||
|
||||
public struct FuzzySearchText: Sendable {
|
||||
private let foldedText: String
|
||||
private let characters: [Character]
|
||||
private let wordStarts: [Int]
|
||||
|
||||
public init(_ text: String) {
|
||||
foldedText = Self.folded(text)
|
||||
let characters = Array(foldedText)
|
||||
self.characters = 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
|
||||
}
|
||||
wordStarts = starts
|
||||
}
|
||||
|
||||
public static func results<Value>(
|
||||
for query: String, in values: [Value],
|
||||
text: (Value) -> FuzzySearchText,
|
||||
breakingTiesWith preferred: (Value, Value) -> Bool
|
||||
) -> [Value] {
|
||||
let query = folded(query.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
guard !query.isEmpty else { return values.sorted(by: preferred) }
|
||||
let characters = Array(query)
|
||||
var ranked = values.compactMap { value -> (Value, Rank)? in
|
||||
let searchable = text(value)
|
||||
let name = searchable.foldedText
|
||||
if name == query { return (value, Rank(category: 0)) }
|
||||
if name.hasPrefix(query) { return (value, Rank(category: 1)) }
|
||||
if searchable.wordStarts.contains(where: {
|
||||
$0 > 0 && searchable.characters[$0...].starts(with: characters)
|
||||
}) {
|
||||
return (value, Rank(category: 2))
|
||||
}
|
||||
if matchesWordInitials(characters, in: searchable) {
|
||||
return (value, Rank(category: 3))
|
||||
}
|
||||
if name.contains(query) { return (value, Rank(category: 4)) }
|
||||
guard let rank = fuzzyRank(characters, in: searchable.characters) else { return nil }
|
||||
return (value, rank)
|
||||
}
|
||||
ranked.sort { lhs, rhs in
|
||||
if lhs.1 != rhs.1 { return lhs.1 < rhs.1 }
|
||||
return preferred(lhs.0, rhs.0)
|
||||
}
|
||||
return ranked.map(\.0)
|
||||
}
|
||||
|
||||
private struct Rank: Comparable {
|
||||
let category: Int
|
||||
var gaps: Int = 0
|
||||
var start: Int = 0
|
||||
|
||||
static func < (lhs: Rank, rhs: Rank) -> Bool {
|
||||
(lhs.category, lhs.gaps, lhs.start) < (rhs.category, rhs.gaps, rhs.start)
|
||||
}
|
||||
}
|
||||
|
||||
private static func matchesWordInitials(_ query: [Character], in text: FuzzySearchText) -> Bool {
|
||||
var matched = 0
|
||||
for start in text.wordStarts {
|
||||
guard text.characters[start] == query[matched] else { continue }
|
||||
matched += 1
|
||||
if matched == query.count { return true }
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ponytail: quadratic in text length; use a linear matcher if long text becomes searchable.
|
||||
private static func fuzzyRank(_ query: [Character], in name: [Character]) -> Rank? {
|
||||
guard query.count <= name.count else { return nil }
|
||||
var best: Rank?
|
||||
for start in name.indices where name[start] == query[0] {
|
||||
var matched = 1
|
||||
var end = start
|
||||
while matched < query.count && end + 1 < name.count {
|
||||
end += 1
|
||||
if name[end] == query[matched] { matched += 1 }
|
||||
}
|
||||
guard matched == query.count else { continue }
|
||||
let rank = Rank(category: 5, gaps: end - start + 1 - query.count, start: start)
|
||||
if let best, best <= rank { continue }
|
||||
best = rank
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
private static let searchLocale = Locale(identifier: "en_US_POSIX")
|
||||
|
||||
private static func folded(_ string: String) -> String {
|
||||
string.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: searchLocale)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user