feat: use searchable themed translation language picker

This commit is contained in:
2026-10-18 18:00:04 +02:00
parent cd47828ddb
commit fa3591adf8
6 changed files with 370 additions and 105 deletions
+1 -1
View File
@@ -148,7 +148,7 @@ The footer places the current primary action and its Return keycap before **Acti
Press Command-P or click the provider selector to expand a searchable action-style popup from its top edge downwards, hiding the indicator until the popup closes. A checkmark marks the current provider. Use Up/Down and Return or click a provider to select it; Escape, Back, or clicking outside closes the popup. Changing providers clears the previous result without submitting a request. Switching away from OpenAI during the context step restores the original source text; context is not sent to MyMemory. Press Command-P or click the provider selector to expand a searchable action-style popup from its top edge downwards, hiding the indicator until the popup closes. A checkmark marks the current provider. Use Up/Down and Return or click a provider to select it; Escape, Back, or clicking outside closes the popup. Changing providers clears the previous result without submitting a request. Switching away from OpenAI during the context step restores the original source text; context is not sent to MyMemory.
The footer shows the current language pair (initially **English → Japanese**). Click it or open **Actions** with Command-K to change the source language, change the target language, or swap them. Language choices are saved locally and apply to both providers. Changing languages clears the previous result without submitting another request. The input hint and output accessibility label follow the selected pair. Escape closes an open action/provider picker first and dismisses the window only when no picker is open. Back closes the picker first, then returns from the context step to editing the original text (discarding context), then returns to the launcher and restores your search. Command-W always dismisses the entire window. The footer shows the current language pair (initially **English → Japanese**). Click it or open **Actions** with Command-K to change the source language, change the target language, or swap them. Source and target selection use Conjure's themed popup, not a native menu. Fuzzy-search language names (for example, `jpns` matches **Japanese**), scroll the full list, and select with Up/Down, Control-J/K, and Return or by clicking a language. A checkmark marks the current language; it is selected and scrolled into view when the picker opens. Escape, Back, Command-K, or clicking outside cancels the picker without changing languages or leaving Translate. Language choices are saved locally and apply to both providers. Changing languages clears the previous result without submitting another request. The input hint and output accessibility label follow the selected pair. Escape closes an open picker first and dismisses the window only when no picker is open. Back closes the picker first, then returns from the context step to editing the original text (discarding context), then returns to the launcher and restores your search. Command-W always dismisses the entire window.
Editing the input, changing languages or providers, saving or forgetting the key in Settings, or leaving Translate cancels the active request; cancelled results are not shown. After a request fails, submit again to retry. Editing the input, changing languages or providers, saving or forgetting the key in Settings, or leaving Translate cancels the active request; cancelled results are not shown. After a request fails, submit again to retry.
@@ -2,7 +2,7 @@ import AppKit
import ConjureKit import ConjureKit
@MainActor @MainActor
final class ApplicationActionsController: NSViewController, NSSearchFieldDelegate { final class ApplicationActionsController: NSViewController, NSSearchFieldDelegate, NSTableViewDataSource, NSTableViewDelegate {
private var search: ActionsSearchField! private var search: ActionsSearchField!
private var background: LauncherBackground! private var background: LauncherBackground!
private var entries: [ActionEntry] = [] private var entries: [ActionEntry] = []
@@ -32,6 +32,17 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
var swapLanguages: (() -> Void)? var swapLanguages: (() -> Void)?
var selectProvider: ((Bool) -> Void)? var selectProvider: ((Bool) -> Void)?
private var currentProviderEntry: Int? 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 closed: (() -> Void)?
var isShown: Bool { viewIfLoaded?.superview != nil && !isClosing } var isShown: Bool { viewIfLoaded?.superview != nil && !isClosing }
@@ -46,6 +57,8 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
empty = NSTextField(labelWithString: "No matching actions") empty = NSTextField(labelWithString: "No matching actions")
applicationLabel = NSTextField(labelWithString: "") applicationLabel = NSTextField(labelWithString: "")
rowTopConstraints = [] rowTopConstraints = []
languageTable = nil
languageScroll = nil
entries = [ entries = [
ActionEntry(title: "Open", symbol: "arrow.up.forward.app", color: SunsetPalette.text), ActionEntry(title: "Open", symbol: "arrow.up.forward.app", color: SunsetPalette.text),
ActionEntry(title: "Favorite", symbol: "star", 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) search.heightAnchor.constraint(equalToConstant: 24)
]) ])
content.layer?.borderColor = SunsetPalette.secondaryText.withAlphaComponent(0.22).cgColor 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() 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, 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 viewIfLoaded?.superview != nil {
if isShown, availableEntries == available { close(); return } if isShown, availableEntries == available, applicationLabel.stringValue == title { close(); return }
close(animated: false) close(animated: false)
} }
guard let window = anchor.window, let host = window.contentView else { return } guard let window = anchor.window, let host = window.contentView else { return }
let pill = anchor.superview ?? anchor let pill = anchor.superview ?? anchor
languages = languageChoices
// Reuse behavior, not native drawing state from a differently sized popup. // Reuse behavior, not native drawing state from a differently sized popup.
if isViewLoaded { preparePresentation() } else { _ = view } if isViewLoaded { preparePresentation() } else { _ = view }
applicationLabel.stringValue = title applicationLabel.stringValue = title
@@ -186,6 +241,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
entries[0].button.setAccessibilityLabel(openTitle) entries[0].button.setAccessibilityLabel(openTitle)
entries[0].icon.image = NSImage(systemSymbolName: emoji ? "doc.on.clipboard" : "arrow.up.forward.app", accessibilityDescription: nil) entries[0].icon.image = NSImage(systemSymbolName: emoji ? "doc.on.clipboard" : "arrow.up.forward.app", accessibilityDescription: nil)
search.setAccessibilityLabel(searchAccessibilityLabel) search.setAccessibilityLabel(searchAccessibilityLabel)
empty.stringValue = languages == nil ? "No matching actions" : "No matching languages"
let favorite = entries[1] let favorite = entries[1]
let favoriteTitle = isFavorite ? "Remove Favorite" : "Favorite" let favoriteTitle = isFavorite ? "Remove Favorite" : "Favorite"
favorite.button.attributedTitle = NSAttributedString(string: favoriteTitle, attributes: [ favorite.button.attributedTitle = NSAttributedString(string: favoriteTitle, attributes: [
@@ -197,7 +253,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
entries[index].icon.isHidden = currentProviderEntry != index entries[index].icon.isHidden = currentProviderEntry != index
} }
search.placeholderAttributedString = NSAttributedString( 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, attributes: [.foregroundColor: SunsetPalette.secondaryText,
.font: NSFont.systemFont(ofSize: LauncherRowView.textSize)]) .font: NSFont.systemFont(ofSize: LauncherRowView.textSize)])
search.stringValue = "" search.stringValue = ""
@@ -224,6 +280,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
coveredPill?.setAccessibilityHidden(true) coveredPill?.setAccessibilityHidden(true)
host.layoutSubtreeIfNeeded() host.layoutSubtreeIfNeeded()
background.needsDisplay = true background.needsDisplay = true
if let selectedEntry { languageTable?.scrollRowToVisible(selectedEntry) }
NSAnimationContext.runAnimationGroup { context in NSAnimationContext.runAnimationGroup { context in
context.duration = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion ? 0 : 0.16 context.duration = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion ? 0 : 0.16
view.animator().alphaValue = 1 view.animator().alphaValue = 1
@@ -281,6 +338,25 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
func controlTextDidChange(_ notification: Notification) { func controlTextDidChange(_ notification: Notification) {
let query = search.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) 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 { visibleEntries = availableEntries.filter {
query.isEmpty || entries[$0].button.title.range(of: query, options: [.caseInsensitive, .diacriticInsensitive]) != nil 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) { 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 } guard !visibleEntries.isEmpty else { return }
let current = selectedEntry.flatMap { visibleEntries.firstIndex(of: $0) } ?? 0 let current = selectedEntry.flatMap { visibleEntries.firstIndex(of: $0) } ?? 0
selectedEntry = visibleEntries[min(max(current + offset, 0), visibleEntries.count - 1)] selectedEntry = visibleEntries[min(max(current + offset, 0), visibleEntries.count - 1)]
@@ -303,6 +386,16 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
} }
private func updateSelection() { 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 { for index in entries.indices {
let selected = selectedEntry == index let selected = selectedEntry == index
entries[index].view.isSelected = selected entries[index].view.isSelected = selected
@@ -330,6 +423,35 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
return true 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 chooseButton(_ sender: NSButton) { chooseAction(at: sender.tag) }
@objc private func chooseRow(_ sender: NSGestureRecognizer) { @objc private func chooseRow(_ sender: NSGestureRecognizer) {
@@ -337,7 +459,15 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
} }
private func chooseAction(at index: Int) { 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() close()
switch index { switch index {
case 0: open?() 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 @MainActor
private final class ActionsPanelView: NSView { private final class ActionsPanelView: NSView {
var acceptsInteraction = true var acceptsInteraction = true
+8 -16
View File
@@ -220,23 +220,15 @@ final class TranslationController: NSViewController {
} }
private func chooseLanguage(source: Bool) { private func chooseLanguage(source: Bool) {
let menu = NSMenu(title: source ? "Source language" : "Target language") actions.selectLanguage = { [weak self] code in
for code in Self.languages { guard let self else { return }
let item = NSMenuItem(title: languageName(code), action: #selector(languageSelected(_:)), keyEquivalent: "") self.setLanguages(source: source ? code : self.sourceLanguage,
item.target = self target: source ? self.targetLanguage : code)
item.representedObject = code
item.tag = source ? 0 : 1
item.state = code == (source ? sourceLanguage : targetLanguage) ? .on : .off
menu.addItem(item)
} }
menu.popUp(positioning: menu.items.first { $0.state == .on }, actions.showLanguages(Self.languages.map { (code: $0, name: languageName($0)) },
at: NSPoint(x: 0, y: languageButton.bounds.maxY), in: languageButton) selected: source ? sourceLanguage : targetLanguage,
} source: source, relativeTo: actionsButton)
translateButton.keyEquivalent = actions.isShown ? "" : "\r"
@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)
} }
func setLanguages(source: String, target: String) { func setLanguages(source: String, target: String) {
+5 -83
View File
@@ -11,9 +11,7 @@ public struct Application: Identifiable, Sendable {
public var id: URL { url } public var id: URL { url }
public let url: URL public let url: URL
public let name: String public let name: String
fileprivate let searchableName: String fileprivate let searchableText: FuzzySearchText
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)
@@ -33,17 +31,7 @@ public struct Application: Identifiable, Sendable {
url = command != nil || canonicalURL.hasDirectoryPath url = command != nil || canonicalURL.hasDirectoryPath
? canonicalURL : URL(fileURLWithPath: canonicalURL.path, isDirectory: true) ? canonicalURL : URL(fileURLWithPath: canonicalURL.path, isDirectory: true)
self.name = name self.name = name
searchableName = folded(name) searchableText = FuzzySearchText(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
} }
} }
@@ -114,83 +102,17 @@ public enum ApplicationSearch {
for query: String, in applications: [Application], for query: String, in applications: [Application],
breakingTiesWith preferred: (Application, Application) -> Bool breakingTiesWith preferred: (Application, Application) -> Bool
) -> [Application] { ) -> [Application] {
let query = folded(query.trimmingCharacters(in: .whitespacesAndNewlines)) FuzzySearchText.results(
guard !query.isEmpty else { return applications.sorted(by: preferred) } for: query, in: applications, text: { $0.searchableText }, breakingTiesWith: 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
} }
} }
private let searchLocale = Locale(identifier: "en_US_POSIX")
private func nonempty(_ name: String?) -> String? { private func nonempty(_ name: String?) -> String? {
guard let name, !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil } guard let name, !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil }
return name return name
} }
private func folded(_ string: String) -> String {
string.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: searchLocale)
}
func alphabetical(_ lhs: Application, _ rhs: Application) -> Bool { func alphabetical(_ lhs: Application, _ rhs: Application) -> Bool {
let order = lhs.name.localizedStandardCompare(rhs.name) let order = lhs.name.localizedStandardCompare(rhs.name)
if order != .orderedSame { return order == .orderedAscending } if order != .orderedSame { return order == .orderedAscending }
+98
View File
@@ -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)
}
}
@@ -0,0 +1,104 @@
import AppKit
import Testing
@testable import Conjure
@MainActor
private func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
@Suite(.serialized)
struct LanguagePickerTests {
@Test @MainActor
func translationActionsFuzzySelectBothLanguagesAndKeepCancellationLocal() throws {
_ = NSApplication.shared
let suite = "LanguagePicker.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
let tool = TranslationController(defaults: defaults,
myMemory: { _, _, _ in Issue.record("Selecting a language must not translate"); return "unexpected" })
tool.input = { "Text stays unchanged" }
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 750, height: 474),
styleMask: .borderless, backing: .buffered, defer: false)
window.isReleasedWhenClosed = false
window.contentView = tool.view
defer { tool.close(); window.orderOut(nil) }
let host = try #require(window.contentView)
var closed = false
tool.closed = { closed = true }
var focused = false
tool.focusSearch = { focused = true }
func search(_ label: String) throws -> NSSearchField {
try #require(descendants(host).compactMap { $0 as? NSSearchField }.first { $0.accessibilityLabel() == label })
}
func filter(_ field: NSSearchField, _ query: String) {
field.stringValue = query
field.delegate?.controlTextDidChange?(Notification(name: NSControl.textDidChangeNotification, object: field))
host.layoutSubtreeIfNeeded()
}
func openLanguages(source: Bool) throws -> NSSearchField {
tool.showActions()
filter(try search("Search translation actions"), source ? "source" : "target")
#expect(tool.handle(#selector(NSResponder.insertNewline(_:))))
return try search(source ? "Search source languages" : "Search target languages")
}
let source = try openLanguages(source: true)
filter(source, "jpns")
#expect(tool.handle(#selector(NSResponder.insertNewline(_:))))
#expect(tool.sourceLanguage == "ja")
#expect(tool.targetLanguage == "ja")
#expect(focused)
#expect(!closed)
let target = try openLanguages(source: false)
filter(target, "grmn")
let german = try #require(descendants(host).compactMap { $0 as? NSButton }.first { $0.title == "German" })
german.performClick(nil)
#expect(tool.targetLanguage == "de")
#expect(defaults.string(forKey: "translationSourceLanguage") == "ja")
#expect(defaults.string(forKey: "translationTargetLanguage") == "de")
let cancelled = try openLanguages(source: false)
filter(cancelled, "zzzzzzzz")
#expect(tool.handle(#selector(NSResponder.insertNewline(_:))))
#expect(tool.targetLanguage == "de")
#expect(cancelled.window === window)
#expect(tool.handle(#selector(NSResponder.cancelOperation(_:))))
#expect(!closed)
#expect(tool.input?() == "Text stays unchanged")
let reopened = TranslationController(defaults: defaults)
#expect(reopened.sourceLanguage == "ja")
#expect(reopened.targetLanguage == "de")
}
@Test @MainActor
func languageListScrollsToCurrentSelectionAndNavigatesBeyondVisibleRows() throws {
_ = NSApplication.shared
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 750, height: 474),
styleMask: .borderless, backing: .buffered, defer: false)
window.isReleasedWhenClosed = false
let host = try #require(window.contentView)
let pill = NSView(frame: NSRect(x: 500, y: 8, width: 230, height: 36))
let anchor = NSButton(frame: pill.bounds)
pill.addSubview(anchor)
host.addSubview(pill)
let popup = ApplicationActionsController()
defer { popup.close(animated: false); window.orderOut(nil) }
let choices = (0..<20).map { (code: "\($0)", name: String(format: "Language %02d", $0)) }
var selected: String?
popup.selectLanguage = { selected = $0 }
popup.showLanguages(choices, selected: "15", source: true, relativeTo: anchor)
host.layoutSubtreeIfNeeded()
let table = try #require(descendants(popup.view).compactMap { $0 as? NSTableView }.first)
#expect(table.selectedRow == 15)
#expect(NSLocationInRange(table.selectedRow, table.rows(in: table.visibleRect)))
#expect(table.visibleRect.height < table.bounds.height)
popup.moveSelection(by: -10)
#expect(table.selectedRow == 5)
#expect(NSLocationInRange(table.selectedRow, table.rows(in: table.visibleRect)))
#expect(popup.handle(#selector(NSResponder.insertNewline(_:))))
#expect(selected == "5")
popup.close(animated: false)
#expect(!pill.isAccessibilityHidden())
}
}