import AppKit import ConjureKit import Security @MainActor final class TranslationController: NSViewController { let providerPicker = NSPopUpButton() 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 languagesChanged: (() -> Void)? var inputPlaceholder: String { "Enter \(languageName(sourceLanguage)) text to translate…" } var inputAccessibilityLabel: String { "\(languageName(sourceLanguage)) text to translate into \(languageName(targetLanguage))" } 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 { providerPicker.indexOfSelectedItem == 0 } 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? var input: (() -> String)? 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) 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) async throws -> String = { try await TranslationAPI.translate($0, apiKey: $3, sourceLanguage: $1, targetLanguage: $2) }, pasteboard: NSPasteboard = .general ) { self.defaults = defaults self.credentialsStore = credentials self.myMemory = myMemory self.openAI = openAI self.pasteboard = pasteboard 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() 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) providerPicker.isBordered = false (providerPicker.cell as? NSPopUpButtonCell)?.arrowPosition = .noArrow providerPicker.font = .systemFont(ofSize: 12, weight: .medium) providerPicker.contentTintColor = SunsetPalette.text let chevron = NSButton(image: NSImage(systemSymbolName: "chevron.down", accessibilityDescription: nil)!, target: providerPicker, action: #selector(NSControl.performClick(_:))) chevron.isBordered = false chevron.contentTintColor = SunsetPalette.secondaryText chevron.setAccessibilityElement(false) stylePill(providerControl) for control in [providerPicker, chevron] { control.translatesAutoresizingMaskIntoConstraints = false providerControl.addSubview(control) } NSLayoutConstraint.activate([ providerControl.heightAnchor.constraint(equalToConstant: 36), providerPicker.leadingAnchor.constraint(equalTo: providerControl.leadingAnchor, constant: 14), providerPicker.centerYAnchor.constraint(equalTo: providerControl.centerYAnchor), chevron.leadingAnchor.constraint(equalTo: providerPicker.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) actionsButton.keyEquivalent = "k" actionsButton.keyEquivalentModifierMask = .command actionsButton.isBordered = false actionsButton.font = .systemFont(ofSize: 12, weight: .medium) actionsButton.contentTintColor = SunsetPalette.text 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() } 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.keyEquivalent = "\r" translateButton.isBordered = false translateButton.font = .systemFont(ofSize: 12, weight: .medium) translateButton.contentTintColor = SunsetPalette.text translateButton.toolTip = "Translate the input (Return). Escape dismisses Conjure." let returnKey = ShortcutIndicator("↵") let actionPill = NSView() stylePill(actionPill) let actionsKey = ShortcutIndicator("⌘K") for control in [actionsButton, actionsKey, translateButton, returnKey] { control.translatesAutoresizingMaskIntoConstraints = false actionPill.addSubview(control) } NSLayoutConstraint.activate([ actionPill.heightAnchor.constraint(equalToConstant: 36), actionsButton.leadingAnchor.constraint(equalTo: actionPill.leadingAnchor, constant: 14), actionsButton.centerYAnchor.constraint(equalTo: actionPill.centerYAnchor), actionsKey.leadingAnchor.constraint(equalTo: actionsButton.trailingAnchor, constant: 8), actionsKey.centerYAnchor.constraint(equalTo: actionPill.centerYAnchor), translateButton.leadingAnchor.constraint(equalTo: actionsKey.trailingAnchor, constant: 16), translateButton.centerYAnchor.constraint(equalTo: actionPill.centerYAnchor), returnKey.leadingAnchor.constraint(equalTo: translateButton.trailingAnchor, constant: 8), returnKey.centerYAnchor.constraint(equalTo: actionPill.centerYAnchor), returnKey.trailingAnchor.constraint(equalTo: actionPill.trailingAnchor, constant: -12), ]) 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.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) scroll.translatesAutoresizingMaskIntoConstraints = false view.addSubview(scroll) NSLayoutConstraint.activate([ scroll.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), scroll.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), scroll.topAnchor.constraint(equalTo: view.topAnchor), scroll.bottomAnchor.constraint(equalTo: footer.topAnchor, constant: -12), 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 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) { let menu = NSMenu(title: source ? "Source language" : "Target language") for code in Self.languages { let item = NSMenuItem(title: languageName(code), action: #selector(languageSelected(_:)), keyEquivalent: "") item.target = self item.representedObject = code item.tag = source ? 0 : 1 item.state = code == (source ? sourceLanguage : targetLanguage) ? .on : .off menu.addItem(item) } menu.popUp(positioning: menu.items.first { $0.state == .on }, at: NSPoint(x: 0, y: languageButton.bounds.maxY), in: languageButton) } @objc private func languageSelected(_ item: NSMenuItem) { guard let code = item.representedObject as? String else { return } setLanguages(source: item.tag == 0 ? code : sourceLanguage, target: item.tag == 1 ? code : targetLanguage) } 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() languagesChanged?() } func swapLanguages() { setLanguages(source: targetLanguage, target: sourceLanguage) } func close() { actions.close(animated: false) cancelRequest() closed?() } @objc func goBack() { if actions.isShown { actions.close() } else { close() } } func inputChanged() { cancelRequest() output.string = "" translateButton.title = "Translate" translateButton.toolTip = "Translate the input (Return). Escape dismisses Conjure." settingsButton.isHidden = true languageButton.title = "\(languageName(sourceLanguage)) → \(languageName(targetLanguage))" output.setAccessibilityLabel("\(languageName(targetLanguage)) translation") setStatus("") } @objc private func providerChanged() { defaults.set(usesMyMemory ? "mymemory" : "openai", forKey: "translationProvider") inputChanged() } 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 } guard !output.string.isEmpty 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 = 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) guard !Task.isCancelled, let self else { return } self.output.string = translation 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))" } } }