Compare commits

...

4 Commits

Author SHA1 Message Date
hl-valdemar 77ac319a34 chore: update todo 2026-10-25 23:17:29 +01:00
hl-valdemar ef979d2838 fix: preserve uncommitted translation input 2026-10-25 15:51:18 +01:00
hl-valdemar 47912d237c fix: discover Safari in cryptex applications 2026-10-25 15:13:34 +01:00
hl-valdemar c427803504 chore: update todo 2026-10-24 13:30:04 +02:00
6 changed files with 97 additions and 10 deletions
+4 -2
View File
@@ -42,7 +42,7 @@ Favorited launcher entries show a small star before their **Application** or **C
Launcher results and the emoji grid start below the top fade; scrolling moves content naturally through it.
Conjure searches the system and user Applications folders. Hover over a result to see its path; duplicate names show their locations. Drag the launcher background or Notes title bar to reposition it; Conjure remembers the window's center and display, shared across the launcher and tools regardless of window size. If a window no longer fits onscreen, it returns to the default placement. Older corner-based saved positions reset once; drag again to save a center-based position.
Conjure searches the system and user Applications folders, including `/System/Cryptexes/App/System/Applications` for Safari. Hover over a result to see its path; duplicate names show their locations. Drag the launcher background or Notes title bar to reposition it; Conjure remembers the window's center and display, shared across the launcher and tools regardless of window size. If a window no longer fits onscreen, it returns to the default placement. Older corner-based saved positions reset once; drag again to save a center-based position.
Conjure runs without a Dock icon. Dismissing the launcher leaves it running; reopen it from Finder or use your configured global shortcut.
@@ -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
View File
@@ -49,6 +49,7 @@ public enum ApplicationCatalog {
[
URL(fileURLWithPath: "/Applications", isDirectory: true),
URL(fileURLWithPath: "/System/Applications", isDirectory: true),
URL(fileURLWithPath: "/System/Cryptexes/App/System/Applications", isDirectory: true),
URL(fileURLWithPath: "/System/Library/CoreServices/Applications", isDirectory: true),
FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Applications", isDirectory: true),
]
-5
View File
@@ -1,5 +0,0 @@
- [x] feature: configurable color scheme
- general design should stay consistent
- only colors should be configurable; not the way the colors are used/presented (e.g. frosted glass effect with color blobs behind shouldn't change)
- this should play nicely together with the theme control tool in `/Users/valdemar/Developer/Personal/Tools/tint`.
- maybe a configuration file (e.g. ~/.config/conjure/config.toml) is warranted
@@ -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()
}
}