e11d06c73e
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
467 lines
22 KiB
Swift
467 lines
22 KiB
Swift
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: "⌃⌥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 }
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
}
|