Sechs neue Widgets (CPU, GPU, Speicher, Akku, Sensoren, Netzwerk) und dieselben sechs als einzeln aktivierbare Menüleisten-Module mit fünf Darstellungsarten. Panel-Widget und Menüleisten-Modul derselben Größe teilen sich die Messschleife über Referenzzählung. Ohne das liefe für jede Anzeige ein eigener Timer mit denselben IOKit- und SMC-Abfragen — bei sechs Modulen und ebenso vielen Widgets ein Vielfaches der nötigen Arbeit. Meldet sich der letzte Konsument ab, hört die Schleife auf; im Ruhezustand misst Onyx nichts. Die Differenzrechnung ist testgetrieben, und die Tests haben zwei echte Fehler gefunden. Erstens: mein Überlaufschutz beim Durchsatz hielt einen zurückgesetzten Zähler (Interface-Wechsel) für einen Überlauf und zeigte 4,3 GB/s. Da if_data64 64-Bit-Zähler liefert, die bei 10 Gbit/s erst nach 470 Jahren umlaufen, ist ein kleinerer Wert immer ein Zurücksetzen — die Unterscheidung produzierte nur den Ausreißer, den sie verhindern sollte. Zweitens: ByteCountFormatter schrieb bei null „Zero KB/s" statt „0 KB/s". Bei den CPU-Zählern bleibt der Überlaufschutz nötig: die sind 32 Bit breit und laufen nach gut 400 Tagen wirklich um, deshalb `&-` statt `-`. Sensoren kommen über den SMC-Leser aus Spike A statt über die private IOHID-Schnittstelle — auf dieser Maschine nachweislich verifiziert, und die dort gefundene Little-Endian-Eigenheit ist berücksichtigt. Nur eine kurze Liste benannter Fühler statt aller 3486 Keys: eine Wand aus Kürzeln ist keine Information. Gegengemessen statt vermutet: 15 Kerne, 39,8 W, GPU 50 %, 16,6/24 GB mit kritischem Druck, Akku 68 Zyklen bei 100 % Gesundheit, fünf Sensoren, zwei Lüfter, en0 mit 661 KB/s. Menüleisten-Module zeichnen in labelColor statt in den Onyx-Farben — nur die Systemfarbe passt sich heller wie dunkler Leiste an. Feste Breite je Darstellungsart, sonst schiebt jeder Messwert die halbe Leiste hin und her. Standardmäßig ist kein Modul aktiv: sechs neue Symbole beim ersten Start wären eine Zumutung. 145 Tests grün.
286 lines
10 KiB
Swift
286 lines
10 KiB
Swift
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
|
||
|
||
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))
|
||
.font(Onyx.Font.metric)
|
||
.foregroundStyle(MetricSummary.color(metric, model.snapshot))
|
||
.animation(Onyx.Motion.value, value: MetricSummary.value(metric, model.snapshot))
|
||
|
||
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))
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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 {
|
||
|
||
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
|
||
}
|
||
}
|
||
}
|