Der Regler war eine Pseudobedienung. Der SMC lehnt Schreibzugriffe auf CHLT ab — der Helfer hat es mit korrekter Größe versucht und „geschrieben (false), zurückgelesen 85" gemeldet. Das Sicherheitsnetz hat also die Wahrheit gesagt; die Oberfläche hat sie nur nicht gezeigt. Weiter zu raten hieße, im Lade-Subsystem Keys durchzuprobieren, und das ist die eine Operation in diesem Projekt, die Hardware dauerhaft beschädigen kann. Also: das Limit wird gelesen und angezeigt, mit einem Weg in die Systemeinstellungen, wo es tatsächlich geht. Der Schreibpfad ist aus dem privilegierten Helfer entfernt — ein Root-Dienst trägt nur, was benutzt wird. Die Akku-Kachel zeigte „40 %" und einen Verlauf über die letzten Sekunden. Der Ladestand ändert sich in Minuten, nicht in Sekunden; der Graph war eine waagerechte Linie. Jetzt: verbleibende Zeit bzw. Zeit bis voll, Leistung in Watt mit Richtung, Ladelimit, Zustand, Zyklen — und der Energiesparmodus. Der erklärt, warum die Maschine langsamer wirkt, und ohne diesen Hinweis sucht man den Grund woanders. In der Menüleiste standen fünf nackte Prozentzahlen nebeneinander. Man sah Zahlen und wusste nicht, welche wozu gehört. Zwei neue Darstellungen: Kürzel und Wert („CPU 34 %") sowie Symbol und Wert. Beim Akku stehen sie zuoberst in der Auswahl. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
363 lines
14 KiB
Swift
363 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) {
|
|
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 {
|
|
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
|
|
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)
|
|
}
|
|
}
|
|
}
|