diff --git a/NotchBuddy/Sources/App/AppGridView.swift b/NotchBuddy/Sources/App/AppGridView.swift index 04f9bfc..9014c3b 100644 --- a/NotchBuddy/Sources/App/AppGridView.swift +++ b/NotchBuddy/Sources/App/AppGridView.swift @@ -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) diff --git a/NotchBuddy/Sources/App/AppState.swift b/NotchBuddy/Sources/App/AppState.swift index c657fab..b8679e8 100644 --- a/NotchBuddy/Sources/App/AppState.swift +++ b/NotchBuddy/Sources/App/AppState.swift @@ -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 diff --git a/NotchBuddy/Sources/App/Func.swift b/NotchBuddy/Sources/App/Func.swift index 734f7e9..769df88 100644 --- a/NotchBuddy/Sources/App/Func.swift +++ b/NotchBuddy/Sources/App/Func.swift @@ -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 } } } diff --git a/NotchBuddy/Sources/App/IslandWindowController.swift b/NotchBuddy/Sources/App/IslandWindowController.swift index 08af2af..ea92987 100644 --- a/NotchBuddy/Sources/App/IslandWindowController.swift +++ b/NotchBuddy/Sources/App/IslandWindowController.swift @@ -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() diff --git a/NotchBuddy/Sources/App/ShortcutLogic.swift b/NotchBuddy/Sources/App/ShortcutLogic.swift index 29d9ed6..e0b61ad 100644 --- a/NotchBuddy/Sources/App/ShortcutLogic.swift +++ b/NotchBuddy/Sources/App/ShortcutLogic.swift @@ -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) diff --git a/NotchBuddy/Sources/App/Tabs.swift b/NotchBuddy/Sources/App/Tabs.swift index 961ae18..a1af844 100644 --- a/NotchBuddy/Sources/App/Tabs.swift +++ b/NotchBuddy/Sources/App/Tabs.swift @@ -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) + } +} diff --git a/NotchBuddy/Sources/App/Translator.swift b/NotchBuddy/Sources/App/Translator.swift new file mode 100644 index 0000000..7c150e1 --- /dev/null +++ b/NotchBuddy/Sources/App/Translator.swift @@ -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) + } + } +}