feat: copy translation and dismiss with Return
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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") })
|
||||||
|
|||||||
Reference in New Issue
Block a user