fix: unify navigation context and feedback across tools

This commit is contained in:
Valdemar Lorenzen
2026-10-15 12:13:45 +02:00
parent 999cd73891
commit 6fd3931f3a
6 changed files with 238 additions and 44 deletions
+57 -16
View File
@@ -3,12 +3,13 @@ import ConjureKit
import Security
@MainActor
final class TranslationController: NSViewController {
final class TranslationController: NSViewController, NSTextFieldDelegate, NSTextViewDelegate {
private let keyField = NSSecureTextField()
private let provider = NSPopUpButton()
private let credentials = NSStackView()
private var usesMyMemory: Bool { provider.indexOfSelectedItem == 0 }
private let output = NSTextView()
private let disclosure = NSTextField(wrappingLabelWithString: "")
private let status = NSTextField(wrappingLabelWithString: "")
private let translateButton = NSButton(title: "Translate", target: nil, action: nil)
private var task: Task<Void, Never>?
@@ -24,7 +25,16 @@ final class TranslationController: NSViewController {
view = NSView()
let title = NSTextField(labelWithString: "Conjure Translate · English → Japanese")
title.font = .systemFont(ofSize: 13, weight: .semibold)
let heading = NSStackView(views: [title, NSView()])
let back = NSButton(title: "", target: self, action: #selector(goBack))
back.image = NSImage(systemSymbolName: "chevron.left", accessibilityDescription: nil)
back.imageScaling = .scaleProportionallyDown
back.isBordered = false
back.contentTintColor = SunsetPalette.secondaryText
back.setAccessibilityLabel("Back to applications")
back.toolTip = "Back to applications (Escape)"
let heading = NSStackView(views: [back, title, NSView()])
heading.alignment = .centerY
heading.spacing = 12
provider.addItems(withTitles: ["MyMemory (no key)", "OpenAI · gpt-6-luna"])
provider.selectItem(at: UserDefaults.standard.string(forKey: "translationProvider") == "openai" ? 1 : 0)
provider.setAccessibilityLabel("Translation provider")
@@ -32,6 +42,7 @@ final class TranslationController: NSViewController {
provider.action = #selector(providerChanged)
keyField.placeholderString = "OpenAI API key (saved in Keychain)"
keyField.setAccessibilityLabel("OpenAI API key")
keyField.delegate = self
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))
@@ -73,6 +84,7 @@ final class TranslationController: NSViewController {
output.textColor = SunsetPalette.text
output.font = .systemFont(ofSize: 20)
output.setAccessibilityLabel("Japanese translation")
output.delegate = self
let scroll = NSScrollView()
scroll.documentView = output
scroll.hasVerticalScroller = true
@@ -80,15 +92,21 @@ final class TranslationController: NSViewController {
output.isVerticallyResizable = true
output.autoresizingMask = [.width]
output.textContainer?.widthTracksTextView = true
status.textColor = SunsetPalette.secondaryText
disclosure.textColor = SunsetPalette.secondaryText
disclosure.font = .systemFont(ofSize: 12)
disclosure.setAccessibilityLabel("Translation provider, privacy, and limits")
status.font = .systemFont(ofSize: 12)
let stack = NSStackView(views: [heading, provider, credentials, scroll, status])
status.setAccessibilityLabel("Translation status and errors")
let stack = NSStackView(views: [heading, provider, disclosure, credentials, scroll, status])
stack.orientation = .vertical
stack.alignment = .leading
stack.spacing = 12
stack.setCustomSpacing(4, after: provider)
stack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(stack)
NSLayoutConstraint.activate([
back.widthAnchor.constraint(equalToConstant: 22),
back.heightAnchor.constraint(equalToConstant: 22),
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
stack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
stack.topAnchor.constraint(equalTo: view.topAnchor),
@@ -98,6 +116,7 @@ final class TranslationController: NSViewController {
footer.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -8),
footer.heightAnchor.constraint(equalToConstant: 36),
credentials.widthAnchor.constraint(equalTo: stack.widthAnchor),
disclosure.widthAnchor.constraint(equalTo: stack.widthAnchor),
scroll.widthAnchor.constraint(equalTo: stack.widthAnchor),
scroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 100),
status.widthAnchor.constraint(equalTo: stack.widthAnchor),
@@ -105,6 +124,22 @@ final class TranslationController: NSViewController {
updateProvider()
}
override func cancelOperation(_ sender: Any?) { goBack() }
func control(_ control: NSControl, textView: NSTextView, doCommandBy selector: Selector) -> Bool {
guard selector == #selector(NSResponder.cancelOperation(_:)) else { return false }
goBack()
return true
}
func textView(_ textView: NSTextView, doCommandBy selector: Selector) -> Bool {
guard selector == #selector(NSResponder.cancelOperation(_:)) else { return false }
goBack()
return true
}
func controlTextDidChange(_ notification: Notification) { inputChanged() }
@objc func goBack() {
task?.cancel()
task = nil
@@ -116,9 +151,7 @@ final class TranslationController: NSViewController {
task = nil
translateButton.isEnabled = true
output.string = ""
status.stringValue = usesMyMemory
? "MyMemory · No key · 500 bytes/request · 5,000 characters/day. Text is sent to MyMemory; avoid sensitive content."
: "OpenAI · gpt-6-luna · Text is sent to OpenAI only when you translate."
setStatus("Press Return to translate.")
}
@objc private func providerChanged() {
@@ -129,13 +162,16 @@ final class TranslationController: NSViewController {
private func updateProvider() {
credentials.isHidden = usesMyMemory
disclosure.stringValue = usesMyMemory
? "MyMemory · No key · 500 bytes/request · 5,000 characters/day. Text is sent to MyMemory; avoid sensitive content."
: "OpenAI · gpt-6-luna · Text is sent to OpenAI only when you translate."
inputChanged()
}
@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."
setStatus("Enter a valid API key before saving.", isError: true)
return
}
let data = Data(key.utf8)
@@ -148,7 +184,7 @@ final class TranslationController: NSViewController {
}
if result == errSecSuccess {
keyField.stringValue = ""
status.stringValue = "API key saved in Keychain. Press Return to translate."
setStatus("API key saved in Keychain. Press Return to translate.")
} else { showKeychainError(result) }
}
@@ -159,19 +195,24 @@ final class TranslationController: NSViewController {
let result = SecItemDelete(Self.credential as CFDictionary)
if result == errSecSuccess || result == errSecItemNotFound {
keyField.stringValue = ""
status.stringValue = "API key removed."
setStatus("API key removed.")
} else { showKeychainError(result) }
}
private func showKeychainError(_ code: OSStatus) {
status.stringValue = "Keychain: \(SecCopyErrorMessageString(code, nil) as String? ?? String(code))"
setStatus("Keychain error: \(SecCopyErrorMessageString(code, nil) as String? ?? String(code))", isError: true)
}
private func setStatus(_ message: String, isError: Bool = false) {
status.stringValue = message
status.textColor = isError ? .systemRed : SunsetPalette.secondaryText
}
@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."
setStatus("Enter English text in the search bar.", isError: true)
return
}
let myMemory = usesMyMemory
@@ -183,14 +224,14 @@ final class TranslationController: NSViewController {
var item: CFTypeRef?
let result = SecItemCopyMatching(query as CFDictionary, &item)
guard result == errSecSuccess, let data = item as? Data, let savedKey = String(data: data, encoding: .utf8) else {
if result == errSecItemNotFound { status.stringValue = "Enter and save your OpenAI API key first." }
if result == errSecItemNotFound { setStatus("Enter and save your OpenAI API key first.", isError: true) }
else { showKeychainError(result) }
return
}
key = savedKey
}
output.string = ""
status.stringValue = "Translating…"
setStatus("Translating…")
translateButton.isEnabled = false
task = Task { [weak self] in
do {
@@ -199,10 +240,10 @@ final class TranslationController: NSViewController {
: 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)."
self.setStatus("Japanese · Select text to copy (⌘C).")
} catch {
guard !Task.isCancelled, let self else { return }
self.status.stringValue = error.localizedDescription
self.setStatus("Translation failed: \(error.localizedDescription)", isError: true)
}
guard let self else { return }
self.task = nil