feat: use searchable themed translation language picker
This commit is contained in:
@@ -2,7 +2,7 @@ import AppKit
|
||||
import ConjureKit
|
||||
|
||||
@MainActor
|
||||
final class ApplicationActionsController: NSViewController, NSSearchFieldDelegate {
|
||||
final class ApplicationActionsController: NSViewController, NSSearchFieldDelegate, NSTableViewDataSource, NSTableViewDelegate {
|
||||
private var search: ActionsSearchField!
|
||||
private var background: LauncherBackground!
|
||||
private var entries: [ActionEntry] = []
|
||||
@@ -32,6 +32,17 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
||||
var swapLanguages: (() -> Void)?
|
||||
var selectProvider: ((Bool) -> Void)?
|
||||
private var currentProviderEntry: Int?
|
||||
var selectLanguage: ((String) -> Void)?
|
||||
private struct LanguageChoice {
|
||||
let code: String
|
||||
let name: String
|
||||
let searchText: FuzzySearchText
|
||||
}
|
||||
private var languages: [LanguageChoice]?
|
||||
private var matchingLanguages: [LanguageChoice] = []
|
||||
private var currentLanguage: String?
|
||||
private var languageTable: NSTableView?
|
||||
private var languageScroll: NSScrollView?
|
||||
var closed: (() -> Void)?
|
||||
var isShown: Bool { viewIfLoaded?.superview != nil && !isClosing }
|
||||
|
||||
@@ -46,6 +57,8 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
||||
empty = NSTextField(labelWithString: "No matching actions")
|
||||
applicationLabel = NSTextField(labelWithString: "")
|
||||
rowTopConstraints = []
|
||||
languageTable = nil
|
||||
languageScroll = nil
|
||||
entries = [
|
||||
ActionEntry(title: "Open", symbol: "arrow.up.forward.app", color: SunsetPalette.text),
|
||||
ActionEntry(title: "Favorite", symbol: "star", color: SunsetPalette.text),
|
||||
@@ -141,6 +154,38 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
||||
search.heightAnchor.constraint(equalToConstant: 24)
|
||||
])
|
||||
content.layer?.borderColor = SunsetPalette.secondaryText.withAlphaComponent(0.22).cgColor
|
||||
if languages != nil {
|
||||
let table = NSTableView()
|
||||
table.addTableColumn(NSTableColumn(identifier: NSUserInterfaceItemIdentifier("Language")))
|
||||
table.headerView = nil
|
||||
table.backgroundColor = .clear
|
||||
table.selectionHighlightStyle = .none
|
||||
table.rowHeight = LauncherRowView.rowHeight
|
||||
table.intercellSpacing = .zero
|
||||
table.columnAutoresizingStyle = .lastColumnOnlyAutoresizingStyle
|
||||
table.autoresizingMask = [.width]
|
||||
table.focusRingType = .none
|
||||
table.dataSource = self
|
||||
table.delegate = self
|
||||
table.target = self
|
||||
table.action = #selector(chooseLanguageRow)
|
||||
table.setAccessibilityLabel("Translation languages")
|
||||
let scroll = NSScrollView()
|
||||
scroll.documentView = table
|
||||
scroll.hasVerticalScroller = true
|
||||
scroll.autohidesScrollers = true
|
||||
scroll.drawsBackground = false
|
||||
scroll.translatesAutoresizingMaskIntoConstraints = false
|
||||
content.addSubview(scroll)
|
||||
NSLayoutConstraint.activate([
|
||||
scroll.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 8),
|
||||
scroll.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -8),
|
||||
scroll.topAnchor.constraint(equalTo: applicationLabel.bottomAnchor, constant: 10),
|
||||
scroll.bottomAnchor.constraint(equalTo: search.topAnchor, constant: -8),
|
||||
])
|
||||
languageTable = table
|
||||
languageScroll = scroll
|
||||
}
|
||||
updateSelection()
|
||||
}
|
||||
|
||||
@@ -169,14 +214,24 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
||||
}
|
||||
}
|
||||
|
||||
func showLanguages(_ choices: [(code: String, name: String)], selected: String,
|
||||
source: Bool, relativeTo anchor: NSView) {
|
||||
currentLanguage = selected
|
||||
show(title: source ? "Source language" : "Target language", relativeTo: anchor, available: [],
|
||||
searchAccessibilityLabel: source ? "Search source languages" : "Search target languages",
|
||||
languageChoices: choices.map { LanguageChoice(code: $0.code, name: $0.name, searchText: FuzzySearchText($0.name)) })
|
||||
}
|
||||
|
||||
private func show(title: String, relativeTo anchor: NSView, available: [Int], emoji: Bool = false,
|
||||
searchAccessibilityLabel: String = "Search application actions", belowAnchor: Bool = false) {
|
||||
searchAccessibilityLabel: String = "Search application actions", belowAnchor: Bool = false,
|
||||
languageChoices: [LanguageChoice]? = nil) {
|
||||
if viewIfLoaded?.superview != nil {
|
||||
if isShown, availableEntries == available { close(); return }
|
||||
if isShown, availableEntries == available, applicationLabel.stringValue == title { close(); return }
|
||||
close(animated: false)
|
||||
}
|
||||
guard let window = anchor.window, let host = window.contentView else { return }
|
||||
let pill = anchor.superview ?? anchor
|
||||
languages = languageChoices
|
||||
// Reuse behavior, not native drawing state from a differently sized popup.
|
||||
if isViewLoaded { preparePresentation() } else { _ = view }
|
||||
applicationLabel.stringValue = title
|
||||
@@ -186,6 +241,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
||||
entries[0].button.setAccessibilityLabel(openTitle)
|
||||
entries[0].icon.image = NSImage(systemSymbolName: emoji ? "doc.on.clipboard" : "arrow.up.forward.app", accessibilityDescription: nil)
|
||||
search.setAccessibilityLabel(searchAccessibilityLabel)
|
||||
empty.stringValue = languages == nil ? "No matching actions" : "No matching languages"
|
||||
let favorite = entries[1]
|
||||
let favoriteTitle = isFavorite ? "Remove Favorite" : "Favorite"
|
||||
favorite.button.attributedTitle = NSAttributedString(string: favoriteTitle, attributes: [
|
||||
@@ -197,7 +253,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
||||
entries[index].icon.isHidden = currentProviderEntry != index
|
||||
}
|
||||
search.placeholderAttributedString = NSAttributedString(
|
||||
string: belowAnchor ? "Search for providers…" : "Search for actions…",
|
||||
string: languages != nil ? "Search for languages…" : belowAnchor ? "Search for providers…" : "Search for actions…",
|
||||
attributes: [.foregroundColor: SunsetPalette.secondaryText,
|
||||
.font: NSFont.systemFont(ofSize: LauncherRowView.textSize)])
|
||||
search.stringValue = ""
|
||||
@@ -224,6 +280,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
||||
coveredPill?.setAccessibilityHidden(true)
|
||||
host.layoutSubtreeIfNeeded()
|
||||
background.needsDisplay = true
|
||||
if let selectedEntry { languageTable?.scrollRowToVisible(selectedEntry) }
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion ? 0 : 0.16
|
||||
view.animator().alphaValue = 1
|
||||
@@ -281,6 +338,25 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
||||
|
||||
func controlTextDidChange(_ notification: Notification) {
|
||||
let query = search.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let languages, let table = languageTable {
|
||||
matchingLanguages = FuzzySearchText.results(for: query, in: languages, text: \.searchText) {
|
||||
$0.name.localizedStandardCompare($1.name) == .orderedAscending
|
||||
}
|
||||
let selection = (query.isEmpty ? matchingLanguages.firstIndex(where: { $0.code == currentLanguage }) : nil)
|
||||
?? (matchingLanguages.isEmpty ? nil : 0)
|
||||
for entry in entries { entry.view.isHidden = true }
|
||||
heightConstraint?.constant = 116 + CGFloat(max(min(matchingLanguages.count, 6), 1) - 1) * LauncherRowView.rowHeight
|
||||
empty.isHidden = !matchingLanguages.isEmpty
|
||||
languageScroll?.isHidden = matchingLanguages.isEmpty
|
||||
table.reloadData()
|
||||
selectedEntry = selection
|
||||
if let selectedEntry {
|
||||
table.selectRowIndexes(IndexSet(integer: selectedEntry), byExtendingSelection: false)
|
||||
table.scrollRowToVisible(selectedEntry)
|
||||
}
|
||||
updateSelection()
|
||||
return
|
||||
}
|
||||
visibleEntries = availableEntries.filter {
|
||||
query.isEmpty || entries[$0].button.title.range(of: query, options: [.caseInsensitive, .diacriticInsensitive]) != nil
|
||||
}
|
||||
@@ -296,6 +372,13 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
||||
}
|
||||
|
||||
func moveSelection(by offset: Int) {
|
||||
if let table = languageTable {
|
||||
guard !matchingLanguages.isEmpty else { return }
|
||||
let next = min(max((selectedEntry ?? 0) + offset, 0), matchingLanguages.count - 1)
|
||||
table.selectRowIndexes(IndexSet(integer: next), byExtendingSelection: false)
|
||||
table.scrollRowToVisible(next)
|
||||
return
|
||||
}
|
||||
guard !visibleEntries.isEmpty else { return }
|
||||
let current = selectedEntry.flatMap { visibleEntries.firstIndex(of: $0) } ?? 0
|
||||
selectedEntry = visibleEntries[min(max(current + offset, 0), visibleEntries.count - 1)]
|
||||
@@ -303,6 +386,16 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
||||
}
|
||||
|
||||
private func updateSelection() {
|
||||
if let table = languageTable {
|
||||
let visible = table.rows(in: table.visibleRect)
|
||||
for row in visible.location..<NSMaxRange(visible) {
|
||||
if let cell = table.view(atColumn: 0, row: row, makeIfNecessary: false) as? LanguageCell {
|
||||
cell.entry.view.isSelected = selectedEntry == row
|
||||
cell.entry.returnKey.isHidden = selectedEntry != row
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
for index in entries.indices {
|
||||
let selected = selectedEntry == index
|
||||
entries[index].view.isSelected = selected
|
||||
@@ -330,6 +423,35 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
||||
return true
|
||||
}
|
||||
|
||||
func numberOfRows(in tableView: NSTableView) -> Int { matchingLanguages.count }
|
||||
|
||||
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
|
||||
let identifier = NSUserInterfaceItemIdentifier("LanguageCell")
|
||||
let cell = tableView.makeView(withIdentifier: identifier, owner: self) as? LanguageCell ?? LanguageCell()
|
||||
cell.identifier = identifier
|
||||
let choice = matchingLanguages[row]
|
||||
cell.entry.button.title = choice.name
|
||||
cell.entry.button.setAccessibilityLabel(choice.name)
|
||||
cell.entry.button.setAccessibilityValue(choice.code == currentLanguage ? "Current language" : "")
|
||||
cell.entry.button.target = self
|
||||
cell.entry.button.action = #selector(chooseButton(_:))
|
||||
cell.entry.button.tag = row
|
||||
cell.entry.icon.isHidden = choice.code != currentLanguage
|
||||
cell.entry.view.isSelected = selectedEntry == row
|
||||
cell.entry.returnKey.isHidden = selectedEntry != row
|
||||
return cell
|
||||
}
|
||||
|
||||
func tableViewSelectionDidChange(_ notification: Notification) {
|
||||
guard let table = languageTable else { return }
|
||||
selectedEntry = table.selectedRow >= 0 ? table.selectedRow : nil
|
||||
updateSelection()
|
||||
}
|
||||
|
||||
@objc private func chooseLanguageRow() {
|
||||
if let selectedEntry { chooseAction(at: selectedEntry) }
|
||||
}
|
||||
|
||||
@objc private func chooseButton(_ sender: NSButton) { chooseAction(at: sender.tag) }
|
||||
|
||||
@objc private func chooseRow(_ sender: NSGestureRecognizer) {
|
||||
@@ -337,7 +459,15 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
||||
}
|
||||
|
||||
private func chooseAction(at index: Int) {
|
||||
guard isShown, visibleEntries.contains(index) else { return }
|
||||
guard isShown else { return }
|
||||
if languages != nil {
|
||||
guard matchingLanguages.indices.contains(index) else { return }
|
||||
let code = matchingLanguages[index].code
|
||||
close()
|
||||
selectLanguage?(code)
|
||||
return
|
||||
}
|
||||
guard visibleEntries.contains(index) else { return }
|
||||
close()
|
||||
switch index {
|
||||
case 0: open?()
|
||||
@@ -396,6 +526,25 @@ private struct ActionEntry {
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class LanguageCell: NSView {
|
||||
let entry = ActionEntry(title: "", symbol: "checkmark", color: SunsetPalette.text)
|
||||
|
||||
init() {
|
||||
super.init(frame: .zero)
|
||||
entry.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(entry.view)
|
||||
NSLayoutConstraint.activate([
|
||||
entry.view.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
entry.view.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
entry.view.topAnchor.constraint(equalTo: topAnchor),
|
||||
entry.view.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("Conjure creates language rows programmatically") }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class ActionsPanelView: NSView {
|
||||
var acceptsInteraction = true
|
||||
|
||||
@@ -220,23 +220,15 @@ final class TranslationController: NSViewController {
|
||||
}
|
||||
|
||||
private func chooseLanguage(source: Bool) {
|
||||
let menu = NSMenu(title: source ? "Source language" : "Target language")
|
||||
for code in Self.languages {
|
||||
let item = NSMenuItem(title: languageName(code), action: #selector(languageSelected(_:)), keyEquivalent: "")
|
||||
item.target = self
|
||||
item.representedObject = code
|
||||
item.tag = source ? 0 : 1
|
||||
item.state = code == (source ? sourceLanguage : targetLanguage) ? .on : .off
|
||||
menu.addItem(item)
|
||||
actions.selectLanguage = { [weak self] code in
|
||||
guard let self else { return }
|
||||
self.setLanguages(source: source ? code : self.sourceLanguage,
|
||||
target: source ? self.targetLanguage : code)
|
||||
}
|
||||
menu.popUp(positioning: menu.items.first { $0.state == .on },
|
||||
at: NSPoint(x: 0, y: languageButton.bounds.maxY), in: languageButton)
|
||||
}
|
||||
|
||||
@objc private func languageSelected(_ item: NSMenuItem) {
|
||||
guard let code = item.representedObject as? String else { return }
|
||||
setLanguages(source: item.tag == 0 ? code : sourceLanguage,
|
||||
target: item.tag == 1 ? code : targetLanguage)
|
||||
actions.showLanguages(Self.languages.map { (code: $0, name: languageName($0)) },
|
||||
selected: source ? sourceLanguage : targetLanguage,
|
||||
source: source, relativeTo: actionsButton)
|
||||
translateButton.keyEquivalent = actions.isShown ? "" : "\r"
|
||||
}
|
||||
|
||||
func setLanguages(source: String, target: String) {
|
||||
|
||||
@@ -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