Files
onyx/Packages/OnyxKit/Sources/MetricsProvider/MetricPopovers.swift
Scarriffle 4c96aced5a Diagramme rechnen mit der vollen Länge, eigene Symbole für CPU und GPU
Frisch gestartet hat der Verlauf fünf Messwerte statt sechzig. Gerechnet
wurde aber „Breite geteilt durch Anzahl" — also war jeder Balken ein
Fünftel breit, und mit jedem weiteren wurden sie schmaler. Das Diagramm sah
nach einem Ausschlag aus und bedeutete nichts.

Jetzt kommt die Schrittweite aus der vollen Länge. Fehlende Werte bleiben
als freie Fläche links stehen; der Verlauf wächst nach links weg, wie bei
jedem Zeitdiagramm.

Das steckte an **drei** Stellen, alle mit derselben Rechnung: dem
Balkendiagramm im Netzwerk-Popover, der Sparkline in den Kacheln und der
Linie in den Menüleisten-Modulen. Die gemeinsame Rechnung liegt jetzt in
`GraphLayout` und ist geprüft, statt dreimal danebenzugehen.

Nebenbei: die Fläche unter der Sparkline begann am linken Rand statt am
ersten Messwert — ein Keil über die ganze Breite, wo noch nichts gemessen
war.

CPU und GPU bekommen eigene Symbole aus chip.svg und graphic-card.svg. SF
Symbols hat für beides nur denselben Chip, und „cpu" neben „cpu.fill" ist
kein Unterschied, den man in der Menüleiste erkennt.

Die Beschriftung unter den Ringen klebte am Rand und sah aus, als gehörte
sie noch hinein — sechs Punkte Abstand statt zwei. Und die Popover für CPU
und GPU tragen jetzt einen Titel: bei drei gleich aussehenden Ringen sieht
man sonst nicht, vor welchem man steht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 13:33:28 +02:00

371 lines
14 KiB
Swift

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)
}
}
}