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
@@ -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