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**. 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. 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. 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: "Copy Emoji", symbol: "doc.on.doc", color: SunsetPalette.text),
ActionEntry(title: "Change source language…", symbol: "character.bubble", 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: "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 visibleEntries: [Int] = []
private var availableEntries = Array(0..<6) private var availableEntries = Array(0..<6)
@@ -41,6 +43,8 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
var changeSourceLanguage: (() -> Void)? var changeSourceLanguage: (() -> Void)?
var changeTargetLanguage: (() -> Void)? var changeTargetLanguage: (() -> Void)?
var swapLanguages: (() -> Void)? var swapLanguages: (() -> Void)?
var selectProvider: ((Bool) -> Void)?
private var currentProviderEntry: Int?
var closed: (() -> Void)? var closed: (() -> Void)?
var isShown: Bool { viewIfLoaded?.superview != nil && !isClosing } var isShown: Bool { viewIfLoaded?.superview != nil && !isClosing }
@@ -143,9 +147,22 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
searchAccessibilityLabel: "Search translation actions") 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, private func show(title: String, relativeTo anchor: NSView, available: [Int], emoji: Bool = false,
searchAccessibilityLabel: String = "Search application actions") { searchAccessibilityLabel: String = "Search application actions", belowAnchor: Bool = false) {
if isShown { close(); return } if viewIfLoaded?.superview != nil {
if isShown, availableEntries == available { close(); return }
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
_ = view _ = view
@@ -163,6 +180,13 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
]) ])
favorite.button.setAccessibilityLabel(favoriteTitle) favorite.button.setAccessibilityLabel(favoriteTitle)
favorite.icon.image = NSImage(systemSymbolName: isFavorite ? "star.fill" : "star", accessibilityDescription: nil) 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 = "" search.stringValue = ""
controlTextDidChange(Notification(name: NSControl.textDidChangeNotification)) controlTextDidChange(Notification(name: NSControl.textDidChangeNotification))
transitionGeneration += 1 transitionGeneration += 1
@@ -182,16 +206,18 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
]) ])
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
view.trailingAnchor.constraint(equalTo: pill.trailingAnchor), 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 coveredPillAlpha = pill.alphaValue
} }
pill.setAccessibilityHidden(true) coveredPill?.setAccessibilityHidden(true)
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
pill.animator().alphaValue = 0 coveredPill?.animator().alphaValue = 0
} }
window.makeFirstResponder(search) window.makeFirstResponder(search)
(search.currentEditor() as? NSTextView)?.insertionPointColor = SunsetPalette.accent (search.currentEditor() as? NSTextView)?.insertionPointColor = SunsetPalette.accent
@@ -271,7 +297,8 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
let selected = selectedEntry == index let selected = selectedEntry == index
entries[index].view.isSelected = selected entries[index].view.isSelected = selected
entries[index].returnKey.isHidden = !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 7: changeSourceLanguage?()
case 8: changeTargetLanguage?() case 8: changeTargetLanguage?()
case 9: swapLanguages?() case 9: swapLanguages?()
case 10: selectProvider?(false)
case 11: selectProvider?(true)
default: break default: break
} }
} }
+27 -18
View File
@@ -4,7 +4,7 @@ import Security
@MainActor @MainActor
final class TranslationController: NSViewController { final class TranslationController: NSViewController {
let providerPicker = NSPopUpButton() private let providerButton = NSButton(title: "", target: nil, action: nil)
let providerControl = NSView() let providerControl = NSView()
private let languageButton = NSButton(title: "", target: nil, action: nil) private let languageButton = NSButton(title: "", target: nil, action: nil)
private let actionsButton = NSButton(title: "Actions", 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 Locale(identifier: "en").localizedString(forLanguageCode: code) ?? code
} }
private func languageName(_ code: String) -> String { Self.languageName(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 output = NSTextView()
private let settingsButton = NSButton(title: "Open Translate Settings", target: nil, action: nil) private let settingsButton = NSButton(title: "Open Translate Settings", target: nil, action: nil)
private let status = NSTextField(wrappingLabelWithString: "") private let status = NSTextField(wrappingLabelWithString: "")
@@ -52,6 +52,7 @@ final class TranslationController: NSViewController {
self.myMemory = myMemory self.myMemory = myMemory
self.openAI = openAI self.openAI = openAI
self.pasteboard = pasteboard self.pasteboard = pasteboard
usesMyMemory = defaults.string(forKey: "translationProvider") != "openai"
sourceLanguage = defaults.string(forKey: "translationSourceLanguage") sourceLanguage = defaults.string(forKey: "translationSourceLanguage")
.flatMap { Self.languages.contains($0) ? $0 : nil } ?? "en" .flatMap { Self.languages.contains($0) ? $0 : nil } ?? "en"
targetLanguage = defaults.string(forKey: "translationTargetLanguage") targetLanguage = defaults.string(forKey: "translationTargetLanguage")
@@ -63,30 +64,28 @@ final class TranslationController: NSViewController {
override func loadView() { override func loadView() {
view = NSView() view = NSView()
providerPicker.addItems(withTitles: ["MyMemory (no key)", "OpenAI · gpt-6-luna"]) providerButton.title = usesMyMemory ? "MyMemory (no key)" : "OpenAI · gpt-6-luna"
providerPicker.selectItem(at: defaults.string(forKey: "translationProvider") == "openai" ? 1 : 0) providerButton.setAccessibilityLabel("Translation provider")
providerPicker.setAccessibilityLabel("Translation provider") providerButton.target = self
providerPicker.target = self providerButton.action = #selector(showProviders)
providerPicker.action = #selector(providerChanged) providerButton.isBordered = false
providerPicker.isBordered = false providerButton.font = .systemFont(ofSize: 12, weight: .medium)
(providerPicker.cell as? NSPopUpButtonCell)?.arrowPosition = .noArrow providerButton.contentTintColor = SunsetPalette.text
providerPicker.font = .systemFont(ofSize: 12, weight: .medium)
providerPicker.contentTintColor = SunsetPalette.text
let chevron = NSButton(image: NSImage(systemSymbolName: "chevron.down", accessibilityDescription: nil)!, 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.isBordered = false
chevron.contentTintColor = SunsetPalette.secondaryText chevron.contentTintColor = SunsetPalette.secondaryText
chevron.setAccessibilityElement(false) chevron.setAccessibilityElement(false)
stylePill(providerControl) stylePill(providerControl)
for control in [providerPicker, chevron] { for control in [providerButton, chevron] {
control.translatesAutoresizingMaskIntoConstraints = false control.translatesAutoresizingMaskIntoConstraints = false
providerControl.addSubview(control) providerControl.addSubview(control)
} }
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
providerControl.heightAnchor.constraint(equalToConstant: 36), providerControl.heightAnchor.constraint(equalToConstant: 36),
providerPicker.leadingAnchor.constraint(equalTo: providerControl.leadingAnchor, constant: 14), providerButton.leadingAnchor.constraint(equalTo: providerControl.leadingAnchor, constant: 14),
providerPicker.centerYAnchor.constraint(equalTo: providerControl.centerYAnchor), providerButton.centerYAnchor.constraint(equalTo: providerControl.centerYAnchor),
chevron.leadingAnchor.constraint(equalTo: providerPicker.trailingAnchor, constant: 8), chevron.leadingAnchor.constraint(equalTo: providerButton.trailingAnchor, constant: 8),
chevron.centerYAnchor.constraint(equalTo: providerControl.centerYAnchor), chevron.centerYAnchor.constraint(equalTo: providerControl.centerYAnchor),
chevron.trailingAnchor.constraint(equalTo: providerControl.trailingAnchor, constant: -12), chevron.trailingAnchor.constraint(equalTo: providerControl.trailingAnchor, constant: -12),
chevron.widthAnchor.constraint(equalToConstant: 10), chevron.widthAnchor.constraint(equalToConstant: 10),
@@ -112,6 +111,7 @@ final class TranslationController: NSViewController {
actions.changeSourceLanguage = { [weak self] in self?.chooseLanguage(source: true) } actions.changeSourceLanguage = { [weak self] in self?.chooseLanguage(source: true) }
actions.changeTargetLanguage = { [weak self] in self?.chooseLanguage(source: false) } actions.changeTargetLanguage = { [weak self] in self?.chooseLanguage(source: false) }
actions.swapLanguages = { [weak self] in self?.swapLanguages() } actions.swapLanguages = { [weak self] in self?.swapLanguages() }
actions.selectProvider = { [weak self] in self?.selectProvider(openAI: $0) }
settingsButton.target = self settingsButton.target = self
settingsButton.action = #selector(openSettings) settingsButton.action = #selector(openSettings)
settingsButton.isHidden = true settingsButton.isHidden = true
@@ -196,6 +196,11 @@ final class TranslationController: NSViewController {
pill.layer?.backgroundColor = SunsetPalette.background.withAlphaComponent(0.25).cgColor 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() { @objc func showActions() {
actions.showTranslation(relativeTo: actionsButton) actions.showTranslation(relativeTo: actionsButton)
translateButton.keyEquivalent = actions.isShown ? "" : "\r" translateButton.keyEquivalent = actions.isShown ? "" : "\r"
@@ -267,8 +272,12 @@ final class TranslationController: NSViewController {
setStatus("") setStatus("")
} }
@objc private func providerChanged() { func selectProvider(openAI: Bool) {
defaults.set(usesMyMemory ? "mymemory" : "openai", forKey: "translationProvider") 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() inputChanged()
} }
@@ -29,3 +29,35 @@ import Testing
actions.close(animated: false) 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 } tool.input = { input }
let views = descendants(tool.view) let views = descendants(tool.view)
let output = try #require(views.compactMap { $0 as? NSTextView }.first) 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") }) let button = try #require(views.compactMap { $0 as? NSButton }.first { $0.action == NSSelectorFromString("performPrimaryAction") })
tool.translate() tool.translate()
try await waitForTranslation { requests.pending.count == 1 } try await waitForTranslation { requests.pending.count == 1 }
@@ -200,8 +199,7 @@ func translationExecutionRejectsOldCompletionsWithoutClearingNewRequests() async
tool.translate() tool.translate()
#expect(requests.inputs.count == 2) #expect(requests.inputs.count == 2)
provider.selectItem(at: 1) tool.selectProvider(openAI: true)
_ = tool.perform(provider.action, with: provider)
#expect(defaults.string(forKey: "translationProvider") == "openai") #expect(defaults.string(forKey: "translationProvider") == "openai")
tool.translate() tool.translate()
try await waitForTranslation { requests.pending.count == 3 } try await waitForTranslation { requests.pending.count == 3 }