import SwiftUI import OnyxDesign /// Die Detailansichten hinter den Menüleisten-Symbolen. /// /// Hier ist ausdrücklich Platz — anders als in der Menüleiste, wo jede Zahl um /// Breite kämpft. Ein Popover, das nur den Wert größer wiederholt, den man /// gerade angeklickt hat, ist verschenkt. @MainActor struct MetricPopoverContent: View { let model: MetricsModel let metric: MetricKind var body: some View { VStack(alignment: .leading, spacing: 12) { // Ein Titel oben: bei drei gleich aussehenden Ringen sieht man // sonst nicht, ob man vor dem CPU- oder dem GPU-Popover steht. Text(String(localized: .init(metric.localizationKey), bundle: .module)) .font(.headline) switch metric { case .cpu, .gpu: ProcessorDetail(model: model, metric: metric) case .memory: MemoryDetail(snapshot: model.snapshot) case .battery: BatteryDetail(snapshot: model.snapshot, control: model.chargeControl) case .temperature: SensorDetail(snapshot: model.snapshot) } } .padding(14) .frame(width: 280) } } // MARK: - Bausteine /// Überschrift eines Abschnitts. Ohne sie ist eine lange Werteliste nur eine /// lange Werteliste. private struct SectionTitle: View { let text: LocalizedStringKey var body: some View { Text(text, bundle: .module) .font(.caption.weight(.semibold)) .foregroundStyle(.tint) .textCase(.uppercase) } } private struct Row: View { let label: String let value: String var emphasis: Color? var body: some View { HStack { Text(label).font(.callout) Spacer(minLength: 12) Text(value) .font(.callout.monospacedDigit()) .foregroundStyle(emphasis ?? .secondary) } } } /// Ein Ring mit Zahl in der Mitte. private struct Dial: View { let value: Double let caption: String let text: String var tint: Color = .accentColor var body: some View { // Sechs Punkte Abstand statt zwei: die Beschriftung klebte am Ring und // sah aus, als gehörte sie noch hinein. VStack(spacing: 6) { ZStack { Circle().stroke(.quaternary, lineWidth: 5) Circle() .trim(from: 0, to: min(max(value, 0), 1)) .stroke(tint, style: .init(lineWidth: 5, lineCap: .round)) .rotationEffect(.degrees(-90)) Text(text).font(.system(size: 15, weight: .medium).monospacedDigit()) } .frame(width: 62, height: 62) Text(caption).font(.caption2).foregroundStyle(.secondary) } .padding(.bottom, 2) } } // MARK: - Prozessor private struct ProcessorDetail: View { let model: MetricsModel let metric: MetricKind var body: some View { let snapshot = model.snapshot HStack(spacing: 14) { Dial(value: metric == .cpu ? snapshot.cpu.total : (snapshot.gpu ?? 0), caption: metric == .cpu ? "CPU" : "GPU", text: MetricFormat.percent(metric == .cpu ? snapshot.cpu.total : (snapshot.gpu ?? 0))) if let celsius = metric == .cpu ? snapshot.cpu.temperature : snapshot.gpuTemperature { Dial(value: celsius / 100, caption: String(localized: "popover.temperature", bundle: .module), text: MetricFormat.temperature(celsius), tint: MetricSummary.temperatureColor(celsius)) } if let fan = snapshot.fans.first { Dial(value: fan.fraction, caption: String(localized: "popover.fans", bundle: .module), text: MetricFormat.percent(fan.fraction)) } } .frame(maxWidth: .infinity) if metric == .cpu, !model.snapshot.cpu.perCore.isEmpty { SectionTitle(text: "popover.perCore") CoreGrid(values: model.snapshot.cpu.perCore) } SectionTitle(text: "popover.power") if let cpu = snapshot.power.cpu { Row(label: String(localized: "popover.power.cpu", bundle: .module), value: MetricFormat.watts(cpu)) } if let display = snapshot.power.display { Row(label: String(localized: "popover.power.display", bundle: .module), value: MetricFormat.watts(display)) } if let total = snapshot.power.total { Row(label: String(localized: "popover.power.total", bundle: .module), value: MetricFormat.watts(total), emphasis: .primary) } if !snapshot.fans.isEmpty { SectionTitle(text: "popover.fans") ForEach(snapshot.fans) { fan in Row(label: fan.name, value: "\(Int(fan.rpm)) U/min") } } Divider() Picker("", selection: Binding( get: { model.displayMode(for: metric) }, set: { model.setDisplayMode($0, for: metric) })) { Text("metric.mode.usage", bundle: .module).tag(MetricDisplayMode.usage) Text("metric.mode.temperature", bundle: .module).tag(MetricDisplayMode.temperature) } .pickerStyle(.segmented) .labelsHidden() } } /// Ein Kästchen je Kern statt einer Balkenreihe. /// /// Bei fünfzehn Kernen wird eine Reihe unleserlich schmal. Als Raster bleibt /// jeder Kern erkennbar, und man sieht auf einen Blick, ob eine einzelne Last /// läuft oder alles arbeitet. private struct CoreGrid: View { let values: [Double] var body: some View { LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 3), count: 8), spacing: 3) { ForEach(Array(values.enumerated()), id: \.offset) { _, value in RoundedRectangle(cornerRadius: 2, style: .continuous) .fill(Color.accentColor.opacity(0.18)) .overlay(alignment: .bottom) { RoundedRectangle(cornerRadius: 2, style: .continuous) .fill(Color.accentColor) .frame(height: max(14 * value, 1.5)) } .frame(height: 14) } } } } // MARK: - Speicher private struct MemoryDetail: View { let snapshot: MetricsSnapshot var body: some View { let memory = snapshot.memory HStack(spacing: 14) { Dial(value: memory.usedFraction, caption: String(localized: "popover.memory.used", bundle: .module), text: MetricFormat.percent(memory.usedFraction), tint: pressureColor) VStack(alignment: .leading, spacing: 3) { legend(.accentColor, "popover.memory.app", memory.app) legend(.purple, "popover.memory.wired", memory.wired) legend(.teal, "popover.memory.compressed", memory.compressed) legend(.gray, "popover.memory.free", memory.free) } } SectionTitle(text: "popover.memory.swap") // Auslagerung ist der Wert, der zählt: solange nicht ausgelagert wird, // ist ein voller Speicher kein Problem — macOS füllt ihn absichtlich. Row(label: String(localized: "popover.memory.swapUsed", bundle: .module), value: "\(bytes(memory.swapUsed)) / \(bytes(memory.swapTotal))", emphasis: memory.swapUsed > 512 * 1024 * 1024 ? .orange : nil) if !snapshot.topMemoryProcesses.isEmpty { SectionTitle(text: "popover.processes") ForEach(snapshot.topMemoryProcesses) { process in Row(label: process.name, value: bytes(process.bytes)) } } } private var pressureColor: Color { switch snapshot.memory.pressure { case .normal: .green case .warning: .orange case .critical: .red } } private func legend(_ color: Color, _ key: LocalizedStringKey, _ value: UInt64) -> some View { HStack(spacing: 5) { Circle().fill(color).frame(width: 7, height: 7) Text(key, bundle: .module).font(.caption) Spacer(minLength: 8) Text(bytes(value)).font(.caption.monospacedDigit()).foregroundStyle(.secondary) } } private func bytes(_ value: UInt64) -> String { ByteCountFormatter.string(fromByteCount: Int64(value), countStyle: .binary) } } // MARK: - Akku private struct BatteryDetail: View { let snapshot: MetricsSnapshot let control: (any ChargeLimitReading)? var body: some View { if let battery = snapshot.battery { HStack(spacing: 14) { Dial(value: battery.charge, caption: String(localized: "metric.battery", bundle: .module), text: MetricFormat.percent(battery.charge), tint: battery.isCharging ? .green : (battery.charge < 0.2 ? .red : .accentColor)) VStack(alignment: .leading, spacing: 4) { if let remaining = battery.timeRemaining { Text(duration(remaining)).font(.title3.monospacedDigit()) Text(battery.isCharging ? "popover.battery.untilFull" : "popover.battery.remaining", bundle: .module) .font(.caption).foregroundStyle(.secondary) } else { Text(battery.isPluggedIn ? "popover.battery.pluggedIn" : "popover.battery.calculating", bundle: .module) .font(.callout).foregroundStyle(.secondary) } } } // Das Ladelimit steht hier und nicht in den Einstellungen: es ist // eine Akkusache, und man greift danach, während man auf den // Ladestand schaut. if let control { Divider().overlay(Onyx.Color.hairline) ChargeLimitControl(control: control, roomy: true) } SectionTitle(text: "popover.battery.condition") if let health = battery.health { Row(label: String(localized: "popover.battery.health", bundle: .module), value: MetricFormat.percent(health)) } if let cycles = battery.cycleCount { Row(label: String(localized: "popover.battery.cycles", bundle: .module), value: "\(cycles)") } if let sensor = snapshot.sensors.first(where: { $0.key == "TB0T" }) { Row(label: String(localized: "popover.temperature", bundle: .module), value: MetricFormat.temperature(sensor.celsius)) } SectionTitle(text: "popover.power") if let watts = battery.watts { Row(label: String(localized: battery.isCharging ? "popover.battery.charging" : "popover.battery.draining", bundle: .module), value: MetricFormat.watts(abs(watts)), emphasis: .primary) } if let adapter = snapshot.power.adapter, adapter > 0 { Row(label: String(localized: "popover.power.adapter", bundle: .module), value: MetricFormat.watts(adapter)) } if let voltage = snapshot.power.busVoltage { Row(label: String(localized: "popover.battery.voltage", bundle: .module), value: String(format: "%.2f V", voltage)) } Divider() // Ehrlich benennen, was fehlt: das Ladelimit braucht Schreibzugriff // auf den SMC und damit den privilegierten Helfer — und der Key ist // auf diesem Modell noch nicht identifiziert. Text("popover.battery.limitComing", bundle: .module) .font(.caption).foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } else { Text("popover.battery.none", bundle: .module).foregroundStyle(.secondary) } } private func duration(_ seconds: TimeInterval) -> String { let total = Int(seconds / 60) return total >= 60 ? "\(total / 60) h \(total % 60) min" : "\(total) min" } } // MARK: - Sensoren private struct SensorDetail: View { let snapshot: MetricsSnapshot var body: some View { SectionTitle(text: "popover.temperature") ForEach(snapshot.sensors) { sensor in HStack { Text(sensor.name).font(.callout) Spacer(minLength: 12) Text(MetricFormat.temperature(sensor.celsius)) .font(.callout.monospacedDigit()) .foregroundStyle(MetricSummary.temperatureColor(sensor.celsius)) // Der Ring macht aus einer Zahl einen Vergleich: 45° neben 79° // sieht man schneller als man es liest. Circle() .trim(from: 0, to: min(sensor.celsius / 100, 1)) .stroke(MetricSummary.temperatureColor(sensor.celsius), style: .init(lineWidth: 2, lineCap: .round)) .rotationEffect(.degrees(-90)) .frame(width: 12, height: 12) } } if !snapshot.fans.isEmpty { SectionTitle(text: "popover.fans") ForEach(snapshot.fans) { fan in VStack(alignment: .leading, spacing: 2) { HStack { Text(fan.name).font(.callout) Spacer(minLength: 12) Text("\(Int(fan.rpm)) U/min").font(.callout.monospacedDigit()) .foregroundStyle(.secondary) } // Ohne Bezug sagt eine Drehzahl nichts: der Balken zeigt sie // zwischen dem Minimum und Maximum der Firmware. ProgressView(value: fan.fraction).controlSize(.small) } } Text("metric.fan.controlComing", bundle: .module) .font(.caption).foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } } }