Files
onyx/Packages/OnyxKit/Sources/MetricsProvider/MetricMenuBarModule.swift
Scarriffle c44bb3af30 Menüleiste springt nicht mehr, Mixer fasst Hilfsprozesse zusammen
Die Breite folgte der Stellenzahl des aktuellen Werts. Aus „14 KB/s" wird
„431 KB/s", und mit der zusätzlichen Stelle wanderte die halbe Leiste. Jetzt
wieder feste Breiten, bemessen am breitesten Wert, der vorkommen kann.

Der frühere Fehler war nicht die feste Breite, sondern der falsche
Bezugswert: gezeichnet wird „63°", bemessen wurde „100 °C" — fünfzehn Punkte
für zwei Zeichen, die nie erscheinen. Nachgemessen: zwei Aufnahmen im
Abstand von sechs Sekunden mit geänderten Werten ergeben identische Breiten.

Der Mixer fasst Hilfsprozesse mit ihrem Programm zusammen. „Google Chrome"
und „Google Chrome Helper" standen als zwei Zeilen nebeneinander, mit
getrennten Reglern für dasselbe Programm — zieht man den einen, hört man
den anderen. Chrome hat je Tab einen Helfer; fünfzehn Regler für einen
Browser sind keine Auskunft.

Abgegriffen wird weiterhin je Prozess, geregelt aber je Programm. Der
Schlüssel der Taps muss deshalb den Prozess mitführen: mehrere Helfer teilen
sich eine Kennung und überschrieben sich sonst gegenseitig. Der Pegel einer
Zeile ist der lauteste ihrer Prozesse.

Die Zeilen sind einzeilig statt zweizeilig: Symbol, Stummschalter, Regler,
Wert. Der Name ist weg — bei einem erkennbaren Programmsymbol ist er
redundant, und die gewonnene Zeile macht die Liste halb so hoch. Dafür ist
das Symbol größer, und wer den Namen braucht, hält den Zeiger darauf.
Popover 420 statt 330 Punkte breit.

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

