feat: copy translation and dismiss with Return

This commit is contained in:
2026-10-17 23:21:51 +02:00
parent 9eb16ddd5a
commit 038a5b78a0
4 changed files with 67 additions and 8 deletions
+1 -1
View File
@@ -130,7 +130,7 @@ Notes autosave locally as RTFD packages, including embedded images, under `~/Lib
## Translation ## Translation
Search for **Conjure Translate**, identified by its centered 🌏 icon, and open it. Enter English in the search bar, choose a provider in the launcher header, then press Return or click **Translate**. Typing alone never sends text. Copy selected Japanese output with Command-C; the copy hint appears beside the Translate action at the bottom. The Back chevron beside the input returns to the launcher and restores your search; Escape dismisses Conjure. Search for **Conjure Translate**, identified by its centered 🌏 icon, and open it. Enter English in the search bar, choose a provider in the launcher header, 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 Back chevron beside the input returns to the launcher and restores your search; Escape dismisses Conjure.
Editing the input, switching 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, switching 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.
@@ -499,7 +499,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
} }
if let emojiPicker { return emojiPicker.handle(commandSelector) } if let emojiPicker { return emojiPicker.handle(commandSelector) }
if let translation { if let translation {
if commandSelector == #selector(NSResponder.insertNewline(_:)) { translation.translate(); return true } if commandSelector == #selector(NSResponder.insertNewline(_:)) { translation.performPrimaryAction(); return true }
return false return false
} }
switch commandSelector { switch commandSelector {
@@ -835,7 +835,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
let accessibilityLabel: String let accessibilityLabel: String
switch command { switch command {
case .translate: case .translate:
let tool = TranslationController() let tool = TranslationController(pasteboard: pasteboard)
tool.dismiss = { [weak self] in self?.dismiss() }
translation = tool translation = tool
tool.input = { [weak self] in self?.searchField.stringValue ?? "" } tool.input = { [weak self] in self?.searchField.stringValue ?? "" }
controller = tool controller = tool
+22 -3
View File
@@ -13,6 +13,8 @@ final class TranslationController: NSViewController {
private var task: Task<Void, Never>? private var task: Task<Void, Never>?
var input: (() -> String)? var input: (() -> String)?
var closed: (() -> Void)? var closed: (() -> Void)?
var dismiss: (() -> Void)?
private let pasteboard: NSPasteboard
private let defaults: UserDefaults private let defaults: UserDefaults
private let credentialsStore: Credentials private let credentialsStore: Credentials
private let myMemory: @MainActor (String) async throws -> String private let myMemory: @MainActor (String) async throws -> String
@@ -22,12 +24,14 @@ final class TranslationController: NSViewController {
defaults: UserDefaults = .standard, defaults: UserDefaults = .standard,
credentials: Credentials = .keychain, credentials: Credentials = .keychain,
myMemory: @escaping @MainActor (String) async throws -> String = { try await MyMemoryAPI.translate($0) }, myMemory: @escaping @MainActor (String) async throws -> String = { try await MyMemoryAPI.translate($0) },
openAI: @escaping @MainActor (String, String) async throws -> String = { try await TranslationAPI.translate($0, apiKey: $1) } openAI: @escaping @MainActor (String, String) async throws -> String = { try await TranslationAPI.translate($0, apiKey: $1) },
pasteboard: NSPasteboard = .general
) { ) {
self.defaults = defaults self.defaults = defaults
self.credentialsStore = credentials self.credentialsStore = credentials
self.myMemory = myMemory self.myMemory = myMemory
self.openAI = openAI self.openAI = openAI
self.pasteboard = pasteboard
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
} }
@@ -49,7 +53,7 @@ final class TranslationController: NSViewController {
NotificationCenter.default.addObserver(self, selector: #selector(credentialsChanged), NotificationCenter.default.addObserver(self, selector: #selector(credentialsChanged),
name: Notification.Name("ConjureTranslationCredentialsChanged"), object: nil) name: Notification.Name("ConjureTranslationCredentialsChanged"), object: nil)
translateButton.target = self translateButton.target = self
translateButton.action = #selector(translate) translateButton.action = #selector(performPrimaryAction)
translateButton.keyEquivalent = "\r" translateButton.keyEquivalent = "\r"
translateButton.isBordered = false translateButton.isBordered = false
translateButton.font = .systemFont(ofSize: 12, weight: .medium) translateButton.font = .systemFont(ofSize: 12, weight: .medium)
@@ -131,6 +135,8 @@ final class TranslationController: NSViewController {
func inputChanged() { func inputChanged() {
cancelRequest() cancelRequest()
output.string = "" output.string = ""
translateButton.title = "Translate"
translateButton.toolTip = "Translate the English input (Return). Escape dismisses Conjure."
settingsButton.isHidden = true settingsButton.isHidden = true
setStatus("Press Return to translate.") setStatus("Press Return to translate.")
} }
@@ -151,6 +157,17 @@ final class TranslationController: NSViewController {
status.textColor = isError ? .systemRed : SunsetPalette.secondaryText status.textColor = isError ? .systemRed : SunsetPalette.secondaryText
} }
@objc func performPrimaryAction() {
guard task == nil else { return }
guard !output.string.isEmpty else { translate(); return }
pasteboard.clearContents()
if pasteboard.setString(output.string, forType: .string) {
dismiss?()
} else {
setStatus("Could not copy the translation. Try again.", isError: true)
}
}
@objc func translate() { @objc func translate() {
guard task == nil else { return } guard task == nil else { return }
let text = input?() ?? "" let text = input?() ?? ""
@@ -186,7 +203,9 @@ final class TranslationController: NSViewController {
: openAITranslate(text, key) : openAITranslate(text, key)
guard !Task.isCancelled, let self else { return } guard !Task.isCancelled, let self else { return }
self.output.string = translation self.output.string = translation
self.setStatus("Japanese · Select text to copy (⌘C).") self.setStatus("English → Japanese")
self.translateButton.title = "Copy"
self.translateButton.toolTip = "Copy the translation and close Conjure (Return)."
} catch { } catch {
guard !Task.isCancelled, let self else { return } guard !Task.isCancelled, let self else { return }
self.settingsButton.isHidden = myMemory self.settingsButton.isHidden = myMemory
@@ -58,6 +58,45 @@ private func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.fl
@Suite(.serialized) @Suite(.serialized)
struct TranslationExecutionTests { struct TranslationExecutionTests {
@Test @MainActor
func primaryActionCopiesCompletedTranslationAndResetsAfterEditing() async throws {
_ = NSApplication.shared
let suite = "TranslateCopy.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
let pasteboard = NSPasteboard.withUniqueName()
defer {
defaults.removePersistentDomain(forName: suite)
pasteboard.releaseGlobally()
}
let requests = TranslationRequests()
let tool = TranslationController(defaults: defaults,
myMemory: { try await requests.translate($0) }, pasteboard: pasteboard)
tool.input = { "Hello" }
var dismissed = false
tool.dismiss = { dismissed = true }
let button = try #require(descendants(tool.view).compactMap { $0 as? NSButton }
.first { $0.action == NSSelectorFromString("performPrimaryAction") })
button.performClick(nil)
try await waitForTranslation { requests.pending.count == 1 }
tool.performPrimaryAction()
#expect(!dismissed)
requests.pending[0].resume(returning: "こんにちは")
try await waitForTranslation { button.isEnabled }
tool.performPrimaryAction()
#expect(pasteboard.string(forType: .string) == "こんにちは")
#expect(dismissed)
dismissed = false
tool.inputChanged()
button.performClick(nil)
try await waitForTranslation { requests.pending.count == 2 }
#expect(!dismissed)
requests.pending[1].resume(returning: "再翻訳")
try await waitForTranslation { button.isEnabled }
button.performClick(nil)
#expect(pasteboard.string(forType: .string) == "再翻訳")
#expect(dismissed)
}
@Test @MainActor @Test @MainActor
func translationExecutionIsExplicitAndMyMemoryNeverTouchesCredentials() async throws { func translationExecutionIsExplicitAndMyMemoryNeverTouchesCredentials() async throws {
_ = NSApplication.shared _ = NSApplication.shared
@@ -103,7 +142,7 @@ func translationExecutionRejectsOldCompletionsWithoutClearingNewRequests() async
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 provider = tool.providerPicker
let button = try #require(views.compactMap { $0 as? NSButton }.first { $0.action == NSSelectorFromString("translate") }) 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 }
input = "second" input = "second"
@@ -160,7 +199,7 @@ func translationExecutionRecoversFromCredentialAndRequestFailures() async throws
let field = try #require(settingsViews.compactMap { $0 as? NSSecureTextField }.first) let field = try #require(settingsViews.compactMap { $0 as? NSSecureTextField }.first)
let output = try #require(views.compactMap { $0 as? NSTextView }.first) let output = try #require(views.compactMap { $0 as? NSTextView }.first)
let status = try #require(views.compactMap { $0 as? NSTextField }.first { $0.accessibilityLabel() == "Translation status and errors" }) let status = try #require(views.compactMap { $0 as? NSTextField }.first { $0.accessibilityLabel() == "Translation status and errors" })
let button = try #require(views.compactMap { $0 as? NSButton }.first { $0.action == NSSelectorFromString("translate") }) let button = try #require(views.compactMap { $0 as? NSButton }.first { $0.action == NSSelectorFromString("performPrimaryAction") })
let save = try #require(settingsViews.compactMap { $0 as? NSButton }.first { $0.action == NSSelectorFromString("saveKey") }) let save = try #require(settingsViews.compactMap { $0 as? NSButton }.first { $0.action == NSSelectorFromString("saveKey") })
let forget = try #require(settingsViews.compactMap { $0 as? NSButton }.first { $0.action == NSSelectorFromString("forgetKey") }) let forget = try #require(settingsViews.compactMap { $0 as? NSButton }.first { $0.action == NSSelectorFromString("forgetKey") })
let openSettings = try #require(views.compactMap { $0 as? NSButton }.first { $0.action == NSSelectorFromString("openSettings") }) let openSettings = try #require(views.compactMap { $0 as? NSButton }.first { $0.action == NSSelectorFromString("openSettings") })