Files
maksarsanjeev efb3bac8fc Translator hotkey: Option + ` (key left of 1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 00:07:35 +03:00

249 lines
12 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import AppKit
import Translation
// MARK: - Translator (Func → Переводчик, ⌥`)
//
// Takes the text selected in the front app (Accessibility; falls back to a simulated ⌘C
// with the clipboard restored afterwards; then to whatever is already on the clipboard),
// detects the direction (Cyrillic → English, anything else → Russian) and translates it
// with the on-device macOS Translation when that language pair is installed, otherwise
// with `claude` (Haiku). The result shows as a card in the notch's home view.
struct TranslationResult: Equatable {
var source: String
var text: String = ""
var toRussian: Bool
var engine: String = ""
var error: String? = nil
var loading = true
}
@MainActor
enum TranslatorRun {
static func start(state: AppState) {
Task { @MainActor in
guard let source = await SelectionGrabber.grab(), !source.isEmpty else {
state.translation = TranslationResult(source: "", toRussian: true,
error: SelectionGrabber.trusted
? "Не нашёл выделенного текста. Выдели текст в приложении и нажми ⌥`."
: "Дай BroV доступ: Системные настройки → Конфиденциальность → Универсальный доступ → BroV. Потом выдели текст и нажми ⌥`.",
loading: false)
show(state)
return
}
let toRussian = !Self.isMostlyCyrillic(source)
state.translation = TranslationResult(source: source, toRussian: toRussian)
show(state)
do {
let (text, engine) = try await Translator.translate(source, toRussian: toRussian)
guard state.translation?.source == source else { return }
state.translation?.text = text
state.translation?.engine = engine
state.translation?.loading = false
SoundEngine.shared.play("pop")
} catch {
guard state.translation?.source == source else { return }
state.translation?.error = error.localizedDescription
state.translation?.loading = false
}
}
}
private static func show(_ state: AppState) {
if state.mode != .expanded || !(state.view == .overview || state.view == .empty) {
NotificationCenter.default.post(name: .hookExpand, object: IslandView.overview)
}
}
static func isMostlyCyrillic(_ s: String) -> Bool {
var cyr = 0, lat = 0
for u in s.unicodeScalars {
if (0x0400...0x04FF).contains(u.value) { cyr += 1 }
else if CharacterSet.letters.contains(u), u.isASCII { lat += 1 }
}
return cyr > lat
}
}
enum Translator {
static func translate(_ text: String, toRussian: Bool) async throws -> (String, String) {
let ru = Locale.Language(identifier: "ru")
let en = Locale.Language(identifier: "en")
if #available(macOS 26.0, *) {
let target = toRussian ? ru : en
// Source: English when going to Russian (the common case), else Russian.
let source = toRussian ? en : ru
let status = await LanguageAvailability().status(from: source, to: target)
if status == .installed {
let session = TranslationSession(installedSource: source, target: target)
if let r = try? await session.translate(text) {
return (r.targetText, "macOS")
}
}
}
return (try await viaClaude(text, toRussian: toRussian), "Claude Haiku")
}
private static func scratchDir() -> String {
let dir = FileManager.default.temporaryDirectory.appendingPathComponent("brov-translate")
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir.path
}
private static func viaClaude(_ text: String, toRussian: Bool) async throws -> String {
let instruction = toRussian
? "Translate the text below into Russian. Output only the translation, keep formatting."
: "Переведи текст ниже на английский. Выведи только перевод, сохрани форматирование."
let cli = ClaudeCodeCLI()
let state = await AppState.shared
let out = try await cli.send(prompt: instruction + "\n\n" + text, model: "haiku",
// An empty temp folder: no file access prompts, nothing to read.
workingDirectory: Self.scratchDir(), binaryPath: await state.claudeBinaryPath,
permissionMode: "plan") { _ in }
return out.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
// MARK: - Selected text of the front app
@MainActor
enum SelectionGrabber {
static var trusted: Bool { AXIsProcessTrusted() }
static func grab() async -> String? {
if !trusted {
// Shows the system prompt once; meanwhile use the clipboard.
let opts = ["AXTrustedCheckOptionPrompt": true] as CFDictionary
_ = AXIsProcessTrustedWithOptions(opts)
return clipboardText()
}
if let s = axSelection(), !s.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return s }
// ⌘C only when BroV isn't the key window (hotkey path); from a click in the notch
// the keystroke would land in BroV itself.
if NSApp.keyWindow == nil, let s = await copyViaCommandC(), !s.isEmpty { return s }
return clipboardText()
}
private static func axSelection() -> String? {
let system = AXUIElementCreateSystemWide()
var focused: CFTypeRef?
guard AXUIElementCopyAttributeValue(system, kAXFocusedUIElementAttribute as CFString, &focused) == .success,
let el = focused else { return nil }
var value: CFTypeRef?
guard AXUIElementCopyAttributeValue(el as! AXUIElement, kAXSelectedTextAttribute as CFString, &value) == .success
else { return nil }
return value as? String
}
/// Simulates ⌘C in the front app, reads the copy, then puts the old clipboard back.
private static func copyViaCommandC() async -> String? {
let pb = NSPasteboard.general
let before = pb.changeCount
let saved = pb.pasteboardItems?.map { item -> [NSPasteboard.PasteboardType: Data] in
var d: [NSPasteboard.PasteboardType: Data] = [:]
for t in item.types { if let v = item.data(forType: t) { d[t] = v } }
return d
} ?? []
let src = CGEventSource(stateID: .combinedSessionState)
let down = CGEvent(keyboardEventSource: src, virtualKey: 8, keyDown: true) // C
down?.flags = .maskCommand
let up = CGEvent(keyboardEventSource: src, virtualKey: 8, keyDown: false)
up?.flags = .maskCommand
down?.post(tap: .cghidEventTap)
up?.post(tap: .cghidEventTap)
for _ in 0..<10 {
try? await Task.sleep(for: .milliseconds(30))
if pb.changeCount != before { break }
}
guard pb.changeCount != before else { return nil }
let text = pb.string(forType: .string)
// Restore what the user had copied — except secrets: password managers mark them
// concealed/transient and clear them on their own; putting them back would defeat that.
pb.clearContents()
let secretTypes: Set<String> = ["org.nspasteboard.ConcealedType", "org.nspasteboard.TransientType",
"org.nspasteboard.AutoGeneratedType", "com.agilebits.onepassword"]
if saved.contains(where: { $0.keys.contains { secretTypes.contains($0.rawValue) } }) { return text }
let items = saved.map { dict -> NSPasteboardItem in
let it = NSPasteboardItem()
for (t, v) in dict { it.setData(v, forType: t) }
return it
}
if !items.isEmpty { pb.writeObjects(items) }
return text
}
private static func clipboardText() -> String? {
NSPasteboard.general.string(forType: .string)
}
}
// MARK: - Card
struct TranslationCard: View {
@ObservedObject var state: AppState
let result: TranslationResult
var body: some View {
ZStack(alignment: .topTrailing) {
CardBackground(wash: nil)
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 6) {
Image(systemName: "character.bubble").font(.system(size: 11, weight: .semibold))
Text(result.error != nil ? "Перевод" : (result.toRussian ? "Перевод на русский" : "Перевод на английский"))
.font(.system(size: 11.5, weight: .semibold))
if !result.engine.isEmpty {
Text("· \(result.engine)").font(.system(size: 10.5)).foregroundColor(Color(hex: "#6B7079"))
}
}
.foregroundColor(Color(hex: "#B0B5BE"))
if let err = result.error {
Text(err).font(.system(size: 11.5)).foregroundColor(Color(hex: "#FB923C"))
.fixedSize(horizontal: false, vertical: true)
} else if result.loading {
HStack(spacing: 6) {
ProgressView().controlSize(.small)
Text(result.source).lineLimit(1).font(.system(size: 11.5)).foregroundColor(Color(hex: "#6B7079"))
}
} else {
ScrollView(.vertical, showsIndicators: false) {
Text(result.text)
.font(.system(size: 12.5))
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
HStack(spacing: 8) {
FuncButton(symbol: "doc.on.doc", title: "Скопировать") {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(result.text, forType: .string)
SoundEngine.shared.play("blip")
}
FuncButton(symbol: "bubble.left", title: "В чат") {
if state.tabs.count < AppState.maxTabs { state.openTab(.chat) } else { state.view = .prompt }
if let tab = state.activeTabId {
ChatTabs.shared.seed(tab, "Исходный текст:\n\(result.source)\n\nПеревод:\n\(result.text)\n\nВопросы ниже — про этот перевод.")
}
state.chatHistory.append(ChatMessage(role: .assistant, content: "**Перевод:**\n\n\(result.text)"))
state.translation = nil
}
Text(result.source).lineLimit(1).font(.system(size: 10.5)).foregroundColor(Color(hex: "#5F646D"))
}
}
}
.padding(.leading, 104)
.padding(.trailing, 36)
.padding(.vertical, 8)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Button { withAnimation(.easeOut(duration: 0.18)) { state.translation = nil } } label: {
Image(systemName: "xmark").font(.system(size: 8, weight: .bold))
.foregroundColor(Color(hex: "#8E939C"))
.frame(width: 18, height: 18)
.background(Circle().fill(Color.white.opacity(0.08)))
}
.buttonStyle(.plain)
.padding(10)
}
}
}