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

467 lines
22 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 Darwin
// MARK: - Func pill (top-right slot of the home view)
//
// Small tools, one icon each:
// • Пипетка — pick any pixel on screen; a new brov-chat tab explains the colour in many
// formats (computed locally, instant) and the HEX goes to the clipboard. The colour is
// passed to claude as context for follow-up questions in that tab.
// • Мак — live card with CPU / cores / RAM / SSD of this Mac.
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 {
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())
}
.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: "⌥`") {
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 }
}
}
struct FuncButton: View {
let symbol: String
let title: String
let action: () -> Void
@State private var hovered = false
var body: some View {
Button(action: action) {
HStack(spacing: 5) {
Image(systemName: symbol).font(.system(size: 11, weight: .semibold))
Text(title).font(.system(size: 11, weight: .medium))
}
.foregroundColor(Color(hex: hovered ? "#F5F6F8" : "#B0B5BE"))
.padding(.horizontal, 9)
.frame(height: 26)
.background(Capsule().fill(Color.white.opacity(hovered ? 0.12 : 0.06)))
.contentShape(Capsule())
}
.buttonStyle(.plain)
.onHover { hovered = $0 }
}
}
// MARK: - Eyedropper
@MainActor
enum ColorPicking {
private static var sampler: NSColorSampler?
static func start(state: AppState) {
SoundEngine.shared.play("blip")
let s = NSColorSampler()
sampler = s
s.show { picked in
Task { @MainActor in
sampler = nil
guard let picked, let c = picked.usingColorSpace(.sRGB) else { return }
deliver(ColorFormats(c), state: state)
}
}
}
private static func deliver(_ f: ColorFormats, state: AppState) {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(f.hex, forType: .string)
// A fresh brov-chat tab (or the current chat one when all 6 tabs are taken).
if state.tabs.count < AppState.maxTabs {
state.openTab(.chat)
} else if let chat = state.tabs.first(where: { $0.kind == .chat }) {
state.activateTab(chat.id)
}
if let tab = state.activeTabId {
ChatTabs.shared.seed(tab, "Пользователь выбрал пипеткой цвет \(f.hex) (rgb \(f.rgbTriple)). Вопросы ниже — про этот цвет.")
}
state.chatHistory.append(ChatMessage(role: .assistant, content: f.markdown))
NotificationCenter.default.post(name: .hookExpand, object: IslandView.prompt)
NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.surprised)
}
}
struct ColorFormats {
let r: Double, g: Double, b: Double // 0…1 sRGB
init(_ c: NSColor) {
r = Double(c.redComponent); g = Double(c.greenComponent); b = Double(c.blueComponent)
}
var R: Int { Int((r * 255).rounded()) }
var G: Int { Int((g * 255).rounded()) }
var B: Int { Int((b * 255).rounded()) }
var hex: String { String(format: "#%02X%02X%02X", R, G, B) }
var rgbTriple: String { "\(R), \(G), \(B)" }
var hsl: (h: Int, s: Int, l: Int) {
let mx = max(r, g, b), mn = min(r, g, b), l = (mx + mn) / 2
guard mx != mn else { return (0, 0, Int((l * 100).rounded())) }
let d = mx - mn
let s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn)
return (hue(mx: mx, d: d), Int((s * 100).rounded()), Int((l * 100).rounded()))
}
var hsb: (h: Int, s: Int, b: Int) {
let mx = max(r, g, b), mn = min(r, g, b), d = mx - mn
let s = mx == 0 ? 0 : d / mx
return (d == 0 ? 0 : hue(mx: mx, d: d), Int((s * 100).rounded()), Int((mx * 100).rounded()))
}
var cmyk: (c: Int, m: Int, y: Int, k: Int) {
let k = 1 - max(r, g, b)
guard k < 1 else { return (0, 0, 0, 100) }
func p(_ v: Double) -> Int { Int(((1 - v - k) / (1 - k) * 100).rounded()) }
return (p(r), p(g), p(b), Int((k * 100).rounded()))
}
private func hue(mx: Double, d: Double) -> Int {
var h: Double
if mx == r { h = (g - b) / d + (g < b ? 6 : 0) }
else if mx == g { h = (b - r) / d + 2 }
else { h = (r - g) / d + 4 }
h *= 60
return Int(h.rounded()) % 360
}
/// Relative luminance → which text colour reads on top of it.
var luminance: Double {
func lin(_ v: Double) -> Double { v <= 0.03928 ? v / 12.92 : pow((v + 0.055) / 1.055, 2.4) }
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b)
}
var contrastWhite: Double { 1.05 / (luminance + 0.05) }
var contrastBlack: Double { (luminance + 0.05) / 0.05 }
var nearestName: (en: String, ru: String) {
ColorNames.nearest(R, G, B)
}
var markdown: String {
let (h1, s1, l1) = hsl, (h2, s2, b2) = hsb, (c, m, y, k) = cmyk
let name = nearestName
let text = contrastWhite >= contrastBlack ? "белый" : "чёрный"
return """
**\(hex)** — \(name.ru) (ближе всего к `\(name.en)`). HEX уже в буфере обмена.
- **HEX:** `\(hex)` · без решётки `\(hex.dropFirst())` · с альфой `\(hex)FF`
- **RGB:** `rgb(\(R), \(G), \(B))` · дроби `\(String(format: "%.3f, %.3f, %.3f", r, g, b))`
- **HSL:** `hsl(\(h1), \(s1)%, \(l1)%)`
- **HSB/HSV:** `\(h2)°, \(s2)%, \(b2)%`
- **CMYK:** `\(c)%, \(m)%, \(y)%, \(k)%`
- **SwiftUI:** `Color(red: \(String(format: "%.3f", r)), green: \(String(format: "%.3f", g)), blue: \(String(format: "%.3f", b)))`
- **NSColor/UIColor:** `NSColor(srgbRed: \(String(format: "%.3f", r)), green: \(String(format: "%.3f", g)), blue: \(String(format: "%.3f", b)), alpha: 1)`
- **Android:** `0xFF\(hex.dropFirst())` · **0x:** `0x\(hex.dropFirst())`
- **Контраст:** с белым \(String(format: "%.1f", contrastWhite)):1, с чёрным \(String(format: "%.1f", contrastBlack)):1 — текст лучше делать \(text)
"""
}
}
/// CSS named colours (subset is enough to give a recognisable name).
enum ColorNames {
static let table: [(String, String, Int, Int, Int)] = [
("black", "чёрный", 0, 0, 0), ("white", "белый", 255, 255, 255), ("gray", "серый", 128, 128, 128),
("silver", "серебристый", 192, 192, 192), ("dimgray", "тёмно-серый", 105, 105, 105),
("gainsboro", "светло-серый", 220, 220, 220), ("red", "красный", 255, 0, 0),
("darkred", "тёмно-красный", 139, 0, 0), ("crimson", "малиновый", 220, 20, 60),
("firebrick", "кирпичный", 178, 34, 34), ("salmon", "лососевый", 250, 128, 114),
("tomato", "томатный", 255, 99, 71), ("coral", "коралловый", 255, 127, 80),
("orangered", "красно-оранжевый", 255, 69, 0), ("orange", "оранжевый", 255, 165, 0),
("darkorange", "тёмно-оранжевый", 255, 140, 0), ("gold", "золотой", 255, 215, 0),
("yellow", "жёлтый", 255, 255, 0), ("khaki", "хаки", 240, 230, 140),
("beige", "бежевый", 245, 245, 220), ("wheat", "пшеничный", 245, 222, 179),
("tan", "песочный", 210, 180, 140), ("peru", "перу (коричнево-рыжий)", 205, 133, 63),
("chocolate", "шоколадный", 210, 105, 30), ("sienna", "сиена", 160, 82, 45),
("saddlebrown", "коричневый", 139, 69, 19), ("maroon", "бордовый", 128, 0, 0),
("olive", "оливковый", 128, 128, 0), ("yellowgreen", "жёлто-зелёный", 154, 205, 50),
("lime", "лаймовый", 0, 255, 0), ("limegreen", "лаймово-зелёный", 50, 205, 50),
("green", "зелёный", 0, 128, 0), ("darkgreen", "тёмно-зелёный", 0, 100, 0),
("forestgreen", "лесной зелёный", 34, 139, 34), ("seagreen", "морской зелёный", 46, 139, 87),
("mediumseagreen", "изумрудный", 60, 179, 113), ("springgreen", "весенний зелёный", 0, 255, 127),
("teal", "бирюзово-зелёный (teal)", 0, 128, 128), ("turquoise", "бирюзовый", 64, 224, 208),
("cyan", "голубой (циан)", 0, 255, 255), ("lightblue", "светло-голубой", 173, 216, 230),
("skyblue", "небесный", 135, 206, 235), ("deepskyblue", "ярко-голубой", 0, 191, 255),
("dodgerblue", "синий доджер", 30, 144, 255), ("steelblue", "стальной синий", 70, 130, 180),
("royalblue", "королевский синий", 65, 105, 225), ("blue", "синий", 0, 0, 255),
("mediumblue", "средне-синий", 0, 0, 205), ("navy", "тёмно-синий (navy)", 0, 0, 128),
("midnightblue", "полуночно-синий", 25, 25, 112), ("slateblue", "сланцево-синий", 106, 90, 205),
("indigo", "индиго", 75, 0, 130), ("purple", "фиолетовый", 128, 0, 128),
("darkviolet", "тёмно-фиолетовый", 148, 0, 211), ("mediumpurple", "лавандово-фиолетовый", 147, 112, 219),
("orchid", "орхидея", 218, 112, 214), ("violet", "фиалковый", 238, 130, 238),
("plum", "сливовый", 221, 160, 221), ("lavender", "лавандовый", 230, 230, 250),
("magenta", "пурпурный (маджента)", 255, 0, 255), ("deeppink", "ярко-розовый", 255, 20, 147),
("hotpink", "горячий розовый", 255, 105, 180), ("pink", "розовый", 255, 192, 203),
("lightpink", "светло-розовый", 255, 182, 193), ("mistyrose", "туманно-розовый", 255, 228, 225),
("ivory", "слоновая кость", 255, 255, 240), ("linen", "льняной", 250, 240, 230),
("slategray", "сланцево-серый", 112, 128, 144), ("darkslategray", "тёмный сланец", 47, 79, 79),
("cadetblue", "кадетский синий", 95, 158, 160), ("aquamarine", "аквамарин", 127, 255, 212),
]
static func nearest(_ r: Int, _ g: Int, _ b: Int) -> (en: String, ru: String) {
// Weighted RGB distance ("redmean"), good enough for naming.
var best = table[0], bestD = Double.infinity
for c in table {
let rm = Double(r + c.2) / 2
let dr = Double(r - c.2), dg = Double(g - c.3), db = Double(b - c.4)
let d = (2 + rm / 256) * dr * dr + 4 * dg * dg + (2 + (255 - rm) / 256) * db * db
if d < bestD { bestD = d; best = c }
}
return (best.0, best.1)
}
}
// MARK: - System info
struct SystemSnapshot {
var chip = ""
var model = ""
var pCores = 0, eCores = 0, logical = 0
var cpuLoad: Double = 0 // 0…1 overall
var ramTotal: UInt64 = 0, ramUsed: UInt64 = 0
var diskTotal: Int64 = 0, diskFree: Int64 = 0
var uptime: TimeInterval = 0
var osVersion = ""
}
@MainActor
final class SystemMonitor: ObservableObject {
static let shared = SystemMonitor()
@Published var snap = SystemSnapshot()
private var timer: Timer?
private var prevTicks: (user: UInt64, sys: UInt64, idle: UInt64, nice: UInt64)?
func start() {
guard timer == nil else { return }
snap = Self.staticInfo()
refresh()
timer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { _ in
Task { @MainActor in SystemMonitor.shared.refresh() }
}
}
func stop() { timer?.invalidate(); timer = nil; prevTicks = nil }
private func refresh() {
var s = snap
s.cpuLoad = cpuLoad() ?? s.cpuLoad
(s.ramTotal, s.ramUsed) = Self.memory()
if let v = try? URL(fileURLWithPath: "/").resourceValues(forKeys: [.volumeTotalCapacityKey, .volumeAvailableCapacityForImportantUsageKey]) {
s.diskTotal = Int64(v.volumeTotalCapacity ?? 0)
s.diskFree = v.volumeAvailableCapacityForImportantUsage ?? 0
}
s.uptime = ProcessInfo.processInfo.systemUptime
snap = s
}
private static func sysctlString(_ name: String) -> String {
var size = 0
sysctlbyname(name, nil, &size, nil, 0)
guard size > 0 else { return "" }
var buf = [CChar](repeating: 0, count: size)
sysctlbyname(name, &buf, &size, nil, 0)
return String(cString: buf)
}
private static func sysctlInt(_ name: String) -> Int {
var v: Int64 = 0
var size = MemoryLayout<Int64>.size
return sysctlbyname(name, &v, &size, nil, 0) == 0 ? Int(v) : 0
}
private static func staticInfo() -> SystemSnapshot {
var s = SystemSnapshot()
s.chip = sysctlString("machdep.cpu.brand_string")
s.model = sysctlString("hw.model")
s.pCores = sysctlInt("hw.perflevel0.physicalcpu")
s.eCores = sysctlInt("hw.perflevel1.physicalcpu")
s.logical = sysctlInt("hw.logicalcpu")
let v = ProcessInfo.processInfo.operatingSystemVersion
s.osVersion = "macOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
return s
}
/// Used = what Activity Monitor calls "Memory Used": app (active+inactive−purgeable… ≈ internal) + wired + compressed.
private static func memory() -> (UInt64, UInt64) {
let total = ProcessInfo.processInfo.physicalMemory
var stats = vm_statistics64()
var count = mach_msg_type_number_t(MemoryLayout<vm_statistics64>.size / MemoryLayout<integer_t>.size)
let kr = withUnsafeMutablePointer(to: &stats) {
$0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
host_statistics64(mach_host_self(), HOST_VM_INFO64, $0, &count)
}
}
guard kr == KERN_SUCCESS else { return (total, 0) }
let page = UInt64(getpagesize())
let app = UInt64(stats.internal_page_count) - UInt64(stats.purgeable_count)
let used = (app + UInt64(stats.wire_count) + UInt64(stats.compressor_page_count)) * page
return (total, min(total, used))
}
private func cpuLoad() -> Double? {
var info = host_cpu_load_info()
var count = mach_msg_type_number_t(MemoryLayout<host_cpu_load_info>.size / MemoryLayout<integer_t>.size)
let kr = withUnsafeMutablePointer(to: &info) {
$0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
host_statistics(mach_host_self(), HOST_CPU_LOAD_INFO, $0, &count)
}
}
guard kr == KERN_SUCCESS else { return nil }
let t = (user: UInt64(info.cpu_ticks.0), sys: UInt64(info.cpu_ticks.1),
idle: UInt64(info.cpu_ticks.2), nice: UInt64(info.cpu_ticks.3))
defer { prevTicks = t }
guard let p = prevTicks else { return nil }
let busy = Double((t.user - p.user) + (t.sys - p.sys) + (t.nice - p.nice))
let all = busy + Double(t.idle - p.idle)
return all > 0 ? busy / all : nil
}
}
struct SystemCard: View {
@ObservedObject private var mon = SystemMonitor.shared
let onClose: () -> Void
private func gb(_ b: Int64) -> String { String(format: "%.0f ГБ", Double(b) / 1_000_000_000) }
private func gib(_ b: UInt64) -> String { String(format: "%.1f ГБ", Double(b) / 1_073_741_824) }
var body: some View {
let s = mon.snap
let diskUsed = max(0, s.diskTotal - s.diskFree)
ZStack(alignment: .topTrailing) {
CardBackground(wash: nil)
VStack(alignment: .leading, spacing: 7) {
HStack(spacing: 6) {
Text(s.chip.isEmpty ? "Mac" : s.chip).font(.system(size: 12.5, weight: .semibold))
Text("· \(s.model) · \(s.osVersion)").font(.system(size: 10.5)).foregroundColor(Color(hex: "#8E939C"))
}
SysRow(title: "CPU", value: "\(Int((s.cpuLoad * 100).rounded()))% · ядер \(s.pCores) произв. + \(s.eCores) эфф. (\(s.logical) потоков)",
fraction: s.cpuLoad)
SysRow(title: "ОЗУ", value: "занято \(gib(s.ramUsed)) из \(gib(s.ramTotal)) · свободно \(gib(s.ramTotal - s.ramUsed))",
fraction: s.ramTotal > 0 ? Double(s.ramUsed) / Double(s.ramTotal) : 0)
SysRow(title: "SSD", value: "занято \(gb(diskUsed)) из \(gb(s.diskTotal)) · свободно \(gb(s.diskFree))",
fraction: s.diskTotal > 0 ? Double(diskUsed) / Double(s.diskTotal) : 0)
}
.padding(.leading, 104)
.padding(.trailing, 36)
.padding(.vertical, 8)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
Button(action: onClose) {
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)
}
.onAppear { SystemMonitor.shared.start() }
.onDisappear { SystemMonitor.shared.stop() }
}
}
private struct SysRow: View {
let title: String
let value: String
let fraction: Double
var body: some View {
HStack(spacing: 8) {
Text(title).font(.system(size: 10.5, weight: .bold, design: .monospaced))
.foregroundColor(Color(hex: "#8E939C")).frame(width: 30, alignment: .leading)
VStack(alignment: .leading, spacing: 3) {
Text(value).font(.system(size: 11)).lineLimit(1).minimumScaleFactor(0.8)
GeometryReader { g in
ZStack(alignment: .leading) {
Capsule().fill(Color.white.opacity(0.08))
Capsule().fill(Color(hex: ClaudePlanGauge.color(for: fraction * 100)))
.frame(width: max(3, g.size.width * min(1, fraction)))
}
}
.frame(height: 4)
}
}
}
}