Files
conjure/Sources/Conjure/TranslationController.swift
T

450 lines
20 KiB
Swift

import AppKit
import ConjureKit
import Security
@MainActor
final class TranslationController: NSViewController {
private let providerButton = NSButton(title: "", target: nil, action: nil)
let providerControl = NSView()
private let languageButton = NSButton(title: "", target: nil, action: nil)
private let actionsButton = NSButton(title: "Actions", target: nil, action: nil)
private let actions = ApplicationActionsController()
private(set) var sourceLanguage = "en"
private(set) var targetLanguage = "ja"
var focusSearch: (() -> Void)?
var inputPresentationChanged: (() -> Void)?
var inputPlaceholder: String {
textToTranslate == nil ? "Enter \(languageName(sourceLanguage)) text to translate…"
: "Describe where or how it was used (optional)…"
}
var inputAccessibilityLabel: String {
textToTranslate == nil ? "\(languageName(sourceLanguage)) text to translate into \(languageName(targetLanguage))"
: "Context or situation for the translation (optional)"
}
var backAccessibilityLabel: String {
textToTranslate == nil ? "Back to applications" : "Edit original text"
}
private static let languages = Locale.LanguageCode.isoLanguageCodes.map(\.identifier).filter { $0.count == 2 }
.sorted { languageName($0).localizedCaseInsensitiveCompare(languageName($1)) == .orderedAscending }
private static func languageName(_ code: String) -> String {
Locale(identifier: "en").localizedString(forLanguageCode: code) ?? code
}
private func languageName(_ code: String) -> String { Self.languageName(code) }
private var usesMyMemory: Bool
private var textToTranslate: String?
private let sourcePreview = NSTextField(wrappingLabelWithString: "")
private let output = NSTextView()
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>?
var input: (() -> String)?
var replaceInput: ((String) -> Void)?
var closed: (() -> Void)?
var dismiss: (() -> Void)?
private let pasteboard: NSPasteboard
private let defaults: UserDefaults
private let credentialsStore: Credentials
private let myMemory: @MainActor (String, String, String) async throws -> String
private let openAI: @MainActor (String, String, String, String, String) async throws -> String
init(
defaults: UserDefaults = .standard,
credentials: Credentials = .keychain,
myMemory: @escaping @MainActor (String, String, String) async throws -> String = {
try await MyMemoryAPI.translate($0, sourceLanguage: $1, targetLanguage: $2)
},
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
) {
self.defaults = defaults
self.credentialsStore = credentials
self.myMemory = myMemory
self.openAI = openAI
self.pasteboard = pasteboard
usesMyMemory = defaults.string(forKey: "translationProvider") != "openai"
sourceLanguage = defaults.string(forKey: "translationSourceLanguage")
.flatMap { Self.languages.contains($0) ? $0 : nil } ?? "en"
targetLanguage = defaults.string(forKey: "translationTargetLanguage")
.flatMap { Self.languages.contains($0) ? $0 : nil } ?? "ja"
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError("Conjure creates Translate programmatically") }
override func loadView() {
view = NSView()
providerButton.title = usesMyMemory ? "MyMemory (no key)" : "OpenAI · gpt-6-luna"
providerButton.setAccessibilityLabel("Translation provider")
providerButton.target = self
providerButton.action = #selector(showProviders)
providerButton.keyEquivalent = "p"
providerButton.keyEquivalentModifierMask = .command
providerButton.toolTip = "Choose translation provider (Command-P)"
providerButton.isBordered = false
providerButton.font = .systemFont(ofSize: 12, weight: .medium)
providerButton.contentTintColor = SunsetPalette.text
let chevron = NSButton(image: NSImage(systemSymbolName: "chevron.down", accessibilityDescription: nil)!,
target: self, action: #selector(showProviders))
chevron.isBordered = false
chevron.contentTintColor = SunsetPalette.secondaryText
chevron.setAccessibilityElement(false)
stylePill(providerControl)
for control in [providerButton, chevron] {
control.translatesAutoresizingMaskIntoConstraints = false
providerControl.addSubview(control)
}
NSLayoutConstraint.activate([
providerControl.heightAnchor.constraint(equalToConstant: 36),
providerButton.leadingAnchor.constraint(equalTo: providerControl.leadingAnchor, constant: 14),
providerButton.centerYAnchor.constraint(equalTo: providerControl.centerYAnchor),
chevron.leadingAnchor.constraint(equalTo: providerButton.trailingAnchor, constant: 8),
chevron.centerYAnchor.constraint(equalTo: providerControl.centerYAnchor),
chevron.trailingAnchor.constraint(equalTo: providerControl.trailingAnchor, constant: -12),
chevron.widthAnchor.constraint(equalToConstant: 10),
])
languageButton.target = self
languageButton.action = #selector(showActions)
languageButton.isBordered = false
languageButton.font = .systemFont(ofSize: 12, weight: .medium)
languageButton.contentTintColor = SunsetPalette.text
languageButton.toolTip = "Change or swap translation languages (Command-K)"
languageButton.setAccessibilityLabel("Translation languages")
actionsButton.target = self
actionsButton.action = #selector(showActions)
actions.closed = { [weak self] in
self?.translateButton.keyEquivalent = "\r"
self?.focusSearch?()
}
actions.changeSourceLanguage = { [weak self] in self?.chooseLanguage(source: true) }
actions.changeTargetLanguage = { [weak self] in self?.chooseLanguage(source: false) }
actions.swapLanguages = { [weak self] in self?.swapLanguages() }
actions.selectProvider = { [weak self] in self?.selectProvider(openAI: $0) }
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(performPrimaryAction)
translateButton.toolTip = "Translate the input (Return). Escape dismisses Conjure."
let actionPill = ActionPill(primaryButton: translateButton, actionsButton: actionsButton)
let footer = NSStackView(views: [languageButton, status, settingsButton, NSView(), actionPill])
footer.orientation = .horizontal
footer.alignment = .centerY
footer.spacing = 12
footer.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(footer)
output.isEditable = false
output.isSelectable = true
output.drawsBackground = false
output.textColor = SunsetPalette.text
output.font = .systemFont(ofSize: 20)
output.textContainerInset = .zero
output.textContainer?.lineFragmentPadding = 0
output.setAccessibilityLabel("\(languageName(targetLanguage)) translation")
let scroll = NSScrollView()
scroll.documentView = output
scroll.hasVerticalScroller = true
scroll.drawsBackground = false
output.isVerticallyResizable = true
output.autoresizingMask = [.width]
output.textContainer?.widthTracksTextView = true
status.font = .systemFont(ofSize: 12)
status.setAccessibilityLabel("Translation status and errors")
status.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
sourcePreview.font = .systemFont(ofSize: 14)
sourcePreview.textColor = SunsetPalette.secondaryText
sourcePreview.maximumNumberOfLines = 3
sourcePreview.lineBreakMode = .byTruncatingTail
sourcePreview.setAccessibilityLabel("Original text to translate")
let body = NSStackView(views: [sourcePreview, scroll])
body.orientation = .vertical
body.alignment = .leading
body.spacing = 12
body.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(body)
NSLayoutConstraint.activate([
body.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
body.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
body.topAnchor.constraint(equalTo: view.topAnchor),
body.bottomAnchor.constraint(equalTo: footer.topAnchor, constant: -12),
sourcePreview.widthAnchor.constraint(equalTo: body.widthAnchor),
scroll.widthAnchor.constraint(equalTo: body.widthAnchor),
footer.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
footer.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -8),
footer.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -8),
footer.heightAnchor.constraint(equalToConstant: 36),
scroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 100),
])
inputChanged()
}
@objc private func openSettings() {
NSApp.sendAction(NSSelectorFromString("showSettings:"), to: NSApp.delegate, from: self)
}
@objc private func credentialsChanged() { inputChanged() }
private func stylePill(_ pill: NSView) {
pill.wantsLayer = true
pill.layer?.cornerRadius = 18
pill.layer?.borderWidth = 0.5
pill.layer?.borderColor = SunsetPalette.accent.withAlphaComponent(0.35).cgColor
pill.layer?.backgroundColor = SunsetPalette.background.withAlphaComponent(0.25).cgColor
}
@objc func showProviders() {
actions.showProviders(selectedOpenAI: !usesMyMemory, relativeTo: providerButton)
translateButton.keyEquivalent = actions.isShown ? "" : "\r"
}
@objc func showActions() {
actions.showTranslation(relativeTo: actionsButton)
translateButton.keyEquivalent = actions.isShown ? "" : "\r"
}
func handle(_ selector: Selector) -> Bool {
if actions.isShown { return actions.handle(selector) }
guard selector == #selector(NSResponder.insertNewline(_:)) else { return false }
performPrimaryAction()
return true
}
func handleControlKey(_ key: String) -> Bool {
guard actions.isShown, key == "j" || key == "k" else { return false }
actions.moveSelection(by: key == "j" ? 1 : -1)
return true
}
private func chooseLanguage(source: Bool) {
actions.selectLanguage = { [weak self] code in
guard let self else { return }
self.setLanguages(source: source ? code : self.sourceLanguage,
target: source ? self.targetLanguage : code)
}
actions.showLanguages(Self.languages.map { (code: $0, name: languageName($0)) },
selected: source ? sourceLanguage : targetLanguage,
source: source, relativeTo: actionsButton)
translateButton.keyEquivalent = actions.isShown ? "" : "\r"
}
func setLanguages(source: String, target: String) {
guard Self.languages.contains(source), Self.languages.contains(target) else { return }
sourceLanguage = source
targetLanguage = target
defaults.set(source, forKey: "translationSourceLanguage")
defaults.set(target, forKey: "translationTargetLanguage")
inputChanged()
inputPresentationChanged?()
}
func swapLanguages() { setLanguages(source: targetLanguage, target: sourceLanguage) }
func close() {
actions.close(animated: false)
cancelRequest()
closed?()
}
@objc func goBack() {
if actions.isShown {
actions.close()
} else if let text = textToTranslate {
textToTranslate = nil
replaceInput?(text)
inputChanged()
inputPresentationChanged?()
focusSearch?()
} else {
close()
}
}
func inputChanged() {
cancelRequest()
output.string = ""
let isEmpty = (input?() ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
if textToTranslate != nil {
translateButton.title = "Translate"
translateButton.toolTip = "Translate using the context, or leave it blank (Return)."
} else if isEmpty {
translateButton.title = "Swap languages"
translateButton.toolTip = "Swap source and target languages (Return)."
} else {
translateButton.title = usesMyMemory ? "Translate" : "Add context"
translateButton.toolTip = usesMyMemory ? "Translate the input (Return)."
: "Continue to context without sending the text (Return)."
}
sourcePreview.stringValue = textToTranslate.map { "Text to translate: \($0)" } ?? ""
sourcePreview.isHidden = textToTranslate == nil
settingsButton.isHidden = true
languageButton.title = "\(languageName(sourceLanguage)) → \(languageName(targetLanguage))"
output.setAccessibilityLabel("\(languageName(targetLanguage)) translation")
setStatus("")
}
func selectProvider(openAI: Bool) {
let newUsesMyMemory = !openAI
guard newUsesMyMemory != usesMyMemory else { return }
usesMyMemory = newUsesMyMemory
providerButton.title = usesMyMemory ? "MyMemory (no key)" : "OpenAI · gpt-6-luna"
defaults.set(openAI ? "openai" : "mymemory", forKey: "translationProvider")
if let text = textToTranslate {
textToTranslate = nil
replaceInput?(text)
}
inputChanged()
inputPresentationChanged?()
}
private func cancelRequest() {
task?.cancel()
task = nil
translateButton.isEnabled = true
}
private func setStatus(_ message: String, isError: Bool = false) {
status.stringValue = message
status.textColor = isError ? .systemRed : SunsetPalette.secondaryText
}
@objc func performPrimaryAction() {
guard task == nil else { return }
if textToTranslate == nil && (input?() ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
swapLanguages()
return
}
if output.string.isEmpty {
if !usesMyMemory && textToTranslate == nil {
textToTranslate = input?() ?? ""
replaceInput?("")
inputChanged()
inputPresentationChanged?()
focusSearch?()
} else {
translate()
}
return
}
pasteboard.clearContents()
if pasteboard.setString(output.string, forType: .string) {
dismiss?()
} else {
setStatus("Could not copy the translation. Try again.", isError: true)
}
}
@objc func translate() {
guard task == nil else { return }
let text = textToTranslate ?? input?() ?? ""
let context = textToTranslate == nil ? "" : input?() ?? ""
guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
setStatus("Enter \(languageName(sourceLanguage)) text in the search bar.", isError: true)
return
}
let myMemory = usesMyMemory
var key = ""
if !myMemory {
do {
guard let savedKey = try credentialsStore.load() else {
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
}
}
output.string = ""
setStatus("Translating…")
translateButton.isEnabled = false
let myMemoryTranslate = self.myMemory
let openAITranslate = self.openAI
let source = sourceLanguage
let target = targetLanguage
task = Task { [weak self] in
do {
let translation = try await myMemory
? myMemoryTranslate(text, source, target)
: openAITranslate(text, source, target, key, context)
guard !Task.isCancelled, let self else { return }
let rendered = NSMutableAttributedString(string: translation, attributes: [
.font: NSFont.systemFont(ofSize: 20),
.foregroundColor: SunsetPalette.text,
])
if !myMemory, let separator = translation.range(of: "\n\n") {
rendered.addAttributes([
.foregroundColor: SunsetPalette.secondaryText,
.font: NSFont.systemFont(ofSize: 14),
], range: NSRange(separator.upperBound..<translation.endIndex, in: translation))
}
self.output.textStorage?.setAttributedString(rendered)
self.setStatus("")
self.translateButton.title = "Copy"
self.translateButton.toolTip = "Copy the translation and close Conjure (Return)."
} 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 }
self.task = nil
self.translateButton.isEnabled = true
}
}
@MainActor
struct Credentials {
var save: (String) throws -> Void
var load: () throws -> String?
var forget: () throws -> Void
private static var query: [String: Any] {
[kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "Conjure.OpenAI",
kSecAttrAccount as String: "translation"]
}
static let keychain = Credentials(
save: { key in
let data = Data(key.utf8)
var result = SecItemUpdate(query as CFDictionary, [kSecValueData as String: data] as CFDictionary)
if result == errSecItemNotFound {
var item = query
item[kSecValueData as String] = data
item[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly
result = SecItemAdd(item as CFDictionary, nil)
}
guard result == errSecSuccess else { throw KeychainError(code: result) }
},
load: {
var query = Self.query
query[kSecReturnData as String] = true
var item: CFTypeRef?
let result = SecItemCopyMatching(query as CFDictionary, &item)
if result == errSecItemNotFound { return nil }
guard result == errSecSuccess, let data = item as? Data,
let key = String(data: data, encoding: .utf8) else { throw KeychainError(code: result) }
return key
},
forget: {
let result = SecItemDelete(query as CFDictionary)
guard result == errSecSuccess || result == errSecItemNotFound else { throw KeychainError(code: result) }
}
)
}
private struct KeychainError: LocalizedError {
let code: OSStatus
var errorDescription: String? {
"Keychain error: \(SecCopyErrorMessageString(code, nil) as String? ?? String(code))"
}
}
}