import SwiftUI import OnyxDesign import OnyxWidgetKit /// Ein Widget je Hardwaregröße. Alle teilen sich dasselbe Modell und damit /// dieselbe Messschleife. public struct MetricWidget: OnyxWidget { public let id: String public let metric: MetricKind public var displayName: String { String(localized: .init(metric.localizationKey), bundle: .module) } public var symbolName: String { metric.symbolName } private let model: MetricsModel public init(metric: MetricKind, model: MetricsModel) { self.metric = metric self.model = model self.id = switch metric { case .cpu: "cpu" case .gpu: "gpu" case .memory: "memory" case .battery: "battery" case .temperature: "sensors" } } public func makeView() -> AnyView { AnyView(MetricWidgetView(model: model, metric: metric)) } } extension MetricKind { var localizationKey: String { switch self { case .cpu: "metric.cpu" case .gpu: "metric.gpu" case .memory: "metric.memory" case .battery: "metric.battery" case .temperature: "metric.sensors" } } } private struct MetricWidgetView: View { let model: MetricsModel let metric: MetricKind @State private var token: UUID? var body: some View { Group { // Die Kachel ist hochkant: die Sensorliste hat Platz für Details, // alles andere zeigt Zahl und Verlauf. if metric == .temperature { SensorList(snapshot: model.snapshot, detailed: true) } else if metric == .battery { BatteryTile(model: model) } else { WideMetric(model: model, metric: metric) } } .onAppear { // Bei offenem Panel schneller messen: dort sieht man die Zahl, // und ein Wert, der nur alle zwei Sekunden springt, wirkt träge. token = model.addConsumer(interval: 1) } .onDisappear { if let token { model.removeConsumer(token) } token = nil } } } // MARK: - Bausteine private struct CompactMetric: View { let model: MetricsModel let metric: MetricKind private var mode: MetricDisplayMode { model.displayMode(for: metric) } var body: some View { VStack(alignment: .leading, spacing: 4) { Label { Text(String(localized: .init(metric.localizationKey), bundle: .module)) .font(Onyx.Font.caption) .foregroundStyle(Onyx.Color.textSecondary) } icon: { Image(systemName: metric.symbolName) .font(.system(size: 11)) .foregroundStyle(Onyx.Color.accent) } Spacer(minLength: 0) Text(MetricSummary.value(metric, model.snapshot, mode: mode)) .font(Onyx.Font.metric) .foregroundStyle(MetricSummary.color(metric, model.snapshot, mode: mode)) .animation(Onyx.Motion.value, value: MetricSummary.value(metric, model.snapshot, mode: mode)) if let detail = MetricSummary.detail(metric, model.snapshot) { Text(detail) .font(.system(size: 9)) .monospacedDigit() .foregroundStyle(Onyx.Color.textTertiary) .lineLimit(1) } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } } private struct WideMetric: View { let model: MetricsModel let metric: MetricKind var body: some View { // Übereinander, nicht nebeneinander: die Kachel ist hochkant, und ein // Verlauf über die volle Breite liest sich besser als einer, der sich // die Zeile mit der Zahl teilt. VStack(alignment: .leading, spacing: 8) { CompactMetric(model: model, metric: metric) Spacer(minLength: 0) Sparkline(values: model.series(metric), tint: MetricSummary.color(metric, model.snapshot, mode: model.displayMode(for: metric))) .frame(height: 46) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } } /// Verlaufskurve. Bewusst ohne Achsen und Beschriftung: auf 90 × 40 Punkten /// ist die Form die Information, nicht der Zahlenwert. public struct Sparkline: View { let values: [Double] let tint: Color public init(values: [Double], tint: Color) { self.values = values self.tint = tint } public var body: some View { GeometryReader { geometry in let points = Array(values.suffix(MetricsModel.historyLength)) if points.count > 1 { let size = geometry.size let step = size.width / CGFloat(points.count - 1) let coordinates = points.enumerated().map { index, value in CGPoint(x: CGFloat(index) * step, y: size.height * (1 - CGFloat(min(max(value, 0), 1)))) } let line = Path { path in for (index, point) in coordinates.enumerated() { index == 0 ? path.move(to: point) : path.addLine(to: point) } } // Dieselbe Kurve, unten geschlossen — die Fläche darunter gibt // dem Verlauf Gewicht, ohne dass eine zweite Linie nötig wäre. let area = Path { path in path.move(to: CGPoint(x: 0, y: size.height)) coordinates.forEach { path.addLine(to: $0) } path.addLine(to: CGPoint(x: size.width, y: size.height)) path.closeSubpath() } ZStack { area.fill(LinearGradient(colors: [tint.opacity(0.22), .clear], startPoint: .top, endPoint: .bottom)) line.stroke(tint, style: .init(lineWidth: 1.5, lineJoin: .round)) } } } } } private struct SensorList: View { let snapshot: MetricsSnapshot let detailed: Bool var body: some View { VStack(alignment: .leading, spacing: 3) { ForEach(snapshot.sensors.prefix(detailed ? 8 : 3)) { sensor in HStack(spacing: 6) { Text(sensor.name) .font(.system(size: 10)) .foregroundStyle(Onyx.Color.textSecondary) .lineLimit(1) Spacer(minLength: 0) Text(MetricFormat.temperature(sensor.celsius)) .font(Onyx.Font.metricSmall) .foregroundStyle(MetricSummary.temperatureColor(sensor.celsius)) } } if detailed, !snapshot.fans.isEmpty { Divider().overlay(Onyx.Color.hairline).padding(.vertical, 2) ForEach(snapshot.fans) { fan in HStack(spacing: 6) { Image(systemName: "fan") .font(.system(size: 9)) .foregroundStyle(Onyx.Color.textTertiary) Text("\(Int(fan.rpm))") .font(Onyx.Font.metricSmall) .foregroundStyle(Onyx.Color.textSecondary) Text(verbatim: "U/min") .font(.system(size: 9)) .foregroundStyle(Onyx.Color.textTertiary) Spacer(minLength: 0) } } } if snapshot.sensors.isEmpty { Text("metric.sensors.none", bundle: .module) .font(Onyx.Font.caption) .foregroundStyle(Onyx.Color.textTertiary) } Spacer(minLength: 0) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } } /// Wie eine Größe zu Text und Farbe wird — an einer Stelle, damit Panel und /// Menüleiste nicht auseinanderlaufen. public enum MetricSummary { /// Der anzuzeigende Wert, abhängig vom gewählten Modus. public static func value(_ metric: MetricKind, _ snapshot: MetricsSnapshot, mode: MetricDisplayMode) -> String { guard mode == .temperature, metric.supportsDisplayModes else { return value(metric, snapshot) } let celsius = metric == .cpu ? snapshot.cpu.temperature : snapshot.gpuTemperature // Solange die Fühlersuche im Hintergrund läuft, gibt es keinen Wert. // „—" ist dann die richtige Auskunft, nicht eine 0. return celsius.map(MetricFormat.temperature) ?? "—" } public static func color(_ metric: MetricKind, _ snapshot: MetricsSnapshot, mode: MetricDisplayMode) -> Color { guard mode == .temperature, metric.supportsDisplayModes else { return color(metric, snapshot) } let celsius = metric == .cpu ? snapshot.cpu.temperature : snapshot.gpuTemperature return temperatureColor(celsius ?? 0) } public static func fraction(_ metric: MetricKind, _ snapshot: MetricsSnapshot, mode: MetricDisplayMode) -> Double { guard mode == .temperature, metric.supportsDisplayModes else { return fraction(metric, snapshot) } let celsius = metric == .cpu ? snapshot.cpu.temperature : snapshot.gpuTemperature // Auf 100 °C normiert: darüber wird ohnehin gedrosselt. return min(max((celsius ?? 0) / 100, 0), 1) } public static func value(_ metric: MetricKind, _ snapshot: MetricsSnapshot) -> String { switch metric { case .cpu: MetricFormat.percent(snapshot.cpu.total) case .gpu: snapshot.gpu.map(MetricFormat.percent) ?? "—" case .memory: MetricFormat.percent(snapshot.memory.usedFraction) case .battery: snapshot.battery.map { MetricFormat.percent($0.charge) } ?? "—" case .temperature: snapshot.sensors.map(\.celsius).max() .map(MetricFormat.temperature) ?? "—" } } public static func detail(_ metric: MetricKind, _ snapshot: MetricsSnapshot) -> String? { switch metric { case .cpu: return snapshot.cpu.watts.map(MetricFormat.watts) case .memory: return ByteCountFormatter.string(fromByteCount: Int64(snapshot.memory.used), countStyle: .binary) case .battery: guard let battery = snapshot.battery else { return nil } if battery.isCharging { return String(localized: "metric.battery.charging", bundle: .module) } // Beim Entladen ist die Leistung negativ; der Betrag ist gemeint. return battery.watts.map { MetricFormat.watts(abs($0)) } case .gpu, .temperature: return nil } } public static func color(_ metric: MetricKind, _ snapshot: MetricsSnapshot) -> Color { switch metric { case .memory: switch snapshot.memory.pressure { case .normal: return Onyx.Color.textPrimary case .warning: return Onyx.Color.warning case .critical: return Onyx.Color.critical } case .battery: guard let battery = snapshot.battery else { return Onyx.Color.textPrimary } if battery.isCharging { return Onyx.Color.positive } return battery.charge < 0.2 ? Onyx.Color.critical : Onyx.Color.textPrimary case .temperature: return temperatureColor(snapshot.sensors.map(\.celsius).max() ?? 0) case .cpu, .gpu: return Onyx.Color.textPrimary } } public static func temperatureColor(_ celsius: Double) -> Color { // Grenzwerte für Apple Silicon: unter 70 unauffällig, ab 90 wird // gedrosselt. Dazwischen ist es warm, aber unproblematisch. switch celsius { case ..<70: Onyx.Color.textPrimary case ..<90: Onyx.Color.warning default: Onyx.Color.critical } } } /// Die Akku-Kachel. /// /// „40 %" und ein Verlauf über die letzten Sekunden ist keine Auskunft — der /// Ladestand ändert sich in Minuten, nicht in Sekunden, und ein Graph darüber /// ist eine waagerechte Linie. Was man wissen will: wie lange noch, zieht oder /// gibt der Akku gerade, läuft der Energiesparmodus, wo endet das Laden. private struct BatteryTile: View { let model: MetricsModel var body: some View { let battery = model.snapshot.battery VStack(alignment: .leading, spacing: 8) { HStack(alignment: .firstTextBaseline, spacing: 6) { Text(MetricFormat.percent(battery?.charge ?? 0)) .font(Onyx.Font.metric).monospacedDigit() .foregroundStyle(tint(battery)) if let battery, battery.isCharging { Image(systemName: "bolt.fill") .font(.system(size: 11)) .foregroundStyle(Onyx.Color.positive) } Spacer(minLength: 0) } if let battery { if let remaining = battery.timeRemaining { Text(battery.isCharging ? "battery.untilFull \(duration(remaining))" : "battery.remaining \(duration(remaining))", bundle: .module) .font(Onyx.Font.caption) .foregroundStyle(Onyx.Color.textSecondary) } else if battery.isPluggedIn { Text("battery.pluggedIn", bundle: .module) .font(Onyx.Font.caption) .foregroundStyle(Onyx.Color.textSecondary) } Spacer(minLength: 0) VStack(alignment: .leading, spacing: 3) { if let watts = battery.watts, abs(watts) > 0.1 { row(watts > 0 ? "battery.charging" : "battery.draining", value: String(format: "%.1f W", abs(watts))) } if let limit = model.chargeControl?.chargeLimit, limit < 100 { row("charge.limit", value: "\(limit) %") } if let health = battery.health { row("battery.health", value: MetricFormat.percent(health)) } if let cycles = battery.cycleCount { row("battery.cycles", value: "\(cycles)") } } // Der Energiesparmodus erklärt, warum die Maschine langsamer // wirkt. Ohne diesen Hinweis sucht man den Grund woanders. if battery.isLowPower { HStack(spacing: 4) { Image(systemName: "leaf.fill").font(.system(size: 9)) Text("battery.lowPower", bundle: .module).font(Onyx.Font.metricSmall) } .foregroundStyle(Onyx.Color.warning) } } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } private func row(_ key: String.LocalizationValue, value: String) -> some View { HStack(spacing: 4) { Text(String(localized: key, bundle: .module)) .foregroundStyle(Onyx.Color.textTertiary) Spacer(minLength: 4) Text(value).monospacedDigit().foregroundStyle(Onyx.Color.textSecondary) } .font(Onyx.Font.metricSmall) } private func tint(_ battery: BatteryReading?) -> Color { guard let battery else { return Onyx.Color.textPrimary } if battery.isCharging { return Onyx.Color.positive } return battery.charge < 0.2 ? Onyx.Color.critical : Onyx.Color.textPrimary } private func duration(_ seconds: TimeInterval) -> String { let total = Int(seconds) return total >= 3600 ? "\(total / 3600) h \(total % 3600 / 60) min" : "\(total / 60) min" } }