374 lines
15 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)"
}
/// Kurz genug für die Menüleiste. Arbeitsspeicher" wäre dort ein Balken.
public var shortLabel: String {
switch metric {
case .cpu: "CPU"
case .gpu: "GPU"
case .memory: "RAM"
case .battery: String(localized: "menubar.short.battery", bundle: .module)
// Der gewählte Sensor gibt sein eigenes Kürzel Temp" neben einer
// Zahl sagt nicht, welcher Punkt gemeint ist.
case .temperature: model.selectedSensorName
?? String(localized: "menubar.short.temperature", bundle: .module)
}
}
public func makeStatusView(presentation: MenuBarPresentation) -> NSView {
let view = MetricStatusView(metric: metric, presentation: presentation,
label: shortLabel)
view.sensorCelsius = model.selectedSensorCelsius
view.sensorCelsius = model.selectedSensorCelsius
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, WidthReporting {
private let metric: MetricKind
private let presentation: MenuBarPresentation
private var snapshot = MetricsSnapshot()
private var history: [Double] = []
private var mode: MetricDisplayMode = .usage
private let label: String
init(metric: MetricKind, presentation: MenuBarPresentation, label: String) {
self.metric = metric
self.presentation = presentation
self.label = label
super.init(frame: NSRect(x: 0, y: 0, width: 22, height: 22))
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
/// Läuft nur, solange geladen wird nur dann bewegt sich der Blitz.
///
/// 30 Bilder je Sekunde reichen für ein weiches Faden und kosten deutlich
/// weniger als die 60, die man reflexhaft nähme. Steht das Ladekabel nicht
/// drin, läuft gar nichts.
private var fadeTimer: Timer?
private var fadePhase: Double = 0
private var shownWidth: CGFloat = MenuBarText.minimumWidth
/// Der breiteste Wert, der vorkommen kann.
///
/// **Fest**, nicht am aktuellen Wert bemessen: ein Element, das bei
/// 431" breiter wird als bei 14", schiebt alle Symbole rechts davon.
///
/// Der frühere Fehler war nicht die feste Breite, sondern der falsche
/// Bezugswert gezeichnet wird 63°", bemessen wurde 100 °C".
private var reference: String {
switch (metric, mode) {
case (.temperature, _), (_, .temperature): "100°"
default: "100 %"
}
}
override var intrinsicContentSize: NSSize {
NSSize(width: shownWidth, height: 22)
}
/// Der Platz für das, was **gerade** dasteht.
///
/// Auf die breiteste denkbare Zeichenfolge zu dimensionieren war zu teuer:
/// Temp 53°" stand in einer Box für Temp 100 °C", und in der Menüleiste
/// ist Platz das knappste Gut. `AdaptiveWidth` nimmt dem die Unruhe
/// sofort wachsen, zögernd schrumpfen.
private var width: CGFloat {
switch presentation {
case .symbol: max(MenuBarText.minimumWidth, symbolSlot + 4)
case .graph: 28
case .bars: barsWidth
case .value: MenuBarText.width(for: reference)
case .valueAndGraph: MenuBarText.width(for: reference) + 2 + 28
case .labelAndValue: MenuBarText.width(for: "\(label) \(reference)")
case .symbolAndValue:
MenuBarText.width(for: reference) + symbolSlot + MenuBarText.innerGap
}
}
/// Ein Balken je Kern braucht Platz für jeden Kern. Bei fünfzehn ist eine
/// feste Breite entweder zu eng oder überall sonst zu weit.
private var barsWidth: CGFloat {
let count = (metric == .cpu && mode == .usage) ? max(snapshot.cpu.perCore.count, 1) : 1
guard count > 1 else { return MenuBarText.minimumWidth }
return min(CGFloat(count) * 2 + CGFloat(count - 1) * 1 + 4, 60)
}
/// Was tatsächlich dasteht. Bei den Sensoren der gewählte Punkt statt des
/// stillen Maximums über alle.
private var displayValue: String {
if metric == .temperature, let celsius = sensorCelsius {
return MetricFormat.temperature(celsius)
}
return MetricSummary.value(metric, snapshot, mode: mode)
}
/// Meldet, wenn sich die nötige Breite geändert hat der Controller stellt
/// das Element danach neu ein.
var onWidthChange: ((CGFloat) -> Void)?
/// Der gewählte Sensor, falls einer gewählt ist.
var sensorCelsius: Double?
func update(snapshot: MetricsSnapshot, history: [Double], mode: MetricDisplayMode) {
self.snapshot = snapshot
self.history = history
self.mode = mode
needsDisplay = true
updateFadeTimer()
let next = max(width, MenuBarText.minimumWidth)
guard next != shownWidth else { return }
shownWidth = next
invalidateIntrinsicContentSize()
onWidthChange?(next)
}
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(displayValue, 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 .labelAndValue:
drawText("\(label) \(displayValue)", color: color)
case .symbolAndValue:
let glyph = symbolSlot
if metric == .battery {
drawBattery(color: color,
at: NSPoint(x: 0, y: bounds.midY - BatteryGlyph.size.height / 2))
} else {
MenuBarText.drawSymbol(currentSymbol, color: color,
in: NSRect(x: 0, y: bounds.midY - 6.5,
width: glyph, height: 13))
}
drawText(displayValue, color: color,
in: NSRect(x: glyph + MenuBarText.innerGap, y: 0,
width: bounds.width - glyph - MenuBarText.innerGap,
height: bounds.height))
case .valueAndGraph:
let split = bounds.width * 0.55
drawText(displayValue, 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) {
MenuBarText.draw(text, color: color, in: rect ?? bounds)
}
private func drawSymbol(color: NSColor) {
if metric == .battery {
drawBattery(color: color,
at: NSPoint(x: bounds.midX - BatteryGlyph.size.width / 2,
y: bounds.midY - BatteryGlyph.size.height / 2))
return
}
let side: CGFloat = 15
let width = MenuBarText.symbolWidth(currentSymbol, height: side)
MenuBarText.drawSymbol(currentSymbol, color: color,
in: NSRect(x: bounds.midX - width / 2,
y: bounds.midY - side / 2,
width: width, height: side))
}
/// Der Akku wird selbst gezeichnet mit stufenloser Füllung und dem Blitz
/// mitten drin, so wie macOS es zeigt.
private func drawBattery(color: NSColor, at origin: NSPoint) {
guard let battery = snapshot.battery else {
MenuBarText.drawSymbol(BatterySymbol.unavailable, color: color,
in: NSRect(x: origin.x, y: origin.y,
width: 15, height: 15))
return
}
BatteryGlyph.draw(charge: battery.charge, isCharging: battery.isCharging,
isPluggedIn: battery.isPluggedIn, fadePhase: fadePhase,
color: color,
in: CGRect(origin: origin, size: BatteryGlyph.size))
}
/// Der Zeitgeber fürs Faden an und aus mit dem Ladevorgang.
private func updateFadeTimer() {
let shouldFade = metric == .battery && snapshot.battery?.isCharging == true
if shouldFade, fadeTimer == nil {
let timer = Timer(timeInterval: 1.0 / 30, repeats: true) { [weak self] _ in
MainActor.assumeIsolated {
guard let self else { return }
self.fadePhase += (1.0 / 30) / BatteryGlyph.fadeDuration
self.needsDisplay = true
}
}
RunLoop.main.add(timer, forMode: .common)
fadeTimer = timer
} else if !shouldFade, fadeTimer != nil {
fadeTimer?.invalidate()
fadeTimer = nil
needsDisplay = true
}
}
/// Wie breit das Glyph ist. Der Akku wird selbst gezeichnet und hat eigene
/// Maße; alles andere ist ein SF-Symbol.
private var symbolSlot: CGFloat {
metric == .battery ? BatteryGlyph.size.width
: MenuBarText.symbolWidth(currentSymbol, height: 13)
}
/// Das Symbol zum **Zustand**, nicht zur Metrik.
///
/// Beim Akku ändert es sich mit dem Ladestand und zeigt beim Laden einen
/// Blitz ein Symbol, das bei 5 % genauso aussieht wie bei 95 %, ist
/// Dekoration. Die übrigen Messgrößen haben nichts, was sich sinnvoll im
/// Glyph abbilden ließe.
private var currentSymbol: String {
guard metric == .battery else { return metric.symbolName }
guard let battery = snapshot.battery else { return BatterySymbol.unavailable }
return BatterySymbol.name(charge: battery.charge)
}
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 {
}
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
}
}
}