feat: complete translation language controls and styling

This commit is contained in:
2026-10-17 23:49:36 +02:00
parent 038a5b78a0
commit 11e604e79e
9 changed files with 310 additions and 75 deletions
+163 -35
View File
@@ -5,6 +5,22 @@ 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)
@@ -17,14 +33,18 @@ final class TranslationController: NSViewController {
private let pasteboard: NSPasteboard
private let defaults: UserDefaults
private let credentialsStore: Credentials
private let myMemory: @MainActor (String) async throws -> String
private let openAI: @MainActor (String, String) async throws -> String
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) async throws -> String = { try await MyMemoryAPI.translate($0) },
openAI: @escaping @MainActor (String, String) async throws -> String = { try await TranslationAPI.translate($0, apiKey: $1) },
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
@@ -32,6 +52,10 @@ final class TranslationController: NSViewController {
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)
}
@@ -39,14 +63,55 @@ final class TranslationController: NSViewController {
override func loadView() {
view = NSView()
let title = NSTextField(labelWithString: "Conjure Translate · English → Japanese")
title.font = .systemFont(ofSize: 13, weight: .semibold)
title.textColor = SunsetPalette.text
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
@@ -58,27 +123,28 @@ final class TranslationController: NSViewController {
translateButton.isBordered = false
translateButton.font = .systemFont(ofSize: 12, weight: .medium)
translateButton.contentTintColor = SunsetPalette.text
translateButton.toolTip = "Translate the English input (Return). Escape dismisses Conjure."
translateButton.toolTip = "Translate the input (Return). Escape dismisses Conjure."
let returnKey = ShortcutIndicator("↵")
let actionPill = NSView()
actionPill.wantsLayer = true
actionPill.layer?.cornerRadius = 18
actionPill.layer?.borderWidth = 0.5
actionPill.layer?.borderColor = SunsetPalette.accent.withAlphaComponent(0.35).cgColor
actionPill.layer?.backgroundColor = SunsetPalette.background.withAlphaComponent(0.25).cgColor
for control in [translateButton, returnKey] {
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),
translateButton.leadingAnchor.constraint(equalTo: actionPill.leadingAnchor, constant: 14),
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: [status, settingsButton, NSView(), actionPill])
let footer = NSStackView(views: [languageButton, status, settingsButton, NSView(), actionPill])
footer.orientation = .horizontal
footer.alignment = .centerY
footer.spacing = 12
@@ -89,7 +155,7 @@ final class TranslationController: NSViewController {
output.drawsBackground = false
output.textColor = SunsetPalette.text
output.font = .systemFont(ofSize: 20)
output.setAccessibilityLabel("Japanese translation")
output.setAccessibilityLabel("\(languageName(targetLanguage)) translation")
let scroll = NSScrollView()
scroll.documentView = output
scroll.hasVerticalScroller = true
@@ -100,22 +166,17 @@ final class TranslationController: NSViewController {
status.font = .systemFont(ofSize: 12)
status.setAccessibilityLabel("Translation status and errors")
status.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
let stack = NSStackView(views: [title, scroll])
stack.orientation = .vertical
stack.alignment = .leading
stack.spacing = 12
stack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(stack)
scroll.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(scroll)
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: footer.topAnchor, constant: -12),
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.widthAnchor.constraint(equalTo: stack.widthAnchor),
scroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 100),
])
inputChanged()
@@ -127,18 +188,83 @@ final class TranslationController: NSViewController {
@objc private func credentialsChanged() { inputChanged() }
@objc func goBack() {
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 English input (Return). Escape dismisses Conjure."
translateButton.toolTip = "Translate the input (Return). Escape dismisses Conjure."
settingsButton.isHidden = true
setStatus("Press Return to translate.")
languageButton.title = "\(languageName(sourceLanguage)) → \(languageName(targetLanguage))"
output.setAccessibilityLabel("\(languageName(targetLanguage)) translation")
setStatus("")
}
@objc private func providerChanged() {
@@ -172,7 +298,7 @@ final class TranslationController: NSViewController {
guard task == nil else { return }
let text = input?() ?? ""
guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
setStatus("Enter English text in the search bar.", isError: true)
setStatus("Enter \(languageName(sourceLanguage)) text in the search bar.", isError: true)
return
}
let myMemory = usesMyMemory
@@ -196,14 +322,16 @@ final class TranslationController: NSViewController {
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)
: openAITranslate(text, key)
? myMemoryTranslate(text, source, target)
: openAITranslate(text, source, target, key)
guard !Task.isCancelled, let self else { return }
self.output.string = translation
self.setStatus("English → Japanese")
self.setStatus("")
self.translateButton.title = "Copy"
self.translateButton.toolTip = "Copy the translation and close Conjure (Return)."
} catch {