Files
onyx/Packages/OnyxKit/Sources/MetricsProvider/MetricMenuBarModule.swift
Guido Schmit d85425671d Aussagekräftige Popovers für CPU, Speicher, Akku und Netzwerk
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.
2026-08-10 22:17:13 +02:00

225 lines
8.4 KiB
Swift

import AppKit
import SwiftUI
import OnyxDesign
import OnyxMenuBar
/// Ein Menüleisten-Modul je Hardwaregröße.
///
/// Teilt sich das Modell und damit die Messschleife mit dem Panel-Widget
/// derselben Größe. Wer CPU im Panel **und** in der Menüleiste zeigt, bekommt
/// trotzdem nur eine Messung.
@MainActor
public final class MetricMenuBarModule: MenuBarModule {
public let id: String
public var displayName: String {
String(localized: .init(metric.localizationKey), bundle: .module)
}
private let metric: MetricKind
private let model: MetricsModel
private var token: UUID?
private var view: MetricStatusView?
private var observation: (any NSObjectProtocol)?
public init(metric: MetricKind, model: MetricsModel) {
self.metric = metric
self.model = model
self.id = "metric.\(metric.rawValue)"
}
public func makeStatusView(presentation: MenuBarPresentation) -> NSView {
let view = MetricStatusView(metric: metric, presentation: presentation)
view.update(snapshot: model.snapshot, history: model.series(metric),
mode: model.displayMode(for: metric))
self.view = view
return view
}
public func makePopoverView() -> AnyView {
AnyView(MetricPopoverContent(model: model, metric: metric))
}
public func activate() {
guard token == nil else { return }
token = model.addConsumer(interval: 2)
startObserving()
}
public func deactivate() {
if let token { model.removeConsumer(token) }
token = nil
observation = nil
view = nil
}
/// `withObservationTracking` meldet sich **einmal** und muss danach neu
/// eingerichtet werden sonst friert die Anzeige nach der ersten Messung ein.
private func startObserving() {
withObservationTracking {
_ = model.snapshot
_ = model.displayModes // auch auf den Moduswechsel reagieren
} onChange: {
Task { @MainActor [weak self] in
guard let self, token != nil else { return }
view?.update(snapshot: model.snapshot, history: model.series(metric),
mode: model.displayMode(for: metric))
startObserving()
}
}
}
}
/// Zeichnet den Messwert in die Menüleiste.
///
/// Eigene `NSView` statt SwiftUI: die Menüleiste verlangt eine feste Breite je
/// Darstellungsart, sonst springt bei jeder Messung die ganze Leiste. Mit
/// SwiftUI müsste man diese Breite doppelt führen einmal für das Layout und
/// einmal für `NSStatusItem.length`.
final class MetricStatusView: NSView {
private let metric: MetricKind
private let presentation: MenuBarPresentation
private var snapshot = MetricsSnapshot()
private var history: [Double] = []
private var mode: MetricDisplayMode = .usage
init(metric: MetricKind, presentation: MenuBarPresentation) {
self.metric = metric
self.presentation = presentation
super.init(frame: NSRect(x: 0, y: 0, width: presentation.width, height: 22))
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
override var intrinsicContentSize: NSSize {
NSSize(width: presentation.width, height: 22)
}
func update(snapshot: MetricsSnapshot, history: [Double], mode: MetricDisplayMode) {
self.snapshot = snapshot
self.history = history
self.mode = mode
needsDisplay = true
}
override func draw(_ dirtyRect: NSRect) {
// `labelColor` statt der Onyx-Farben: die Menüleiste ist mal hell, mal
// dunkel, und nur die Systemfarbe passt sich beidem an.
let color = NSColor.labelColor
switch presentation {
case .value:
drawText(MetricSummary.value(metric, snapshot, mode: mode), color: color)
case .symbol:
drawSymbol(color: color)
case .graph:
drawGraph(color: color, in: bounds.insetBy(dx: 2, dy: 5))
case .bars:
drawBars(color: color)
case .valueAndGraph:
let split = bounds.width * 0.55
drawText(MetricSummary.value(metric, snapshot, mode: mode), color: color,
in: NSRect(x: 0, y: 0, width: split, height: bounds.height))
drawGraph(color: color,
in: NSRect(x: split + 2, y: 5,
width: bounds.width - split - 4, height: bounds.height - 10))
}
}
private func drawText(_ text: String, color: NSColor, in rect: NSRect? = nil) {
let attributes: [NSAttributedString.Key: Any] = [
// Feste Ziffernbreite: ohne sie wackelt der Text bei jeder Messung.
.font: NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .regular),
.foregroundColor: color,
]
let string = NSAttributedString(string: text, attributes: attributes)
let area = rect ?? bounds
let size = string.size()
string.draw(at: NSPoint(x: area.midX - size.width / 2,
y: area.midY - size.height / 2))
}
private func drawSymbol(color: NSColor) {
guard let image = NSImage(systemSymbolName: metric.symbolName,
accessibilityDescription: nil) else { return }
image.isTemplate = true
let side: CGFloat = 15
let rect = NSRect(x: bounds.midX - side / 2, y: bounds.midY - side / 2,
width: side, height: side)
color.set()
image.draw(in: rect)
}
private func drawGraph(color: NSColor, in rect: NSRect) {
guard history.count > 1, let context = NSGraphicsContext.current?.cgContext else { return }
let step = rect.width / CGFloat(history.count - 1)
context.setStrokeColor(color.withAlphaComponent(0.85).cgColor)
context.setLineWidth(1)
context.setLineJoin(.round)
for (index, value) in history.enumerated() {
let point = CGPoint(x: rect.minX + CGFloat(index) * step,
y: rect.minY + rect.height * CGFloat(min(max(value, 0), 1)))
index == 0 ? context.move(to: point) : context.addLine(to: point)
}
context.strokePath()
}
/// Ein Balken je Kern. Bei fünfzehn Kernen ist das kein Diagramm mehr,
/// sondern ein Muster aber genau daran erkennt man auf einen Blick, ob
/// eine einzelne Last läuft oder alles ausgelastet ist.
private func drawBars(color: NSColor) {
// Bei der CPU-Auslastung ein Balken je Kern; sonst auch bei der
// CPU-Temperatur ein einzelner Balken.
let values = (metric == .cpu && mode == .usage)
? snapshot.cpu.perCore
: [MetricSummary.fraction(metric, snapshot, mode: mode)]
guard !values.isEmpty, let context = NSGraphicsContext.current?.cgContext else { return }
let area = bounds.insetBy(dx: 2, dy: 5)
let gap: CGFloat = values.count > 4 ? 1 : 2
let width = (area.width - gap * CGFloat(values.count - 1)) / CGFloat(values.count)
for (index, value) in values.enumerated() {
let height = max(area.height * CGFloat(min(max(value, 0), 1)), 1)
let rect = NSRect(x: area.minX + CGFloat(index) * (width + gap),
y: area.minY, width: width, height: height)
context.setFillColor(color.withAlphaComponent(0.85).cgColor)
context.fill(rect)
}
}
}
extension MenuBarPresentation {
/// Feste Breite je Darstellungsart.
///
/// Ohne sie ändert das Element bei jedem Messwert seine Größe und schiebt
/// alle Symbole rechts davon hin und her das fällt in der Menüleiste
/// sofort unangenehm auf.
var width: CGFloat {
switch self {
case .value: 42
case .symbol: 22
case .graph: 34
case .bars: 30
case .valueAndGraph: 74
}
}
}
public extension MetricSummary {
/// Der Messwert als Anteil von 0 bis 1 für Balken und Graphen.
static func fraction(_ metric: MetricKind, _ snapshot: MetricsSnapshot) -> Double {
switch metric {
case .cpu: snapshot.cpu.total
case .gpu: snapshot.gpu ?? 0
case .memory: snapshot.memory.usedFraction
case .battery: snapshot.battery?.charge ?? 0
case .temperature: (snapshot.sensors.map(\.celsius).max() ?? 0) / 100
}
}
}