Func pill: eyedropper (colour in many formats in a new brov-chat tab, HEX to clipboard, context for claude) and live Mac card (CPU, cores, RAM, SSD)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
maksarsanjeev
2026-10-07 15:45:38 +03:00
parent d8a64ab5b5
commit 9570dc190f
4 changed files with 416 additions and 1 deletions
+7 -1
View File
@@ -27,6 +27,7 @@ enum DesktopApps {
struct AppGridView: View {
@ObservedObject var state: AppState
@State private var showSystem = false
/// "9% · сброс через 1 д 18 ч" — the Claude window closest to its limit.
private var claudeLimit: PillLimit? {
@@ -85,7 +86,7 @@ struct AppGridView: View {
VStack(spacing: 8) {
HStack(spacing: 8) {
ShelfPill()
EmptyPill()
FuncPill(state: state, showSystem: $showSystem)
}
HStack(spacing: 8) {
AppPill(app: DesktopApps.claude, limit: claudeLimit, alert: alert(for: .claude))
@@ -96,6 +97,11 @@ struct AppGridView: View {
.padding(.trailing, 20)
.padding(.vertical, 6)
if showSystem {
SystemCard { withAnimation(.easeOut(duration: 0.18)) { showSystem = false } }
.transition(.opacity)
}
#if !APPSTORE
if state.showingPlanDetail {
CardBackground(wash: nil)
@@ -275,6 +275,7 @@ final class ClaudeService {
let tab = state.activeTabId
let cli = ChatTabs.shared.cli(for: tab)
func isOnScreen() -> Bool { tab == nil || state.activeTabId == tab }
let seed = cli.sessionID == nil ? ChatTabs.shared.takeSeed(tab) : nil
var prompt = query
// Context goes with the first message of a conversation, like the API chat.
if cli.sessionID == nil, let context {
@@ -293,6 +294,8 @@ final class ClaudeService {
}
}
if let seed { prompt = seed + "\n\n" + prompt }
let placeholder = ChatMessage(role: .assistant, content: "")
let msgId = placeholder.id
if let tab { ChatTabs.shared.append(tab: tab, placeholder) } else { state.chatHistory.append(placeholder) }
+396
View File
@@ -0,0 +1,396 @@
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
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() }
}
}
.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))
}
}
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)
}
}
}
}
+10
View File
@@ -45,6 +45,15 @@ final class ChatTabs {
return c
}
private var seeds: [UUID: String] = [:]
/// Context prepended to the first message sent from a tab (e.g. the picked colour).
func seed(_ id: UUID, _ text: String) { seeds[id] = text }
func takeSeed(_ id: UUID?) -> String? {
guard let id else { return nil }
return seeds.removeValue(forKey: id)
}
func saveHistory(_ h: [ChatMessage], for id: UUID) { histories[id] = h }
func history(for id: UUID) -> [ChatMessage] { histories[id] ?? [] }
@@ -73,6 +82,7 @@ final class ChatTabs {
clis[id]?.cancel()
clis.removeValue(forKey: id)
histories.removeValue(forKey: id)
seeds.removeValue(forKey: id)
}
}