From ef979d283852b1a4154c6ed5e12eccbda98e4379 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Sun, 25 Oct 2026 15:44:44 +0100 Subject: [PATCH] fix: preserve uncommitted translation input --- README.md | 4 +- .../Conjure/LauncherWindowController.swift | 8 +- Sources/Conjure/TranslationController.swift | 14 +++- TODO.md | 2 +- .../TranslationExecutionTests.swift | 73 +++++++++++++++++++ 5 files changed, 96 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 9da155d..aa5e716 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/Sources/Conjure/LauncherWindowController.swift b/Sources/Conjure/LauncherWindowController.swift index 6352e10..e17c122 100644 --- a/Sources/Conjure/LauncherWindowController.swift +++ b/Sources/Conjure/LauncherWindowController.swift @@ -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([ diff --git a/Sources/Conjure/TranslationController.swift b/Sources/Conjure/TranslationController.swift index 260c472..8f024d2 100644 --- a/Sources/Conjure/TranslationController.swift +++ b/Sources/Conjure/TranslationController.swift @@ -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) diff --git a/TODO.md b/TODO.md index a077578..b9473e7 100644 --- a/TODO.md +++ b/TODO.md @@ -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. \ No newline at end of file diff --git a/Tests/ConjureKitTests/TranslationExecutionTests.swift b/Tests/ConjureKitTests/TranslationExecutionTests.swift index 357356d..087c601 100644 --- a/Tests/ConjureKitTests/TranslationExecutionTests.swift +++ b/Tests/ConjureKitTests/TranslationExecutionTests.swift @@ -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() +} }