feat: use searchable themed translation language picker
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user