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:
maksarsanjeev
2026-10-07 15:57:06 +03:00
parent 9570dc190f
commit e11d06c73e
7 changed files with 356 additions and 13 deletions
+5
View File
@@ -97,6 +97,11 @@ struct AppGridView: View {
.padding(.trailing, 20) .padding(.trailing, 20)
.padding(.vertical, 6) .padding(.vertical, 6)
if let t = state.translation {
TranslationCard(state: state, result: t)
.transition(.opacity)
}
if showSystem { if showSystem {
SystemCard { withAnimation(.easeOut(duration: 0.18)) { showSystem = false } } SystemCard { withAnimation(.easeOut(duration: 0.18)) { showSystem = false } }
.transition(.opacity) .transition(.opacity)
+3
View File
@@ -493,6 +493,9 @@ final class AppState: ObservableObject {
} }
} }
/// Func → Переводчик result shown on the home view.
@Published var translation: TranslationResult? = nil
// MARK: Agent health (BroV) // MARK: Agent health (BroV)
@Published var crashedSessions: [CrashedSession] = [] @Published var crashedSessions: [CrashedSession] = []
@Published var claudeNeedsLogin = false @Published var claudeNeedsLogin = false
+81 -11
View File
@@ -13,23 +13,93 @@ import Darwin
struct FuncPill: View { struct FuncPill: View {
@ObservedObject var state: AppState @ObservedObject var state: AppState
@Binding var showSystem: Bool @Binding var showSystem: Bool
@State private var open = false
@State private var hovered = false
var body: some View { var body: some View {
HStack(spacing: 6) { Button { open.toggle() } label: {
HStack(spacing: 8) {
Image(systemName: "square.grid.2x2.fill")
.font(.system(size: 13))
.foregroundColor(Color(hex: "#A78BFA"))
Text("Func") Text("Func")
.font(.system(size: 12, weight: .semibold, design: .monospaced)) .font(.system(size: 13, weight: .semibold, design: .monospaced))
.foregroundColor(Color(hex: "#8E939C")) .foregroundColor(Color(hex: "#F5F6F8"))
.padding(.leading, 12)
Spacer(minLength: 4) Spacer(minLength: 4)
FuncButton(symbol: "eyedropper", title: "Пипетка") { ColorPicking.start(state: state) } Image(systemName: open ? "chevron.up" : "chevron.down")
FuncButton(symbol: "cpu", title: "Мак") { .font(.system(size: 9, weight: .semibold))
withAnimation(.easeOut(duration: 0.18)) { showSystem.toggle() } .foregroundColor(Color(hex: "#5F646D"))
} }
} .padding(.horizontal, 12)
.padding(.trailing, 6)
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.background(RoundedRectangle(cornerRadius: 14, style: .continuous).fill(Color(hex: "#0E0F11"))) .background(RoundedRectangle(cornerRadius: 14, style: .continuous)
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(Color.white.opacity(0.10), lineWidth: 1)) .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())
}
.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 = { fsm.isHeldOpen = {
let s = AppState.shared let s = AppState.shared
return s.pendingApproval != nil || (s.mode == .expanded && s.view.isTall) 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: case .desktopToggle:
DesktopMochiController.shared.flyOutOrHome() DesktopMochiController.shared.flyOutOrHome()
case .translate:
TranslatorRun.start(state: state)
case .wardrobeToggle: case .wardrobeToggle:
if state.mode == .expanded && state.view == .wardrobe { if state.mode == .expanded && state.view == .wardrobe {
collapse() collapse()
@@ -18,6 +18,7 @@ enum ShortcutAction: String, CaseIterable, Sendable {
case muteToggle = "muteToggle" // ⌃⌥M — mute / unmute sounds case muteToggle = "muteToggle" // ⌃⌥M — mute / unmute sounds
case desktopToggle = "desktopToggle" // ⌃⌥D — send Mochi to desktop / bring back case desktopToggle = "desktopToggle" // ⌃⌥D — send Mochi to desktop / bring back
case wardrobeToggle = "wardrobeToggle" // ⌃⌥G — open / close wardrobe case wardrobeToggle = "wardrobeToggle" // ⌃⌥G — open / close wardrobe
case translate = "translate" // ⌃⌥R — translate the selected text (BroV)
// MARK: UserDefaults keys // MARK: UserDefaults keys
@@ -48,6 +49,7 @@ enum ShortcutAction: String, CaseIterable, Sendable {
case .muteToggle: return "Выключить / включить звук" case .muteToggle: return "Выключить / включить звук"
case .desktopToggle: return "BroV на рабочий стол / обратно" case .desktopToggle: return "BroV на рабочий стол / обратно"
case .wardrobeToggle: return "Открыть / закрыть гардероб" case .wardrobeToggle: return "Открыть / закрыть гардероб"
case .translate: return "Перевести выделенный текст"
} }
} }
@@ -104,6 +106,7 @@ enum ShortcutLogic {
.muteToggle: ShortcutSpec(keyCode: 46, nsFlags: ShortcutSpec.ctrlOpt), // ⌃⌥M .muteToggle: ShortcutSpec(keyCode: 46, nsFlags: ShortcutSpec.ctrlOpt), // ⌃⌥M
.desktopToggle: ShortcutSpec(keyCode: 2, nsFlags: ShortcutSpec.ctrlOpt), // ⌃⌥D .desktopToggle: ShortcutSpec(keyCode: 2, nsFlags: ShortcutSpec.ctrlOpt), // ⌃⌥D
.wardrobeToggle: ShortcutSpec(keyCode: 5, nsFlags: ShortcutSpec.ctrlOpt), // ⌃⌥G .wardrobeToggle: ShortcutSpec(keyCode: 5, nsFlags: ShortcutSpec.ctrlOpt), // ⌃⌥G
.translate: ShortcutSpec(keyCode: 15, nsFlags: ShortcutSpec.ctrlOpt), // ⌃⌥R
] ]
// MARK: - Load / save (UserDefaults) // MARK: - Load / save (UserDefaults)
+14
View File
@@ -270,6 +270,7 @@ struct TabChip: View {
} }
.padding(14) .padding(14)
.frame(width: 260) .frame(width: 260)
.notchPopoverStyle()
} }
} }
} }
@@ -303,6 +304,7 @@ struct NewTabButton: View {
} }
} }
.padding(10) .padding(10)
.notchPopoverStyle()
} }
} }
@@ -331,3 +333,15 @@ struct NewTabButton: View {
.disabled(state.tabs.count >= AppState.maxTabs) .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)
}
}
+244
View File
@@ -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)
}
}
}