fix: preserve uncommitted translation input
This commit is contained in:
@@ -78,7 +78,7 @@ The header’s trailing edge is constrained to the footer’s trailing edge. The
|
||||
|
||||
In Settings, choose a **Global shortcut destination**: **Applications**, **Conjure Emoji**, **Conjure Notes**, **Conjure Translate**, or **Toggle System Theme**. Choose **Record Shortcut…** and press a combination containing Command, Option, or Control. For example, select **Conjure Emoji** and record Command-Shift-Space to open the emoji picker directly. **Clear** disables only the selected destination's shortcut. No shortcuts are assigned by default; existing launcher bindings are preserved.
|
||||
|
||||
The Applications shortcut always targets the application launcher, not the last-used tool. View commands open their selected tool directly. Action-only shortcuts, such as Toggle System Theme, execute in the background without opening the launcher, taking focus, clearing a search, or closing an existing tool. Pressing a tool shortcut while its destination is already frontmost dismisses Conjure; switching view destinations opens a fresh view. Active confirmations and uninstall operations are not interrupted. All global shortcuts pause while recording and resume afterward. Conjure rejects shortcuts already assigned to another destination or used by its own menus and navigation.
|
||||
The Applications shortcut always targets the application launcher, not the last-used tool. View commands open their selected tool directly. Action-only shortcuts, such as Toggle System Theme, execute in the background without opening the launcher, taking focus, clearing a search, or closing an existing tool. Pressing a tool shortcut while its destination is already frontmost dismisses Conjure; switching view destinations opens the selected view, restoring any uncommitted Translate draft. Active confirmations and uninstall operations are not interrupted. All global shortcuts pause while recording and resume afterward. Conjure rejects shortcuts already assigned to another destination or used by its own menus and navigation.
|
||||
|
||||
Cancelling recording, changing destination, or leaving the recorder restores saved shortcuts. If a replacement cannot be registered, the previous saved binding is retained; any failure to restore a shortcut is reported in Settings.
|
||||
|
||||
@@ -267,6 +267,8 @@ The translation uses 20-point primary text; OpenAI's explanation below the blank
|
||||
|
||||
When the translation is available, the action changes to **Copy**: press Return or click it to copy the full output (including OpenAI's explanation) and close Conjure. Command-C still copies selected output. Editing the current input clears the result; editing context keeps the original text. The action is disabled while translating.
|
||||
|
||||
Closing Translate, losing focus, returning to Applications, or switching tools keeps the uncommitted source text, optional context, and current input step in memory. Reopening Translate restores that draft without sending a request; results are not retained. A successful **Copy** clears the draft. Drafts are not written to disk and disappear when Conjure quits.
|
||||
|
||||
The footer places the current primary action and its Return keycap before **Actions**, whose Command and K keys use separate keycaps, matching the launcher.
|
||||
|
||||
Press Command-P or click the provider selector to expand a searchable action-style popup from its top edge downwards, hiding the indicator until the popup closes. A checkmark marks the current provider. Use Up/Down and Return or click a provider to select it; Escape, Back, or clicking outside closes the popup. Changing providers clears the previous result without submitting a request. Switching away from OpenAI during the context step restores the original source text; context is not sent to MyMemory.
|
||||
|
||||
@@ -37,6 +37,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
private var launcherViews: [NSView] = []
|
||||
private var launcherFrameBeforeReview: NSRect?
|
||||
private var translation: TranslationController?
|
||||
private var translationDraft: TranslationController.Draft?
|
||||
private var emojiPicker: EmojiPickerController?
|
||||
private var notes: NotesController?
|
||||
private let notesDirectory: URL?
|
||||
@@ -886,7 +887,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
let accessibilityLabel: String
|
||||
switch command {
|
||||
case .translate:
|
||||
let tool = TranslationController(defaults: defaults, pasteboard: pasteboard)
|
||||
let tool = TranslationController(defaults: defaults, pasteboard: pasteboard, draft: translationDraft)
|
||||
tool.dismiss = { [weak self] in self?.dismiss() }
|
||||
translation = tool
|
||||
tool.input = { [weak self] in self?.searchField.stringValue ?? "" }
|
||||
@@ -931,6 +932,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
guard let self, let controller else { return }
|
||||
controller.view.removeFromSuperview()
|
||||
self.translation?.providerControl.removeFromSuperview()
|
||||
if let translation = self.translation { self.translationDraft = translation.draft }
|
||||
self.translationHeader.isHidden = true
|
||||
self.translation = nil
|
||||
self.emojiPicker = nil
|
||||
@@ -976,7 +978,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
commandBack.target = self
|
||||
commandBack.action = #selector(goBackCommand)
|
||||
commandBack.isHidden = false
|
||||
searchField.stringValue = ""
|
||||
searchField.stringValue = command == .translate ? translationDraft?.input ?? "" : ""
|
||||
searchField.placeholderAttributedString = NSAttributedString(
|
||||
string: placeholder,
|
||||
attributes: previousPlaceholder?.attributes(at: 0, effectiveRange: nil) ?? [:]
|
||||
@@ -1011,6 +1013,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
content.addSubview(controller.view)
|
||||
if let translation {
|
||||
translationHeader.addArrangedSubview(translation.providerControl)
|
||||
commandBack.setAccessibilityLabel(translation.backAccessibilityLabel)
|
||||
commandBack.toolTip = translation.backAccessibilityLabel
|
||||
translationHeader.isHidden = false
|
||||
}
|
||||
NSLayoutConstraint.activate([
|
||||
|
||||
@@ -32,6 +32,14 @@ final class TranslationController: NSViewController {
|
||||
private func languageName(_ code: String) -> String { Self.languageName(code) }
|
||||
private var usesMyMemory: Bool
|
||||
private var textToTranslate: String?
|
||||
struct Draft {
|
||||
let input: String
|
||||
let textToTranslate: String?
|
||||
}
|
||||
private var committed = false
|
||||
var draft: Draft? {
|
||||
committed ? nil : Draft(input: input?() ?? "", textToTranslate: textToTranslate)
|
||||
}
|
||||
private let sourcePreview = NSTextField(wrappingLabelWithString: "")
|
||||
private let output = NSTextView()
|
||||
private let settingsButton = NSButton(title: "Open Translate Settings", target: nil, action: nil)
|
||||
@@ -60,13 +68,15 @@ final class TranslationController: NSViewController {
|
||||
openAI: @escaping @MainActor (String, String, String, String, String) async throws -> String = {
|
||||
try await TranslationAPI.translate($0, apiKey: $3, sourceLanguage: $1, targetLanguage: $2, context: $4)
|
||||
},
|
||||
pasteboard: NSPasteboard = .general
|
||||
pasteboard: NSPasteboard = .general,
|
||||
draft: Draft? = nil
|
||||
) {
|
||||
self.defaults = defaults
|
||||
self.credentialsStore = credentials
|
||||
self.myMemory = myMemory
|
||||
self.openAI = openAI
|
||||
self.pasteboard = pasteboard
|
||||
textToTranslate = draft?.textToTranslate
|
||||
usesMyMemory = defaults.string(forKey: "translationProvider") != "openai"
|
||||
sourceLanguage = defaults.string(forKey: "translationSourceLanguage")
|
||||
.flatMap { Self.languages.contains($0) ? $0 : nil } ?? "en"
|
||||
@@ -285,6 +295,7 @@ final class TranslationController: NSViewController {
|
||||
}
|
||||
|
||||
func inputChanged() {
|
||||
committed = false
|
||||
cancelRequest()
|
||||
output.string = ""
|
||||
outputSecondaryRange = nil
|
||||
@@ -354,6 +365,7 @@ final class TranslationController: NSViewController {
|
||||
}
|
||||
pasteboard.clearContents()
|
||||
if pasteboard.setString(output.string, forType: .string) {
|
||||
committed = true
|
||||
dismiss?()
|
||||
} else {
|
||||
setStatus("Could not copy the translation. Try again.", isError: true)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
- [ ] conjure translate: keep the current state of input when closing.
|
||||
- [x] conjure translate: keep the current state of input when closing.
|
||||
- sometimes, the window covers up what you need to see in order to write, so closing the window momentarily to see the next piece of text to input is pretty natural.
|
||||
- unfortunately, this causes the input fields to reset.
|
||||
- to amend this, we should instead keep the input state around when not committed.
|
||||
@@ -1,4 +1,5 @@
|
||||
import AppKit
|
||||
import ConjureKit
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Conjure
|
||||
@@ -146,11 +147,14 @@ func primaryActionCopiesCompletedTranslationAndResetsAfterEditing() async throws
|
||||
#expect(!dismissed)
|
||||
requests.pending[0].resume(returning: "こんにちは")
|
||||
try await waitForTranslation { button.isEnabled }
|
||||
#expect(tool.draft?.input == "Hello")
|
||||
tool.performPrimaryAction()
|
||||
#expect(pasteboard.string(forType: .string) == "こんにちは")
|
||||
#expect(dismissed)
|
||||
#expect(tool.draft == nil)
|
||||
dismissed = false
|
||||
tool.inputChanged()
|
||||
#expect(tool.draft?.input == "Hello")
|
||||
button.performClick(nil)
|
||||
try await waitForTranslation { requests.pending.count == 2 }
|
||||
#expect(!dismissed)
|
||||
@@ -435,4 +439,73 @@ func blankContextTranslatesWithoutSwappingAndProviderChangesRestoreOriginalText(
|
||||
requests.pending[2].resume(returning: "やあ")
|
||||
try await waitForTranslation { output.string == "やあ" }
|
||||
}
|
||||
|
||||
@Test(arguments: ["escape", "window-close", "deactivate", "switch"])
|
||||
@MainActor func uncommittedInputSurvivesLeavingTranslate(_ exit: String) throws {
|
||||
_ = NSApplication.shared
|
||||
let suite = "TranslateDraft.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defaults.set("openai", forKey: "translationProvider")
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let launcher = LauncherWindowController(
|
||||
history: LaunchHistory(defaults: defaults, now: { Date(timeIntervalSinceReferenceDate: 12_000) }),
|
||||
defaults: defaults)
|
||||
let window = try #require(launcher.window)
|
||||
defer { window.orderOut(nil) }
|
||||
let content = try #require(window.contentView)
|
||||
let search = try #require(descendants(content).compactMap { $0 as? NSSearchField }.first)
|
||||
func tool() throws -> TranslationController {
|
||||
try #require(descendants(content).compactMap { $0 as? NSButton }
|
||||
.compactMap { $0.target as? TranslationController }.first)
|
||||
}
|
||||
func enter(_ text: String) {
|
||||
search.stringValue = text
|
||||
launcher.controlTextDidChange(Notification(name: NSControl.textDidChangeNotification, object: search))
|
||||
}
|
||||
func leave() {
|
||||
switch exit {
|
||||
case "escape":
|
||||
_ = launcher.control(search, textView: NSTextView(), doCommandBy: #selector(NSResponder.cancelOperation(_:)))
|
||||
case "window-close":
|
||||
window.performClose(nil)
|
||||
case "deactivate":
|
||||
launcher.windowDidResignKey(Notification(name: NSWindow.didResignKeyNotification, object: window))
|
||||
default:
|
||||
launcher.present(command: .emoji)
|
||||
}
|
||||
}
|
||||
launcher.present(command: .translate)
|
||||
enter(" An unfinished sentence 🌏\n")
|
||||
leave()
|
||||
launcher.present(command: .translate)
|
||||
#expect(search.stringValue == " An unfinished sentence 🌏\n")
|
||||
#expect(search.accessibilityLabel() == "English text to translate into Japanese")
|
||||
|
||||
try tool().performPrimaryAction() // Advance to context without sending a request.
|
||||
leave()
|
||||
launcher.present(command: .translate)
|
||||
#expect(search.stringValue.isEmpty)
|
||||
#expect(search.accessibilityLabel() == "Context or situation for the translation (optional)")
|
||||
enter("Said by a friend")
|
||||
leave()
|
||||
launcher.present(command: .translate)
|
||||
#expect(search.stringValue == "Said by a friend")
|
||||
#expect(search.accessibilityLabel() == "Context or situation for the translation (optional)")
|
||||
let preview = try #require(descendants(content).compactMap { $0 as? NSTextField }
|
||||
.first { $0.accessibilityLabel() == "Original text to translate" })
|
||||
#expect(preview.stringValue == "Text to translate: An unfinished sentence 🌏\n")
|
||||
try tool().goBack()
|
||||
#expect(search.stringValue == " An unfinished sentence 🌏\n")
|
||||
try tool().goBack() // Returning to Applications must also retain the draft.
|
||||
search.stringValue = "translate"
|
||||
launcher.controlTextDidChange(Notification(name: NSControl.textDidChangeNotification, object: search))
|
||||
_ = launcher.control(search, textView: NSTextView(), doCommandBy: #selector(NSResponder.insertNewline(_:)))
|
||||
#expect(search.stringValue == " An unfinished sentence 🌏\n")
|
||||
|
||||
enter("")
|
||||
leave()
|
||||
launcher.present(command: .translate)
|
||||
#expect(search.stringValue.isEmpty) // Clearing a draft must not resurrect older text.
|
||||
launcher.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user