From d82956518895b0f6cfd2dadfaa12855ab212e753 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Sun, 18 Oct 2026 00:05:46 +0200 Subject: [PATCH] fix: match provider picker to action popup --- README.md | 2 + .../ApplicationActionsController.swift | 45 +++++++++++++++---- Sources/Conjure/TranslationController.swift | 45 +++++++++++-------- .../ActionsBackgroundTests.swift | 32 +++++++++++++ .../TranslationExecutionTests.swift | 4 +- 5 files changed, 99 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index a7dc5b3..d222424 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/Sources/Conjure/ApplicationActionsController.swift b/Sources/Conjure/ApplicationActionsController.swift index 415e945..cf8cb76 100644 --- a/Sources/Conjure/ApplicationActionsController.swift +++ b/Sources/Conjure/ApplicationActionsController.swift @@ -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 } } diff --git a/Sources/Conjure/TranslationController.swift b/Sources/Conjure/TranslationController.swift index 568a99d..3f0cba5 100644 --- a/Sources/Conjure/TranslationController.swift +++ b/Sources/Conjure/TranslationController.swift @@ -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() } diff --git a/Tests/ConjureKitTests/ActionsBackgroundTests.swift b/Tests/ConjureKitTests/ActionsBackgroundTests.swift index 038a7a1..d9d84f2 100644 --- a/Tests/ConjureKitTests/ActionsBackgroundTests.swift +++ b/Tests/ConjureKitTests/ActionsBackgroundTests.swift @@ -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]) +} diff --git a/Tests/ConjureKitTests/TranslationExecutionTests.swift b/Tests/ConjureKitTests/TranslationExecutionTests.swift index 29a3afc..202c3cb 100644 --- a/Tests/ConjureKitTests/TranslationExecutionTests.swift +++ b/Tests/ConjureKitTests/TranslationExecutionTests.swift @@ -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 }