ui: move translation credentials into settings
This commit is contained in:
@@ -8,6 +8,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
private let searchField = NSSearchField()
|
||||
private let searchSymbol = IconView(font: .systemFont(ofSize: 20))
|
||||
private let commandBack = NSButton(title: "", target: nil, action: nil)
|
||||
private let translationHeader = NSStackView()
|
||||
private let tableView = ApplicationTableView()
|
||||
private let statusLabel = NSTextField(labelWithString: "Finding applications…")
|
||||
private let emptyLabel = NSTextField(labelWithString: "Finding applications…")
|
||||
@@ -276,7 +277,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
commandBack.widthAnchor.constraint(equalToConstant: IconStyle.applicationSize(for: searchFont)),
|
||||
commandBack.heightAnchor.constraint(equalToConstant: IconStyle.applicationSize(for: searchFont)),
|
||||
])
|
||||
let header = NSStackView(views: [searchSymbol, commandBack, searchField])
|
||||
translationHeader.isHidden = true
|
||||
let header = NSStackView(views: [searchSymbol, commandBack, searchField, translationHeader])
|
||||
header.orientation = .horizontal
|
||||
header.alignment = .centerY
|
||||
header.spacing = 12
|
||||
@@ -861,6 +863,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
let close: () -> Void = { [weak self, weak controller] in
|
||||
guard let self, let controller else { return }
|
||||
controller.view.removeFromSuperview()
|
||||
self.translation?.providerPicker.removeFromSuperview()
|
||||
self.translationHeader.isHidden = true
|
||||
self.translation = nil
|
||||
self.emojiPicker = nil
|
||||
self.notes = nil
|
||||
@@ -907,6 +911,10 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
}
|
||||
controller.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
content.addSubview(controller.view)
|
||||
if let translation {
|
||||
translationHeader.addArrangedSubview(translation.providerPicker)
|
||||
translationHeader.isHidden = false
|
||||
}
|
||||
NSLayoutConstraint.activate([
|
||||
controller.view.leadingAnchor.constraint(equalTo: content.leadingAnchor),
|
||||
controller.view.trailingAnchor.constraint(equalTo: content.trailingAnchor),
|
||||
|
||||
@@ -5,6 +5,7 @@ import ServiceManagement
|
||||
@MainActor
|
||||
final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
private let shortcuts: GlobalShortcuts
|
||||
private let translationSettings = TranslationSettingsController()
|
||||
private let destinationSelector = NSPopUpButton(frame: .zero, pullsDown: false)
|
||||
private let commandSpaceButton = NSButton(title: "Use ⌘Space", target: nil, action: nil)
|
||||
private var shortcut: GlobalShortcut { shortcuts.bindings[destinationSelector.indexOfSelectedItem] }
|
||||
@@ -146,15 +147,20 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
let loginHeading = NSTextField(labelWithString: "Startup")
|
||||
loginHeading.font = .systemFont(ofSize: 20, weight: .semibold)
|
||||
loginHeading.textColor = SunsetPalette.text
|
||||
let translationHeading = NSTextField(labelWithString: "Conjure Translate")
|
||||
translationHeading.font = .systemFont(ofSize: 20, weight: .semibold)
|
||||
translationHeading.textColor = SunsetPalette.text
|
||||
|
||||
let stack = NSStackView(views: [
|
||||
heading, destinationSelector, controls, instructions, statusLabel, errorLabel, spotlight, keyboardSettings,
|
||||
loginHeading, loginToggle, loginStatus, loginSettings, recoveryLabel, recoveryButton
|
||||
loginHeading, loginToggle, loginStatus, loginSettings, recoveryLabel, recoveryButton,
|
||||
translationHeading, translationSettings.view
|
||||
])
|
||||
stack.orientation = .vertical
|
||||
stack.alignment = .leading
|
||||
stack.spacing = 12
|
||||
stack.setCustomSpacing(32, after: keyboardSettings)
|
||||
stack.setCustomSpacing(32, after: recoveryButton)
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
let content = NSVisualEffectView()
|
||||
content.material = .hudWindow
|
||||
@@ -165,12 +171,27 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
background.autoresizingMask = [.width, .height]
|
||||
background.setAccessibilityElement(false)
|
||||
content.addSubview(background)
|
||||
content.addSubview(stack)
|
||||
let scroll = NSScrollView()
|
||||
scroll.hasVerticalScroller = true
|
||||
scroll.autohidesScrollers = true
|
||||
scroll.drawsBackground = false
|
||||
scroll.translatesAutoresizingMaskIntoConstraints = false
|
||||
let document = SettingsDocumentView()
|
||||
document.translatesAutoresizingMaskIntoConstraints = false
|
||||
document.addSubview(stack)
|
||||
scroll.documentView = document
|
||||
content.addSubview(scroll)
|
||||
NSLayoutConstraint.activate([
|
||||
stack.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 24),
|
||||
stack.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -24),
|
||||
stack.topAnchor.constraint(equalTo: (window.contentLayoutGuide as! NSLayoutGuide).topAnchor, constant: 24),
|
||||
stack.bottomAnchor.constraint(lessThanOrEqualTo: content.bottomAnchor, constant: -24),
|
||||
scroll.leadingAnchor.constraint(equalTo: content.leadingAnchor),
|
||||
scroll.trailingAnchor.constraint(equalTo: content.trailingAnchor),
|
||||
scroll.topAnchor.constraint(equalTo: (window.contentLayoutGuide as! NSLayoutGuide).topAnchor),
|
||||
scroll.bottomAnchor.constraint(equalTo: content.bottomAnchor),
|
||||
document.widthAnchor.constraint(equalTo: scroll.contentView.widthAnchor),
|
||||
stack.leadingAnchor.constraint(equalTo: document.leadingAnchor, constant: 24),
|
||||
stack.trailingAnchor.constraint(equalTo: document.trailingAnchor, constant: -24),
|
||||
stack.topAnchor.constraint(equalTo: document.topAnchor, constant: 24),
|
||||
stack.bottomAnchor.constraint(equalTo: document.bottomAnchor, constant: -24),
|
||||
translationSettings.view.widthAnchor.constraint(equalTo: stack.widthAnchor),
|
||||
recorder.widthAnchor.constraint(greaterThanOrEqualToConstant: 190)
|
||||
])
|
||||
for label in [instructions, statusLabel, errorLabel, spotlight, loginStatus, recoveryLabel] {
|
||||
@@ -389,3 +410,7 @@ private final class ShortcutRecorderButton: NSButton {
|
||||
return resigned
|
||||
}
|
||||
}
|
||||
|
||||
private final class SettingsDocumentView: NSView {
|
||||
override var isFlipped: Bool { true }
|
||||
}
|
||||
|
||||
@@ -3,13 +3,11 @@ import ConjureKit
|
||||
import Security
|
||||
|
||||
@MainActor
|
||||
final class TranslationController: NSViewController, NSTextFieldDelegate {
|
||||
private let keyField = NSSecureTextField()
|
||||
private let provider = NSPopUpButton()
|
||||
private let credentials = NSStackView()
|
||||
private var usesMyMemory: Bool { provider.indexOfSelectedItem == 0 }
|
||||
final class TranslationController: NSViewController {
|
||||
let providerPicker = NSPopUpButton()
|
||||
private var usesMyMemory: Bool { providerPicker.indexOfSelectedItem == 0 }
|
||||
private let output = NSTextView()
|
||||
private let disclosure = NSTextField(wrappingLabelWithString: "")
|
||||
private let settingsButton = NSButton(title: "Open Translate Settings", target: nil, action: nil)
|
||||
private let status = NSTextField(wrappingLabelWithString: "")
|
||||
private let translateButton = NSButton(title: "Translate", target: nil, action: nil)
|
||||
private var task: Task<Void, Never>?
|
||||
@@ -40,19 +38,16 @@ final class TranslationController: NSViewController, NSTextFieldDelegate {
|
||||
let title = NSTextField(labelWithString: "Conjure Translate · English → Japanese")
|
||||
title.font = .systemFont(ofSize: 13, weight: .semibold)
|
||||
title.textColor = SunsetPalette.text
|
||||
provider.addItems(withTitles: ["MyMemory (no key)", "OpenAI · gpt-6-luna"])
|
||||
provider.selectItem(at: defaults.string(forKey: "translationProvider") == "openai" ? 1 : 0)
|
||||
provider.setAccessibilityLabel("Translation provider")
|
||||
provider.target = self
|
||||
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))
|
||||
for control in [keyField, save, forget] { credentials.addArrangedSubview(control) }
|
||||
credentials.distribution = .fill
|
||||
providerPicker.addItems(withTitles: ["MyMemory (no key)", "OpenAI · gpt-6-luna"])
|
||||
providerPicker.selectItem(at: defaults.string(forKey: "translationProvider") == "openai" ? 1 : 0)
|
||||
providerPicker.setAccessibilityLabel("Translation provider")
|
||||
providerPicker.target = self
|
||||
providerPicker.action = #selector(providerChanged)
|
||||
settingsButton.target = self
|
||||
settingsButton.action = #selector(openSettings)
|
||||
settingsButton.isHidden = true
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(credentialsChanged),
|
||||
name: Notification.Name("ConjureTranslationCredentialsChanged"), object: nil)
|
||||
translateButton.target = self
|
||||
translateButton.action = #selector(translate)
|
||||
translateButton.keyEquivalent = "\r"
|
||||
@@ -79,7 +74,7 @@ final class TranslationController: NSViewController, NSTextFieldDelegate {
|
||||
returnKey.centerYAnchor.constraint(equalTo: actionPill.centerYAnchor),
|
||||
returnKey.trailingAnchor.constraint(equalTo: actionPill.trailingAnchor, constant: -12),
|
||||
])
|
||||
let footer = NSStackView(views: [status, NSView(), actionPill])
|
||||
let footer = NSStackView(views: [status, settingsButton, NSView(), actionPill])
|
||||
footer.orientation = .horizontal
|
||||
footer.alignment = .centerY
|
||||
footer.spacing = 12
|
||||
@@ -98,17 +93,13 @@ final class TranslationController: NSViewController, NSTextFieldDelegate {
|
||||
output.isVerticallyResizable = true
|
||||
output.autoresizingMask = [.width]
|
||||
output.textContainer?.widthTracksTextView = true
|
||||
disclosure.textColor = SunsetPalette.secondaryText
|
||||
disclosure.font = .systemFont(ofSize: 12)
|
||||
disclosure.setAccessibilityLabel("Translation provider, privacy, and limits")
|
||||
status.font = .systemFont(ofSize: 12)
|
||||
status.setAccessibilityLabel("Translation status and errors")
|
||||
status.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
let stack = NSStackView(views: [title, provider, disclosure, credentials, scroll])
|
||||
let stack = NSStackView(views: [title, scroll])
|
||||
stack.orientation = .vertical
|
||||
stack.alignment = .leading
|
||||
stack.spacing = 12
|
||||
stack.setCustomSpacing(4, after: provider)
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(stack)
|
||||
NSLayoutConstraint.activate([
|
||||
@@ -120,15 +111,17 @@ final class TranslationController: NSViewController, NSTextFieldDelegate {
|
||||
footer.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -8),
|
||||
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),
|
||||
])
|
||||
updateProvider()
|
||||
inputChanged()
|
||||
}
|
||||
|
||||
func controlTextDidChange(_ notification: Notification) { inputChanged() }
|
||||
@objc private func openSettings() {
|
||||
NSApp.sendAction(NSSelectorFromString("showSettings:"), to: NSApp.delegate, from: self)
|
||||
}
|
||||
|
||||
@objc private func credentialsChanged() { inputChanged() }
|
||||
|
||||
@objc func goBack() {
|
||||
cancelRequest()
|
||||
@@ -138,45 +131,15 @@ final class TranslationController: NSViewController, NSTextFieldDelegate {
|
||||
func inputChanged() {
|
||||
cancelRequest()
|
||||
output.string = ""
|
||||
settingsButton.isHidden = true
|
||||
setStatus("Press Return to translate.")
|
||||
}
|
||||
|
||||
@objc private func providerChanged() {
|
||||
defaults.set(usesMyMemory ? "mymemory" : "openai", forKey: "translationProvider")
|
||||
keyField.stringValue = ""
|
||||
updateProvider()
|
||||
}
|
||||
|
||||
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 {
|
||||
setStatus("Enter a valid API key before saving.", isError: true)
|
||||
return
|
||||
}
|
||||
do {
|
||||
try credentialsStore.save(key)
|
||||
keyField.stringValue = ""
|
||||
setStatus("API key saved in Keychain. Press Return to translate.")
|
||||
} catch { setStatus(error.localizedDescription, isError: true) }
|
||||
}
|
||||
|
||||
@objc private func forgetKey() {
|
||||
cancelRequest()
|
||||
do {
|
||||
try credentialsStore.forget()
|
||||
keyField.stringValue = ""
|
||||
setStatus("API key removed.")
|
||||
} catch { setStatus(error.localizedDescription, isError: true) }
|
||||
}
|
||||
|
||||
private func cancelRequest() {
|
||||
task?.cancel()
|
||||
task = nil
|
||||
@@ -198,14 +161,15 @@ final class TranslationController: NSViewController, NSTextFieldDelegate {
|
||||
let myMemory = usesMyMemory
|
||||
var key = ""
|
||||
if !myMemory {
|
||||
if !keyField.stringValue.isEmpty { saveKey(); guard keyField.stringValue.isEmpty else { return } }
|
||||
do {
|
||||
guard let savedKey = try credentialsStore.load() else {
|
||||
setStatus("Enter and save your OpenAI API key first.", isError: true)
|
||||
settingsButton.isHidden = false
|
||||
setStatus("Save your OpenAI API key in Settings first.", isError: true)
|
||||
return
|
||||
}
|
||||
key = savedKey
|
||||
} catch {
|
||||
settingsButton.isHidden = false
|
||||
setStatus(error.localizedDescription, isError: true)
|
||||
return
|
||||
}
|
||||
@@ -225,6 +189,7 @@ final class TranslationController: NSViewController, NSTextFieldDelegate {
|
||||
self.setStatus("Japanese · Select text to copy (⌘C).")
|
||||
} catch {
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
self.settingsButton.isHidden = myMemory
|
||||
self.setStatus("Translation failed: \(error.localizedDescription)", isError: true)
|
||||
}
|
||||
guard let self else { return }
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import AppKit
|
||||
|
||||
@MainActor
|
||||
final class TranslationSettingsController: NSViewController {
|
||||
private let keyField = NSSecureTextField()
|
||||
private let status = NSTextField(wrappingLabelWithString: "")
|
||||
private let credentials: TranslationController.Credentials
|
||||
|
||||
init(credentials: TranslationController.Credentials = .keychain) {
|
||||
self.credentials = credentials
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("Conjure creates Translate settings programmatically") }
|
||||
|
||||
override func loadView() {
|
||||
view = NSView()
|
||||
keyField.placeholderString = "OpenAI API key (saved in Keychain)"
|
||||
keyField.setAccessibilityLabel("OpenAI API key")
|
||||
let save = NSButton(title: "Save Key", target: self, action: #selector(saveKey))
|
||||
let forget = NSButton(title: "Forget Key", target: self, action: #selector(forgetKey))
|
||||
for button in [save, forget] {
|
||||
button.bezelStyle = .rounded
|
||||
button.contentTintColor = SunsetPalette.accent
|
||||
}
|
||||
let controls = NSStackView(views: [keyField, save, forget])
|
||||
controls.orientation = .horizontal
|
||||
controls.alignment = .centerY
|
||||
controls.spacing = 8
|
||||
keyField.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
||||
keyField.widthAnchor.constraint(greaterThanOrEqualToConstant: 220).isActive = true
|
||||
status.setAccessibilityLabel("Translation credential status and errors")
|
||||
let myMemory = NSTextField(wrappingLabelWithString:
|
||||
"MyMemory · No key · 500 bytes/request · 5,000 characters/day. Text is sent to MyMemory only when you translate; avoid sensitive content."
|
||||
)
|
||||
let openAI = NSTextField(wrappingLabelWithString:
|
||||
"OpenAI · gpt-6-luna · Text is sent to OpenAI only when you explicitly translate. Saving a key does not send your text. Your API key is stored in Keychain."
|
||||
)
|
||||
for label in [status, myMemory, openAI] {
|
||||
label.textColor = SunsetPalette.secondaryText
|
||||
}
|
||||
let stack = NSStackView(views: [controls, status, myMemory, openAI])
|
||||
stack.orientation = .vertical
|
||||
stack.alignment = .leading
|
||||
stack.spacing = 12
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(stack)
|
||||
NSLayoutConstraint.activate([
|
||||
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
stack.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
stack.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
stack.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
controls.widthAnchor.constraint(equalTo: stack.widthAnchor),
|
||||
])
|
||||
for label in [status, myMemory, openAI] {
|
||||
label.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
|
||||
}
|
||||
do {
|
||||
setStatus(try credentials.load() == nil ? "No OpenAI API key saved." : "OpenAI API key saved in Keychain.")
|
||||
} catch { setStatus(error.localizedDescription, isError: true) }
|
||||
}
|
||||
|
||||
@objc private func saveKey() {
|
||||
let key = keyField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !key.isEmpty, !key.contains(where: { $0.isNewline }) else {
|
||||
setStatus("Enter a valid API key before saving.", isError: true)
|
||||
return
|
||||
}
|
||||
do {
|
||||
try credentials.save(key)
|
||||
keyField.stringValue = ""
|
||||
setStatus("API key saved in Keychain.")
|
||||
NotificationCenter.default.post(name: Notification.Name("ConjureTranslationCredentialsChanged"), object: self)
|
||||
} catch { setStatus(error.localizedDescription, isError: true) }
|
||||
}
|
||||
|
||||
@objc private func forgetKey() {
|
||||
do {
|
||||
try credentials.forget()
|
||||
keyField.stringValue = ""
|
||||
setStatus("API key removed.")
|
||||
NotificationCenter.default.post(name: Notification.Name("ConjureTranslationCredentialsChanged"), object: self)
|
||||
} catch { setStatus(error.localizedDescription, isError: true) }
|
||||
}
|
||||
|
||||
private func setStatus(_ message: String, isError: Bool = false) {
|
||||
status.stringValue = message
|
||||
status.textColor = isError ? .systemRed : SunsetPalette.secondaryText
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user