feat: add Conjure Translate command

This commit is contained in:
Valdemar Lorenzen
2026-10-15 10:22:50 +02:00
parent 796e8a820c
commit fa3fc762ec
6 changed files with 378 additions and 16 deletions
+94 -15
View File
@@ -31,6 +31,11 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
private var uninstallReview: UninstallReviewController?
private var launcherViews: [NSView] = []
private var launcherFrameBeforeReview: NSRect?
private var translation: TranslationController?
private var showsTranslationCommand = true
private var translationCommandRow: Int? {
calculation == nil && showsTranslationCommand ? results.count : nil
}
init(history: LaunchHistory = LaunchHistory(), uninstaller: ApplicationUninstaller = ApplicationUninstaller()) {
self.history = history
@@ -63,13 +68,14 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
window.dismiss = { [weak self] in self?.dismiss() }
window.canUseNumberShortcuts = { [weak self] in
guard let self else { return false }
return !self.isLaunching && self.calculation == nil && !self.results.isEmpty
&& self.launcherFrameBeforeReview == nil && !self.actions.isShown
return !self.isLaunching && self.calculation == nil && self.resultCount > 0
&& self.launcherFrameBeforeReview == nil && !self.actions.isShown && self.translation == nil
}
window.numberHintsChanged = { [weak self] in self?.updateNumberHints() }
window.launchNumber = { [weak self] index in
guard let self, self.results.indices.contains(index) else { return }
self.openApplication(self.results[index])
guard let self else { return }
if index == self.translationCommandRow { self.showTranslation() }
else if self.results.indices.contains(index) { self.openApplication(self.results[index]) }
}
window.dragEnded = { [weak self] in
guard let self else { return }
@@ -95,7 +101,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
func present() {
(window as? LauncherPanel)?.beginNumberHintSession()
if launcherFrameBeforeReview != nil {
if launcherFrameBeforeReview != nil || translation != nil {
showWindow(nil)
NSApp.activate()
window?.makeKeyAndOrderFront(nil)
@@ -131,6 +137,10 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
func dismiss(restoringFocus: Bool = true) {
guard !restoringFocus || (!isLaunching && window?.attachedSheet == nil) else { return }
if let review = uninstallReview { review.goBack(); return }
if let translation {
translation.goBack()
if restoringFocus { return }
}
guard launcherFrameBeforeReview == nil else { return }
actions.close()
alignmentGuides?.end()
@@ -417,6 +427,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
}
private func updateResults(preservingSelection: Bool = false, allowingCurrencyFetch: Bool = true) {
guard translation == nil else { return }
let selectedURL = preservingSelection && results.indices.contains(tableView.selectedRow)
? results[tableView.selectedRow].id : nil
let query = searchField.stringValue
@@ -447,6 +458,9 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
detail: "\(answer.detail) · \(cacheStatus)"
)
}
showsTranslationCommand = query.split(whereSeparator: \.isWhitespace).allSatisfy {
"Conjure Translate".localizedStandardContains(String($0))
}
if calculation != nil { (window as? LauncherPanel)?.resetNumberHints() }
if calculation != nil {
results = []
@@ -456,7 +470,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
results = history.results(for: searchField.stringValue, in: applications)
}
sectionLabel.stringValue = isCurrencyCalculation ? "Daily currency rates"
: calculation == nil ? "Applications" : "Calculator"
: calculation == nil ? "Applications & Commands" : "Calculator"
tableView.setAccessibilityLabel(isCurrencyCalculation ? "Daily currency conversion result"
: calculation == nil ? "Applications" : "Calculator result")
tableView.reloadData()
@@ -473,17 +487,23 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
statusLabel.stringValue = "Finding applications…"
} else {
emptyLabel.stringValue = applications.isEmpty ? "No applications found" : "No matching applications"
statusLabel.stringValue = results.count == 1 ? "1 application" : "\(results.count) applications"
statusLabel.stringValue = "\(results.count) applications" + (showsTranslationCommand ? " · 1 command" : "")
}
updateOpenButton()
(window as? LauncherPanel)?.sampleNumberHints()
}
func controlTextDidChange(_ notification: Notification) {
if let translation { translation.inputChanged(); return }
updateResults()
}
func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
if let translation {
if commandSelector == #selector(NSResponder.insertNewline(_:)) { translation.translate(); return true }
if commandSelector == #selector(NSResponder.cancelOperation(_:)) { translation.goBack(); return true }
return false
}
switch commandSelector {
case #selector(NSResponder.moveDown(_:)):
moveSelection(by: 1)
@@ -499,9 +519,10 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
return true
}
private var resultCount: Int { calculation == nil ? results.count : 1 }
private var resultCount: Int { calculation == nil ? results.count + (showsTranslationCommand ? 1 : 0) : 1 }
private func moveSelection(by offset: Int) {
guard translation == nil else { return }
guard resultCount > 0 else { return }
let row = min(max(tableView.selectedRow + offset, 0), resultCount - 1)
tableView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false)
@@ -509,13 +530,13 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
}
private func updateOpenButton() {
let canAct = !isLaunching && launcherFrameBeforeReview == nil && tableView.selectedRow >= 0
openButton.title = calculation == nil ? "Open Application" : "Copy Answer"
let canAct = !isLaunching && launcherFrameBeforeReview == nil && translation == nil && tableView.selectedRow >= 0
openButton.title = calculation == nil ? (tableView.selectedRow == translationCommandRow ? "Open Command" : "Open Application") : "Copy Answer"
openButton.toolTip = calculation == nil
? "Open the selected application (Return). Use ↑ and ↓ to select."
? "Open the selected entry (Return). Use ↑ and ↓ to select."
: "Copy the answer to the clipboard (Return)."
openButton.isEnabled = canAct && (calculation.map { $0.copyValue != nil }
?? results.indices.contains(tableView.selectedRow))
?? (results.indices.contains(tableView.selectedRow) || tableView.selectedRow == translationCommandRow))
actionsButton.isEnabled = canAct && calculation == nil && results.indices.contains(tableView.selectedRow)
}
@@ -553,6 +574,14 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
.withSymbolConfiguration(.init(pointSize: LauncherRowView.iconSize, weight: .regular))
cell.imageView?.contentTintColor = SunsetPalette.accent
cell.toolTip = calculation.detail
} else if row == translationCommandRow {
cell.textField?.stringValue = "Conjure Translate"
cell.textField?.setAccessibilityLabel(nil)
cell.locationLabel.isHidden = true
cell.kindLabel.stringValue = "Command"
cell.imageView?.image = NSImage(systemSymbolName: "character.bubble", accessibilityDescription: nil)
cell.imageView?.contentTintColor = SunsetPalette.accent
cell.toolTip = "Translate English to Japanese"
} else {
let application = results[row]
cell.textField?.stringValue = application.name
@@ -582,9 +611,9 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
private func configureNumberHint(_ cell: ApplicationCell, row: Int, animated: Bool = false) {
let visible = (window as? LauncherPanel)?.numberHintsVisible == true
&& calculation == nil && row < 9 && results.indices.contains(row)
&& calculation == nil && row < 9 && row < resultCount && translation == nil
cell.setNumberHint(visible ? String(row + 1) : nil, animated: animated)
cell.setAccessibilityHelp(visible ? "Command-\(row + 1) opens this application" : nil)
cell.setAccessibilityHelp(visible ? "Command-\(row + 1) opens this entry" : nil)
}
private func updateNumberHints() {
@@ -605,6 +634,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
@objc private func showActions() {
guard !isLaunching, launcherFrameBeforeReview == nil, window?.attachedSheet == nil, results.indices.contains(tableView.selectedRow) else { return }
guard translation == nil else { return }
(window as? LauncherPanel)?.resetNumberHints()
let application = results[tableView.selectedRow]
actions.open = { [weak self] in self?.openApplication(application) }
@@ -692,7 +722,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
}
@objc private func openSelection() {
guard !isLaunching, launcherFrameBeforeReview == nil, window?.attachedSheet == nil else { return }
guard !isLaunching, translation == nil, launcherFrameBeforeReview == nil, window?.attachedSheet == nil else { return }
if tableView.selectedRow == translationCommandRow { showTranslation(); return }
if let calculation {
guard tableView.selectedRow == 0, let value = calculation.copyValue else { return }
let pasteboard = NSPasteboard.general
@@ -738,6 +769,54 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
updateOpenButton()
}
}
private func showTranslation() {
guard translation == nil, let content = window?.contentView else { return }
let controller = TranslationController()
translation = controller
controller.input = { [weak self] in self?.searchField.stringValue ?? "" }
let previousQuery = searchField.stringValue
controller.closed = { [weak self, weak controller] in
guard let self, let controller else { return }
controller.view.removeFromSuperview()
self.translation = nil
for view in self.launcherViews.dropFirst() {
view.isHidden = false
view.setAccessibilityHidden(false)
view.alphaValue = 1
}
self.searchField.placeholderString = "Search apps, calculate, or convert…"
self.searchField.setAccessibilityLabel("Search applications, calculate, or convert units and currencies")
self.searchField.stringValue = previousQuery
self.openButton.keyEquivalent = "\r"
self.updateResults()
self.window?.makeFirstResponder(self.searchField)
}
actions.close()
(window as? LauncherPanel)?.resetNumberHints()
openButton.keyEquivalent = ""
searchField.stringValue = ""
searchField.placeholderString = "Enter English text to translate…"
searchField.setAccessibilityLabel("English text to translate into Japanese")
for view in launcherViews.dropFirst() {
view.isHidden = true
view.setAccessibilityHidden(true)
}
controller.view.translatesAutoresizingMaskIntoConstraints = false
controller.view.alphaValue = 0
content.addSubview(controller.view)
NSLayoutConstraint.activate([
controller.view.leadingAnchor.constraint(equalTo: content.leadingAnchor),
controller.view.trailingAnchor.constraint(equalTo: content.trailingAnchor),
controller.view.topAnchor.constraint(equalTo: searchField.bottomAnchor, constant: 16),
controller.view.bottomAnchor.constraint(equalTo: content.bottomAnchor),
])
NSAnimationContext.runAnimationGroup { context in
context.duration = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion ? 0 : 0.18
controller.view.animator().alphaValue = 1
}
window?.makeFirstResponder(searchField)
}
}
@MainActor
+153
View File
@@ -0,0 +1,153 @@
import AppKit
import ConjureKit
import Security
@MainActor
final class TranslationController: NSViewController {
private let keyField = NSSecureTextField()
private let output = NSTextView()
private let status = NSTextField(wrappingLabelWithString: "English → Japanese · gpt-6-luna · Text is sent to OpenAI only when you translate.")
private let translateButton = NSButton(title: "Translate ↵", target: nil, action: nil)
private var task: Task<Void, Never>?
var input: (() -> String)?
var closed: (() -> Void)?
private static let credential: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "Conjure.OpenAI",
kSecAttrAccount as String: "translation",
]
override func loadView() {
view = NSView()
let back = NSButton(title: "‹ Back", target: self, action: #selector(goBack))
let title = NSTextField(labelWithString: "Conjure Translate · English → Japanese")
title.font = .systemFont(ofSize: 13, weight: .semibold)
let heading = NSStackView(views: [back, title, NSView()])
keyField.placeholderString = "OpenAI API key (saved in Keychain)"
keyField.setAccessibilityLabel("OpenAI API key")
keyField.widthAnchor.constraint(greaterThanOrEqualToConstant: 220).isActive = true
let save = NSButton(title: "Save Key", target: self, action: #selector(saveKey))
let forget = NSButton(title: "Forget Key", target: self, action: #selector(forgetKey))
let credentials = NSStackView(views: [keyField, save, forget])
credentials.distribution = .fill
translateButton.target = self
translateButton.action = #selector(translate)
translateButton.keyEquivalent = "\r"
output.isEditable = false
output.isSelectable = true
output.drawsBackground = false
output.textColor = SunsetPalette.text
output.font = .systemFont(ofSize: 20)
output.setAccessibilityLabel("Japanese translation")
let scroll = NSScrollView()
scroll.documentView = output
scroll.hasVerticalScroller = true
scroll.drawsBackground = false
output.isVerticallyResizable = true
output.autoresizingMask = [.width]
output.textContainer?.widthTracksTextView = true
status.textColor = SunsetPalette.secondaryText
status.font = .systemFont(ofSize: 12)
let stack = NSStackView(views: [heading, credentials, scroll, status, translateButton])
stack.orientation = .vertical
stack.alignment = .leading
stack.spacing = 12
stack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(stack)
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
stack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
stack.topAnchor.constraint(equalTo: view.topAnchor),
stack.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -16),
credentials.widthAnchor.constraint(equalTo: stack.widthAnchor),
scroll.widthAnchor.constraint(equalTo: stack.widthAnchor),
scroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 100),
status.widthAnchor.constraint(equalTo: stack.widthAnchor),
])
}
@objc func goBack() {
task?.cancel()
task = nil
closed?()
}
func inputChanged() {
task?.cancel()
task = nil
translateButton.isEnabled = true
output.string = ""
status.stringValue = "Press Return to translate English → Japanese with gpt-6-luna."
}
@objc private func saveKey() {
let key = keyField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty, !key.contains(where: { $0.isNewline }) else {
status.stringValue = "Enter a valid API key before saving."
return
}
let data = Data(key.utf8)
var result = SecItemUpdate(Self.credential as CFDictionary, [kSecValueData as String: data] as CFDictionary)
if result == errSecItemNotFound {
var item = Self.credential
item[kSecValueData as String] = data
item[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly
result = SecItemAdd(item as CFDictionary, nil)
}
if result == errSecSuccess {
keyField.stringValue = ""
status.stringValue = "API key saved in Keychain. Press Return to translate."
} else { showKeychainError(result) }
}
@objc private func forgetKey() {
task?.cancel()
task = nil
translateButton.isEnabled = true
let result = SecItemDelete(Self.credential as CFDictionary)
if result == errSecSuccess || result == errSecItemNotFound {
keyField.stringValue = ""
status.stringValue = "API key removed."
} else { showKeychainError(result) }
}
private func showKeychainError(_ code: OSStatus) {
status.stringValue = "Keychain: \(SecCopyErrorMessageString(code, nil) as String? ?? String(code))"
}
@objc func translate() {
guard task == nil else { return }
let text = input?() ?? ""
guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
status.stringValue = "Enter English text in the search bar."
return
}
if !keyField.stringValue.isEmpty { saveKey(); guard keyField.stringValue.isEmpty else { return } }
var query = Self.credential
query[kSecReturnData as String] = true
var item: CFTypeRef?
let result = SecItemCopyMatching(query as CFDictionary, &item)
guard result == errSecSuccess, let data = item as? Data, let key = String(data: data, encoding: .utf8) else {
if result == errSecItemNotFound { status.stringValue = "Enter and save your OpenAI API key first." }
else { showKeychainError(result) }
return
}
output.string = ""
status.stringValue = "Translating…"
translateButton.isEnabled = false
task = Task { [weak self] in
do {
let translation = try await TranslationAPI.translate(text, apiKey: key)
guard !Task.isCancelled, let self else { return }
self.output.string = translation
self.status.stringValue = "Japanese · Select text to copy (⌘C)."
} catch {
guard !Task.isCancelled, let self else { return }
self.status.stringValue = error.localizedDescription
}
guard let self else { return }
self.task = nil
self.translateButton.isEnabled = true
}
}
}