Func is one button with a dark panel of functions; Translator (⌃⌥R or Func): selected text RU<->EN via macOS Translation or claude Haiku, card holds the notch open
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -97,6 +97,11 @@ struct AppGridView: View {
|
||||
.padding(.trailing, 20)
|
||||
.padding(.vertical, 6)
|
||||
|
||||
if let t = state.translation {
|
||||
TranslationCard(state: state, result: t)
|
||||
.transition(.opacity)
|
||||
}
|
||||
|
||||
if showSystem {
|
||||
SystemCard { withAnimation(.easeOut(duration: 0.18)) { showSystem = false } }
|
||||
.transition(.opacity)
|
||||
|
||||
@@ -493,6 +493,9 @@ final class AppState: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Func → Переводчик result shown on the home view.
|
||||
@Published var translation: TranslationResult? = nil
|
||||
|
||||
// MARK: Agent health (BroV)
|
||||
@Published var crashedSessions: [CrashedSession] = []
|
||||
@Published var claudeNeedsLogin = false
|
||||
|
||||
@@ -13,23 +13,93 @@ import Darwin
|
||||
struct FuncPill: View {
|
||||
@ObservedObject var state: AppState
|
||||
@Binding var showSystem: Bool
|
||||
@State private var open = false
|
||||
@State private var hovered = false
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
Text("Func")
|
||||
.font(.system(size: 12, weight: .semibold, design: .monospaced))
|
||||
.foregroundColor(Color(hex: "#8E939C"))
|
||||
.padding(.leading, 12)
|
||||
Spacer(minLength: 4)
|
||||
FuncButton(symbol: "eyedropper", title: "Пипетка") { ColorPicking.start(state: state) }
|
||||
FuncButton(symbol: "cpu", title: "Мак") {
|
||||
withAnimation(.easeOut(duration: 0.18)) { showSystem.toggle() }
|
||||
Button { open.toggle() } label: {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "square.grid.2x2.fill")
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(Color(hex: "#A78BFA"))
|
||||
Text("Func")
|
||||
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
||||
.foregroundColor(Color(hex: "#F5F6F8"))
|
||||
Spacer(minLength: 4)
|
||||
Image(systemName: open ? "chevron.up" : "chevron.down")
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.foregroundColor(Color(hex: "#5F646D"))
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(hovered || open ? Color(hex: "#A78BFA").opacity(0.14) : Color(hex: "#0E0F11")))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.stroke(Color(hex: "#A78BFA").opacity(hovered || open ? 0.5 : 0.2), lineWidth: 1))
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.padding(.trailing, 6)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(RoundedRectangle(cornerRadius: 14, style: .continuous).fill(Color(hex: "#0E0F11")))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(Color.white.opacity(0.10), lineWidth: 1))
|
||||
.buttonStyle(.plain)
|
||||
.onHover { h in withAnimation(.easeOut(duration: 0.12)) { hovered = h } }
|
||||
.popover(isPresented: $open, arrowEdge: .bottom) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
FuncRow(symbol: "eyedropper", color: "#F472B6", title: "Пипетка",
|
||||
subtitle: "цвет пикселя во всех форматах", hint: nil) {
|
||||
open = false
|
||||
ColorPicking.start(state: state)
|
||||
}
|
||||
FuncRow(symbol: "cpu", color: "#34D399", title: "Мак",
|
||||
subtitle: "CPU, ядра, ОЗУ, SSD", hint: nil) {
|
||||
open = false
|
||||
state.translation = nil
|
||||
withAnimation(.easeOut(duration: 0.18)) { showSystem = true }
|
||||
}
|
||||
FuncRow(symbol: "character.bubble", color: "#60A5FA", title: "Переводчик",
|
||||
subtitle: "выделенный текст RU ↔ EN", hint: "⌃⌥R") {
|
||||
open = false
|
||||
showSystem = false
|
||||
TranslatorRun.start(state: state)
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
.frame(width: 280)
|
||||
.notchPopoverStyle()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FuncRow: View {
|
||||
let symbol: String
|
||||
let color: String
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let hint: String?
|
||||
let action: () -> Void
|
||||
@State private var hovered = false
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: symbol)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundColor(Color(hex: color))
|
||||
.frame(width: 26, height: 26)
|
||||
.background(RoundedRectangle(cornerRadius: 7).fill(Color(hex: color).opacity(0.15)))
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(title).font(.system(size: 13, weight: .semibold))
|
||||
Text(subtitle).font(.system(size: 11)).foregroundColor(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if let hint {
|
||||
Text(hint).font(.system(size: 11, design: .rounded)).foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 6)
|
||||
.background(RoundedRectangle(cornerRadius: 8).fill(Color.primary.opacity(hovered ? 0.08 : 0)))
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.onHover { hovered = $0 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -211,6 +211,7 @@ final class IslandWindowController: NSWindowController {
|
||||
fsm.isHeldOpen = {
|
||||
let s = AppState.shared
|
||||
return s.pendingApproval != nil || (s.mode == .expanded && s.view.isTall)
|
||||
|| (s.mode == .expanded && s.translation != nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -445,6 +446,9 @@ final class IslandWindowController: NSWindowController {
|
||||
case .desktopToggle:
|
||||
DesktopMochiController.shared.flyOutOrHome()
|
||||
|
||||
case .translate:
|
||||
TranslatorRun.start(state: state)
|
||||
|
||||
case .wardrobeToggle:
|
||||
if state.mode == .expanded && state.view == .wardrobe {
|
||||
collapse()
|
||||
|
||||
@@ -18,6 +18,7 @@ enum ShortcutAction: String, CaseIterable, Sendable {
|
||||
case muteToggle = "muteToggle" // ⌃⌥M — mute / unmute sounds
|
||||
case desktopToggle = "desktopToggle" // ⌃⌥D — send Mochi to desktop / bring back
|
||||
case wardrobeToggle = "wardrobeToggle" // ⌃⌥G — open / close wardrobe
|
||||
case translate = "translate" // ⌃⌥R — translate the selected text (BroV)
|
||||
|
||||
// MARK: UserDefaults keys
|
||||
|
||||
@@ -48,6 +49,7 @@ enum ShortcutAction: String, CaseIterable, Sendable {
|
||||
case .muteToggle: return "Выключить / включить звук"
|
||||
case .desktopToggle: return "BroV на рабочий стол / обратно"
|
||||
case .wardrobeToggle: return "Открыть / закрыть гардероб"
|
||||
case .translate: return "Перевести выделенный текст"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,6 +106,7 @@ enum ShortcutLogic {
|
||||
.muteToggle: ShortcutSpec(keyCode: 46, nsFlags: ShortcutSpec.ctrlOpt), // ⌃⌥M
|
||||
.desktopToggle: ShortcutSpec(keyCode: 2, nsFlags: ShortcutSpec.ctrlOpt), // ⌃⌥D
|
||||
.wardrobeToggle: ShortcutSpec(keyCode: 5, nsFlags: ShortcutSpec.ctrlOpt), // ⌃⌥G
|
||||
.translate: ShortcutSpec(keyCode: 15, nsFlags: ShortcutSpec.ctrlOpt), // ⌃⌥R
|
||||
]
|
||||
|
||||
// MARK: - Load / save (UserDefaults)
|
||||
|
||||
@@ -270,6 +270,7 @@ struct TabChip: View {
|
||||
}
|
||||
.padding(14)
|
||||
.frame(width: 260)
|
||||
.notchPopoverStyle()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -303,6 +304,7 @@ struct NewTabButton: View {
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.notchPopoverStyle()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,3 +333,15 @@ struct NewTabButton: View {
|
||||
.disabled(state.tabs.count >= AppState.maxTabs)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
extension View {
|
||||
/// Popovers out of the notch: dark like the island, readable text whatever the
|
||||
/// island's foreground colour is.
|
||||
func notchPopoverStyle() -> some View {
|
||||
self
|
||||
.foregroundColor(Color(hex: "#F5F6F8"))
|
||||
.background(Color(hex: "#16171B"))
|
||||
.environment(\.colorScheme, .dark)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
import Translation
|
||||
|
||||
// MARK: - Translator (Func → Переводчик, ⌃⌥R)
|
||||
//
|
||||
// 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
|
||||
? "Не нашёл выделенного текста. Выдели текст в приложении и нажми ⌃⌥R."
|
||||
: "Дай BroV доступ: Системные настройки → Конфиденциальность → Универсальный доступ → BroV. Потом выдели текст и нажми ⌃⌥R.",
|
||||
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.
|
||||
pb.clearContents()
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user