fix: match provider picker to action popup

This commit is contained in:
2026-10-18 00:05:46 +02:00
parent 11e604e79e
commit d829565188
5 changed files with 99 additions and 29 deletions
+2
View File
@@ -132,6 +132,8 @@ Notes autosave locally as RTFD packages, including embedded images, under `~/Lib
Search for **Conjure Translate**, identified by its centered 🌏 icon, and open it. Choose a provider in the pill-shaped header selector, enter text in the search bar, then press Return or click **Translate**. Typing alone never sends text. When the translation is available, the action changes to **Copy**: press Return or click it to copy the full translation and close Conjure. Command-C still copies selected output. Editing the input restores **Translate**.
The provider selector opens a searchable action-style popup below the header, with a checkmark beside the current provider. Use Up/Down and Return or click a provider to select it; Back or clicking outside closes the popup. Changing providers clears the previous result without submitting a request.
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. Back closes Actions first, then returns to the launcher and restores your search; Escape or Command-W 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.
@@ -15,7 +15,9 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
ActionEntry(title: "Copy Emoji", symbol: "doc.on.doc", color: SunsetPalette.text),
ActionEntry(title: "Change source language…", symbol: "character.bubble", color: SunsetPalette.text),
ActionEntry(title: "Change target language…", symbol: "character.bubble", color: SunsetPalette.text),
ActionEntry(title: "Swap languages", symbol: "arrow.left.arrow.right", color: SunsetPalette.text)
ActionEntry(title: "Swap languages", symbol: "arrow.left.arrow.right", color: SunsetPalette.text),
ActionEntry(title: "MyMemory (no key)", symbol: "checkmark", color: SunsetPalette.text),
ActionEntry(title: "OpenAI · gpt-6-luna", symbol: "checkmark", color: SunsetPalette.text)
]
private var visibleEntries: [Int] = []
private var availableEntries = Array(0..<6)
@@ -41,6 +43,8 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
var changeSourceLanguage: (() -> Void)?
var changeTargetLanguage: (() -> Void)?
var swapLanguages: (() -> Void)?
var selectProvider: ((Bool) -> Void)?
private var currentProviderEntry: Int?
var closed: (() -> Void)?
var isShown: Bool { viewIfLoaded?.superview != nil && !isClosing }
@@ -143,9 +147,22 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
searchAccessibilityLabel: "Search translation actions")
}
func showProviders(selectedOpenAI: Bool, relativeTo anchor: NSView) {
currentProviderEntry = selectedOpenAI ? 11 : 10
show(title: "Translation provider", relativeTo: anchor, available: [10, 11],
searchAccessibilityLabel: "Search translation providers", belowAnchor: true)
if isShown {
selectedEntry = currentProviderEntry
updateSelection()
}
}
private func show(title: String, relativeTo anchor: NSView, available: [Int], emoji: Bool = false,
searchAccessibilityLabel: String = "Search application actions") {
if isShown { close(); return }
searchAccessibilityLabel: String = "Search application actions", belowAnchor: Bool = false) {
if viewIfLoaded?.superview != nil {
if isShown, availableEntries == available { close(); return }
close(animated: false)
}
guard let window = anchor.window, let host = window.contentView else { return }
let pill = anchor.superview ?? anchor
_ = view
@@ -163,6 +180,13 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
])
favorite.button.setAccessibilityLabel(favoriteTitle)
favorite.icon.image = NSImage(systemSymbolName: isFavorite ? "star.fill" : "star", accessibilityDescription: nil)
for index in [10, 11] {
entries[index].icon.isHidden = currentProviderEntry != index
}
search.placeholderAttributedString = NSAttributedString(
string: belowAnchor ? "Search for providers…" : "Search for actions…",
attributes: [.foregroundColor: SunsetPalette.secondaryText,
.font: NSFont.systemFont(ofSize: LauncherRowView.textSize)])
search.stringValue = ""
controlTextDidChange(Notification(name: NSControl.textDidChangeNotification))
transitionGeneration += 1
@@ -182,16 +206,18 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
])
NSLayoutConstraint.activate([
view.trailingAnchor.constraint(equalTo: pill.trailingAnchor),
view.bottomAnchor.constraint(equalTo: pill.bottomAnchor)
belowAnchor
? view.topAnchor.constraint(equalTo: pill.bottomAnchor, constant: 8)
: view.bottomAnchor.constraint(equalTo: pill.bottomAnchor)
])
coveredPill = pill
coveredPill = belowAnchor ? nil : pill
coveredPillAlpha = pill.alphaValue
}
pill.setAccessibilityHidden(true)
coveredPill?.setAccessibilityHidden(true)
NSAnimationContext.runAnimationGroup { context in
context.duration = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion ? 0 : 0.16
view.animator().alphaValue = 1
pill.animator().alphaValue = 0
coveredPill?.animator().alphaValue = 0
}
window.makeFirstResponder(search)
(search.currentEditor() as? NSTextView)?.insertionPointColor = SunsetPalette.accent
@@ -271,7 +297,8 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
let selected = selectedEntry == index
entries[index].view.isSelected = selected
entries[index].returnKey.isHidden = !selected
entries[index].button.setAccessibilityValue(selected ? "Selected" : "Not selected")
let current = currentProviderEntry == index ? ", Current provider" : ""
entries[index].button.setAccessibilityValue((selected ? "Selected" : "Not selected") + current)
}
}
@@ -310,6 +337,8 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
case 7: changeSourceLanguage?()
case 8: changeTargetLanguage?()
case 9: swapLanguages?()
case 10: selectProvider?(false)
case 11: selectProvider?(true)
default: break
}
}
+27 -18
View File
@@ -4,7 +4,7 @@ import Security
@MainActor
final class TranslationController: NSViewController {
let providerPicker = NSPopUpButton()
private let providerButton = NSButton(title: "", target: nil, action: nil)
let providerControl = NSView()
private let languageButton = NSButton(title: "", target: nil, action: nil)
private let actionsButton = NSButton(title: "Actions", target: nil, action: nil)
@@ -21,7 +21,7 @@ final class TranslationController: NSViewController {
Locale(identifier: "en").localizedString(forLanguageCode: code) ?? code
}
private func languageName(_ code: String) -> String { Self.languageName(code) }
private var usesMyMemory: Bool { providerPicker.indexOfSelectedItem == 0 }
private var usesMyMemory: Bool
private let output = NSTextView()
private let settingsButton = NSButton(title: "Open Translate Settings", target: nil, action: nil)
private let status = NSTextField(wrappingLabelWithString: "")
@@ -52,6 +52,7 @@ final class TranslationController: NSViewController {
self.myMemory = myMemory
self.openAI = openAI
self.pasteboard = pasteboard
usesMyMemory = defaults.string(forKey: "translationProvider") != "openai"
sourceLanguage = defaults.string(forKey: "translationSourceLanguage")
.flatMap { Self.languages.contains($0) ? $0 : nil } ?? "en"
targetLanguage = defaults.string(forKey: "translationTargetLanguage")
@@ -63,30 +64,28 @@ final class TranslationController: NSViewController {
override func loadView() {
view = NSView()
providerPicker.addItems(withTitles: ["MyMemory (no key)", "OpenAI · gpt-6-luna"])
providerPicker.selectItem(at: defaults.string(forKey: "translationProvider") == "openai" ? 1 : 0)
providerPicker.setAccessibilityLabel("Translation provider")
providerPicker.target = self
providerPicker.action = #selector(providerChanged)
providerPicker.isBordered = false
(providerPicker.cell as? NSPopUpButtonCell)?.arrowPosition = .noArrow
providerPicker.font = .systemFont(ofSize: 12, weight: .medium)
providerPicker.contentTintColor = SunsetPalette.text
providerButton.title = usesMyMemory ? "MyMemory (no key)" : "OpenAI · gpt-6-luna"
providerButton.setAccessibilityLabel("Translation provider")
providerButton.target = self
providerButton.action = #selector(showProviders)
providerButton.isBordered = false
providerButton.font = .systemFont(ofSize: 12, weight: .medium)
providerButton.contentTintColor = SunsetPalette.text
let chevron = NSButton(image: NSImage(systemSymbolName: "chevron.down", accessibilityDescription: nil)!,
target: providerPicker, action: #selector(NSControl.performClick(_:)))
target: self, action: #selector(showProviders))
chevron.isBordered = false
chevron.contentTintColor = SunsetPalette.secondaryText
chevron.setAccessibilityElement(false)
stylePill(providerControl)
for control in [providerPicker, chevron] {
for control in [providerButton, chevron] {
control.translatesAutoresizingMaskIntoConstraints = false
providerControl.addSubview(control)
}
NSLayoutConstraint.activate([
providerControl.heightAnchor.constraint(equalToConstant: 36),
providerPicker.leadingAnchor.constraint(equalTo: providerControl.leadingAnchor, constant: 14),
providerPicker.centerYAnchor.constraint(equalTo: providerControl.centerYAnchor),
chevron.leadingAnchor.constraint(equalTo: providerPicker.trailingAnchor, constant: 8),
providerButton.leadingAnchor.constraint(equalTo: providerControl.leadingAnchor, constant: 14),
providerButton.centerYAnchor.constraint(equalTo: providerControl.centerYAnchor),
chevron.leadingAnchor.constraint(equalTo: providerButton.trailingAnchor, constant: 8),
chevron.centerYAnchor.constraint(equalTo: providerControl.centerYAnchor),
chevron.trailingAnchor.constraint(equalTo: providerControl.trailingAnchor, constant: -12),
chevron.widthAnchor.constraint(equalToConstant: 10),
@@ -112,6 +111,7 @@ final class TranslationController: NSViewController {
actions.changeSourceLanguage = { [weak self] in self?.chooseLanguage(source: true) }
actions.changeTargetLanguage = { [weak self] in self?.chooseLanguage(source: false) }
actions.swapLanguages = { [weak self] in self?.swapLanguages() }
actions.selectProvider = { [weak self] in self?.selectProvider(openAI: $0) }
settingsButton.target = self
settingsButton.action = #selector(openSettings)
settingsButton.isHidden = true
@@ -196,6 +196,11 @@ final class TranslationController: NSViewController {
pill.layer?.backgroundColor = SunsetPalette.background.withAlphaComponent(0.25).cgColor
}
@objc func showProviders() {
actions.showProviders(selectedOpenAI: !usesMyMemory, relativeTo: providerButton)
translateButton.keyEquivalent = actions.isShown ? "" : "\r"
}
@objc func showActions() {
actions.showTranslation(relativeTo: actionsButton)
translateButton.keyEquivalent = actions.isShown ? "" : "\r"
@@ -267,8 +272,12 @@ final class TranslationController: NSViewController {
setStatus("")
}
@objc private func providerChanged() {
defaults.set(usesMyMemory ? "mymemory" : "openai", forKey: "translationProvider")
func selectProvider(openAI: Bool) {
let newUsesMyMemory = !openAI
guard newUsesMyMemory != usesMyMemory else { return }
usesMyMemory = newUsesMyMemory
providerButton.title = usesMyMemory ? "MyMemory (no key)" : "OpenAI · gpt-6-luna"
defaults.set(openAI ? "openai" : "mymemory", forKey: "translationProvider")
inputChanged()
}
@@ -29,3 +29,35 @@ import Testing
actions.close(animated: false)
}
}
@Test @MainActor func providerPickerOpensBelowAnchorAndKeepsCurrentSelection() throws {
_ = NSApplication.shared
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 750, height: 480),
styleMask: .borderless, backing: .buffered, defer: false)
window.isReleasedWhenClosed = false
let host = try #require(window.contentView)
let pill = NSView(frame: NSRect(x: 500, y: 420, 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) }
var selections: [Bool] = []
popup.selectProvider = { selections.append($0) }
popup.showProviders(selectedOpenAI: true, relativeTo: anchor)
host.layoutSubtreeIfNeeded()
let rect = popup.view.convert(popup.view.bounds, to: host)
#expect(rect.maxY < pill.frame.minY)
#expect(rect.maxX == pill.frame.maxX)
#expect(pill.alphaValue == 1)
_ = popup.handle(#selector(NSResponder.insertNewline(_:)))
#expect(selections == [true])
// Reopen during the close animation, then switch modes without retaining the old anchor.
popup.showTranslation(relativeTo: anchor)
popup.showProviders(selectedOpenAI: false, relativeTo: anchor)
host.layoutSubtreeIfNeeded()
#expect(popup.view.convert(popup.view.bounds, to: host).maxY < pill.frame.minY)
popup.moveSelection(by: 1)
_ = popup.handle(#selector(NSResponder.insertNewline(_:)))
#expect(selections == [true, true])
}
@@ -185,7 +185,6 @@ func translationExecutionRejectsOldCompletionsWithoutClearingNewRequests() async
tool.input = { input }
let views = descendants(tool.view)
let output = try #require(views.compactMap { $0 as? NSTextView }.first)
let provider = tool.providerPicker
let button = try #require(views.compactMap { $0 as? NSButton }.first { $0.action == NSSelectorFromString("performPrimaryAction") })
tool.translate()
try await waitForTranslation { requests.pending.count == 1 }
@@ -200,8 +199,7 @@ func translationExecutionRejectsOldCompletionsWithoutClearingNewRequests() async
tool.translate()
#expect(requests.inputs.count == 2)
provider.selectItem(at: 1)
_ = tool.perform(provider.action, with: provider)
tool.selectProvider(openAI: true)
#expect(defaults.string(forKey: "translationProvider") == "openai")
tool.translate()
try await waitForTranslation { requests.pending.count == 3 }