diff --git a/README.md b/README.md index 169913b..fc7c158 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,7 @@ Notes autosave locally as RTFD packages, including embedded images, under `~/Lib ## 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. diff --git a/Sources/Conjure/LauncherWindowController.swift b/Sources/Conjure/LauncherWindowController.swift index ed16068..98c2c0f 100644 --- a/Sources/Conjure/LauncherWindowController.swift +++ b/Sources/Conjure/LauncherWindowController.swift @@ -499,7 +499,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe } if let emojiPicker { return emojiPicker.handle(commandSelector) } if let translation { - if commandSelector == #selector(NSResponder.insertNewline(_:)) { translation.translate(); return true } + if commandSelector == #selector(NSResponder.insertNewline(_:)) { translation.performPrimaryAction(); return true } return false } switch commandSelector { @@ -835,7 +835,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe let accessibilityLabel: String switch command { case .translate: - let tool = TranslationController() + let tool = TranslationController(pasteboard: pasteboard) + tool.dismiss = { [weak self] in self?.dismiss() } translation = tool tool.input = { [weak self] in self?.searchField.stringValue ?? "" } controller = tool diff --git a/Sources/Conjure/TranslationController.swift b/Sources/Conjure/TranslationController.swift index d09bf58..e93c2e0 100644 --- a/Sources/Conjure/TranslationController.swift +++ b/Sources/Conjure/TranslationController.swift @@ -13,6 +13,8 @@ final class TranslationController: NSViewController { private var task: Task? var input: (() -> String)? var closed: (() -> Void)? + var dismiss: (() -> Void)? + private let pasteboard: NSPasteboard private let defaults: UserDefaults private let credentialsStore: Credentials private let myMemory: @MainActor (String) async throws -> String @@ -22,12 +24,14 @@ final class TranslationController: NSViewController { defaults: UserDefaults = .standard, credentials: Credentials = .keychain, 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.credentialsStore = credentials self.myMemory = myMemory self.openAI = openAI + self.pasteboard = pasteboard super.init(nibName: nil, bundle: nil) } @@ -49,7 +53,7 @@ final class TranslationController: NSViewController { NotificationCenter.default.addObserver(self, selector: #selector(credentialsChanged), name: Notification.Name("ConjureTranslationCredentialsChanged"), object: nil) translateButton.target = self - translateButton.action = #selector(translate) + translateButton.action = #selector(performPrimaryAction) translateButton.keyEquivalent = "\r" translateButton.isBordered = false translateButton.font = .systemFont(ofSize: 12, weight: .medium) @@ -131,6 +135,8 @@ final class TranslationController: NSViewController { func inputChanged() { cancelRequest() output.string = "" + translateButton.title = "Translate" + translateButton.toolTip = "Translate the English input (Return). Escape dismisses Conjure." settingsButton.isHidden = true setStatus("Press Return to translate.") } @@ -151,6 +157,17 @@ final class TranslationController: NSViewController { 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() { guard task == nil else { return } let text = input?() ?? "" @@ -186,7 +203,9 @@ final class TranslationController: NSViewController { : openAITranslate(text, key) guard !Task.isCancelled, let self else { return } 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 { guard !Task.isCancelled, let self else { return } self.settingsButton.isHidden = myMemory diff --git a/Tests/ConjureKitTests/TranslationExecutionTests.swift b/Tests/ConjureKitTests/TranslationExecutionTests.swift index c91a792..4825694 100644 --- a/Tests/ConjureKitTests/TranslationExecutionTests.swift +++ b/Tests/ConjureKitTests/TranslationExecutionTests.swift @@ -58,6 +58,45 @@ private func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.fl @Suite(.serialized) 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 func translationExecutionIsExplicitAndMyMemoryNeverTouchesCredentials() async throws { _ = NSApplication.shared @@ -103,7 +142,7 @@ func translationExecutionRejectsOldCompletionsWithoutClearingNewRequests() async 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("translate") }) + let button = try #require(views.compactMap { $0 as? NSButton }.first { $0.action == NSSelectorFromString("performPrimaryAction") }) tool.translate() try await waitForTranslation { requests.pending.count == 1 } input = "second" @@ -160,7 +199,7 @@ func translationExecutionRecoversFromCredentialAndRequestFailures() async throws let field = try #require(settingsViews.compactMap { $0 as? NSSecureTextField }.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 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 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") })