fix: preserve uncommitted translation input

This commit is contained in:
2026-10-25 15:44:44 +01:00
parent 47912d237c
commit ef979d2838
5 changed files with 96 additions and 5 deletions
+3 -1
View File
@@ -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([
+13 -1
View File
@@ -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 -1
View File
@@ -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()
}
}