Compare commits
4 Commits
c0a17f5efe
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 77ac319a34 | |||
| ef979d2838 | |||
| 47912d237c | |||
| c427803504 |
@@ -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.
|
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.
|
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.
|
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.
|
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.
|
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.
|
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.
|
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 launcherViews: [NSView] = []
|
||||||
private var launcherFrameBeforeReview: NSRect?
|
private var launcherFrameBeforeReview: NSRect?
|
||||||
private var translation: TranslationController?
|
private var translation: TranslationController?
|
||||||
|
private var translationDraft: TranslationController.Draft?
|
||||||
private var emojiPicker: EmojiPickerController?
|
private var emojiPicker: EmojiPickerController?
|
||||||
private var notes: NotesController?
|
private var notes: NotesController?
|
||||||
private let notesDirectory: URL?
|
private let notesDirectory: URL?
|
||||||
@@ -886,7 +887,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
let accessibilityLabel: String
|
let accessibilityLabel: String
|
||||||
switch command {
|
switch command {
|
||||||
case .translate:
|
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() }
|
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 ?? "" }
|
||||||
@@ -931,6 +932,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
guard let self, let controller else { return }
|
guard let self, let controller else { return }
|
||||||
controller.view.removeFromSuperview()
|
controller.view.removeFromSuperview()
|
||||||
self.translation?.providerControl.removeFromSuperview()
|
self.translation?.providerControl.removeFromSuperview()
|
||||||
|
if let translation = self.translation { self.translationDraft = translation.draft }
|
||||||
self.translationHeader.isHidden = true
|
self.translationHeader.isHidden = true
|
||||||
self.translation = nil
|
self.translation = nil
|
||||||
self.emojiPicker = nil
|
self.emojiPicker = nil
|
||||||
@@ -976,7 +978,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
commandBack.target = self
|
commandBack.target = self
|
||||||
commandBack.action = #selector(goBackCommand)
|
commandBack.action = #selector(goBackCommand)
|
||||||
commandBack.isHidden = false
|
commandBack.isHidden = false
|
||||||
searchField.stringValue = ""
|
searchField.stringValue = command == .translate ? translationDraft?.input ?? "" : ""
|
||||||
searchField.placeholderAttributedString = NSAttributedString(
|
searchField.placeholderAttributedString = NSAttributedString(
|
||||||
string: placeholder,
|
string: placeholder,
|
||||||
attributes: previousPlaceholder?.attributes(at: 0, effectiveRange: nil) ?? [:]
|
attributes: previousPlaceholder?.attributes(at: 0, effectiveRange: nil) ?? [:]
|
||||||
@@ -1011,6 +1013,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
content.addSubview(controller.view)
|
content.addSubview(controller.view)
|
||||||
if let translation {
|
if let translation {
|
||||||
translationHeader.addArrangedSubview(translation.providerControl)
|
translationHeader.addArrangedSubview(translation.providerControl)
|
||||||
|
commandBack.setAccessibilityLabel(translation.backAccessibilityLabel)
|
||||||
|
commandBack.toolTip = translation.backAccessibilityLabel
|
||||||
translationHeader.isHidden = false
|
translationHeader.isHidden = false
|
||||||
}
|
}
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
|
|||||||
@@ -32,6 +32,14 @@ final class TranslationController: NSViewController {
|
|||||||
private func languageName(_ code: String) -> String { Self.languageName(code) }
|
private func languageName(_ code: String) -> String { Self.languageName(code) }
|
||||||
private var usesMyMemory: Bool
|
private var usesMyMemory: Bool
|
||||||
private var textToTranslate: String?
|
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 sourcePreview = NSTextField(wrappingLabelWithString: "")
|
||||||
private let output = NSTextView()
|
private let output = NSTextView()
|
||||||
private let settingsButton = NSButton(title: "Open Translate Settings", target: nil, action: nil)
|
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 = {
|
openAI: @escaping @MainActor (String, String, String, String, String) async throws -> String = {
|
||||||
try await TranslationAPI.translate($0, apiKey: $3, sourceLanguage: $1, targetLanguage: $2, context: $4)
|
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.defaults = defaults
|
||||||
self.credentialsStore = credentials
|
self.credentialsStore = credentials
|
||||||
self.myMemory = myMemory
|
self.myMemory = myMemory
|
||||||
self.openAI = openAI
|
self.openAI = openAI
|
||||||
self.pasteboard = pasteboard
|
self.pasteboard = pasteboard
|
||||||
|
textToTranslate = draft?.textToTranslate
|
||||||
usesMyMemory = defaults.string(forKey: "translationProvider") != "openai"
|
usesMyMemory = defaults.string(forKey: "translationProvider") != "openai"
|
||||||
sourceLanguage = defaults.string(forKey: "translationSourceLanguage")
|
sourceLanguage = defaults.string(forKey: "translationSourceLanguage")
|
||||||
.flatMap { Self.languages.contains($0) ? $0 : nil } ?? "en"
|
.flatMap { Self.languages.contains($0) ? $0 : nil } ?? "en"
|
||||||
@@ -285,6 +295,7 @@ final class TranslationController: NSViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func inputChanged() {
|
func inputChanged() {
|
||||||
|
committed = false
|
||||||
cancelRequest()
|
cancelRequest()
|
||||||
output.string = ""
|
output.string = ""
|
||||||
outputSecondaryRange = nil
|
outputSecondaryRange = nil
|
||||||
@@ -354,6 +365,7 @@ final class TranslationController: NSViewController {
|
|||||||
}
|
}
|
||||||
pasteboard.clearContents()
|
pasteboard.clearContents()
|
||||||
if pasteboard.setString(output.string, forType: .string) {
|
if pasteboard.setString(output.string, forType: .string) {
|
||||||
|
committed = true
|
||||||
dismiss?()
|
dismiss?()
|
||||||
} else {
|
} else {
|
||||||
setStatus("Could not copy the translation. Try again.", isError: true)
|
setStatus("Could not copy the translation. Try again.", isError: true)
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ public enum ApplicationCatalog {
|
|||||||
[
|
[
|
||||||
URL(fileURLWithPath: "/Applications", isDirectory: true),
|
URL(fileURLWithPath: "/Applications", isDirectory: true),
|
||||||
URL(fileURLWithPath: "/System/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),
|
URL(fileURLWithPath: "/System/Library/CoreServices/Applications", isDirectory: true),
|
||||||
FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Applications", isDirectory: true),
|
FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Applications", isDirectory: true),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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 AppKit
|
||||||
|
import ConjureKit
|
||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import Testing
|
||||||
@testable import Conjure
|
@testable import Conjure
|
||||||
@@ -146,11 +147,14 @@ func primaryActionCopiesCompletedTranslationAndResetsAfterEditing() async throws
|
|||||||
#expect(!dismissed)
|
#expect(!dismissed)
|
||||||
requests.pending[0].resume(returning: "こんにちは")
|
requests.pending[0].resume(returning: "こんにちは")
|
||||||
try await waitForTranslation { button.isEnabled }
|
try await waitForTranslation { button.isEnabled }
|
||||||
|
#expect(tool.draft?.input == "Hello")
|
||||||
tool.performPrimaryAction()
|
tool.performPrimaryAction()
|
||||||
#expect(pasteboard.string(forType: .string) == "こんにちは")
|
#expect(pasteboard.string(forType: .string) == "こんにちは")
|
||||||
#expect(dismissed)
|
#expect(dismissed)
|
||||||
|
#expect(tool.draft == nil)
|
||||||
dismissed = false
|
dismissed = false
|
||||||
tool.inputChanged()
|
tool.inputChanged()
|
||||||
|
#expect(tool.draft?.input == "Hello")
|
||||||
button.performClick(nil)
|
button.performClick(nil)
|
||||||
try await waitForTranslation { requests.pending.count == 2 }
|
try await waitForTranslation { requests.pending.count == 2 }
|
||||||
#expect(!dismissed)
|
#expect(!dismissed)
|
||||||
@@ -435,4 +439,73 @@ func blankContextTranslatesWithoutSwappingAndProviderChangesRestoreOriginalText(
|
|||||||
requests.pending[2].resume(returning: "やあ")
|
requests.pending[2].resume(returning: "やあ")
|
||||||
try await waitForTranslation { output.string == "やあ" }
|
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