Ein Popover, das nur den Wert größer wiederholt, den man gerade angeklickt hat, ist verschenkter Platz. Hier ist Raum — anders als in der Menüleiste, wo jede Zahl um Breite kämpft. Prozessor: Ringe für Auslastung, Temperatur und Lüfter, alle Kerne als Raster statt als Balkenreihe (bei fünfzehn Kernen wird eine Reihe unleserlich schmal), Leistungsaufschlüsselung und beide Lüfter mit Namen. Die Leistungsschlüssel sind gegen iStat Menus abgeglichen: PHPC liefert 14,02 W und deckt sich mit deren CPU-Wert, PSTR mit der Gesamtleistung. Frequenzen fehlen bewusst — die stehen nicht im SMC, dafür bräuchte es IOReport. Speicher: Ring mit Aufschlüsselung nach Programmen, Reserviertem, Komprimiertem und Freiem, Auslagerung mit Gesamtgröße und die fünf größten Verbraucher über phys_footprint — derselbe Wert, den der Aktivitätsmonitor zeigt, nicht der virtuelle Adressraum. Akku: Restzeit, Kapazität, Zyklen, Temperatur, Lade- oder Entladeleistung, Netzteilleistung und Spannung. Netzwerk: Verlauf in beide Richtungen auf gemeinsamem Maßstab — getrennte Maßstäbe ließen einen Upload von 20 KB/s so hoch aussehen wie einen Download von 20 MB/s. Dazu Spitzenwerte, VPN-Hinweis, alle Adressen und die Summen seit dem Start. Was fehlt, wird benannt statt weggelassen: Durchsatz je Programm gibt es ohne root nicht, das Ladelimit braucht den privilegierten Helfer und einen Key, der auf diesem Modell noch nicht identifiziert ist. Sensorliste erweitert und benannt: CPU-Kerne und Grafik stehen vorn, dazu Luftstrom, Thunderbolt links und rechts. Lüfter heißen links und rechts statt 0 und 1. Nebenbei ein Übersetzungsfehler behoben: die Modus-Auswahl in den Einstellungen zeigte die Rohschlüssel, weil die Texte im MetricsProvider-Bündel liegen und die Einstellungen im App-Bündel suchen. 145 Tests grün.
353 lines
14 KiB
Swift
353 lines
14 KiB
Swift
import SwiftUI
|
|
|
|
/// 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) {
|
|
switch metric {
|
|
case .cpu, .gpu: ProcessorDetail(model: model, metric: metric)
|
|
case .memory: MemoryDetail(snapshot: model.snapshot)
|
|
case .battery: BatteryDetail(snapshot: model.snapshot)
|
|
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 {
|
|
VStack(spacing: 2) {
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|