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 } public let supportedSizes: [WidgetSize] 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" } self.supportedSizes = metric == .temperature ? [.medium, .large] : [.small, .medium] } public func makeView(size: WidgetSize) -> AnyView { AnyView(MetricWidgetView(model: model, metric: metric, size: size)) } } 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 let size: WidgetSize @State private var token: UUID? var body: some View { Group { switch (metric, size) { case (.temperature, _): SensorList(snapshot: model.snapshot, detailed: size == .large) case (_, .small): CompactMetric(model: model, metric: metric) default: 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 { HStack(spacing: 10) { CompactMetric(model: model, metric: metric) .frame(width: 84) Sparkline(values: model.series(metric), tint: MetricSummary.color(metric, model.snapshot, mode: model.displayMode(for: metric))) } } } /// 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 } } }