Files
onyx/Packages/OnyxKit/Sources/MetricsProvider/MetricMenuBarModule.swift
Scarriffle 4b5e86b3f4 Akkusymbol folgt dem Ladestand
Ein Symbol, das bei 5 % genauso aussieht wie bei 95 %, ist Dekoration und
keine Anzeige. Wer in der Menüleiste nur das Glyph stehen hat, will es ohne
die Zahl daneben lesen können.

Fünf Füllstufen, gerundet zur nächsten: 60 % soll nicht halbleer aussehen
und 40 % nicht dreiviertel voll.

Beim Bauen fiel auf, dass die naheliegende Lösung nicht geht. Die erste
Fassung hängte beim Laden ein „.bolt" an den Füllstand — von diesen
kombinierten Symbolen existiert aber allein `battery.100percent.bolt`, und
`battery.slash` gibt es auch nicht. Die übrigen wären leere Felder in der
Menüleiste gewesen. Nachgemessen statt angenommen, und ein Test prüft jetzt
für jeden erzeugten Namen, dass es ihn wirklich gibt.

Den Füllstand beim Laden gegen einen Blitz einzutauschen wäre die falsche
Wahl gewesen: dass geladen wird, sieht man am Netzstecker; wie weit es ist,
sieht man nirgends sonst. Also bleibt der Füllstand, und der Blitz kommt als
kleines Zeichen darüber.

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

314 lines
12 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() }
override var intrinsicContentSize: NSSize {
NSSize(width: width, height: 22)
}
/// Gemessen statt geschätzt und zwar an der **breitesten Zeichenfolge,
/// die vorkommen kann**, nicht an der gerade angezeigten. Sonst schiebt
/// jedes Prozent alle Symbole rechts davon hin und her.
private var width: CGFloat {
switch presentation {
case .symbol: max(MenuBarText.minimumWidth,
MenuBarText.symbolWidth(currentSymbol, height: 15) + 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)
+ MenuBarText.symbolWidth(currentSymbol, height: 13)
+ 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)
}
/// Die breiteste Zeichenfolge dieser Metrik.
private var reference: String {
switch (metric, mode) {
case (.temperature, _), (_, .temperature): "100 °C"
default: "100 %"
}
}
/// 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) {
let before = width
self.snapshot = snapshot
self.history = history
self.mode = mode
needsDisplay = true
// Die Kernzahl steht erst nach der ersten Messung fest, und der Modus
// wechselt zwischen 100 %" und 100 °C".
if width != before {
invalidateIntrinsicContentSize()
onWidthChange?(width)
}
}
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 = MenuBarText.symbolWidth(currentSymbol, height: 13)
let symbolRect = NSRect(x: 0, y: bounds.midY - 6.5, width: glyph, height: 13)
MenuBarText.drawSymbol(currentSymbol, color: color, in: symbolRect)
drawChargingBolt(color: color, over: symbolRect)
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) {
let side: CGFloat = 15
let width = MenuBarText.symbolWidth(currentSymbol, height: side)
let rect = NSRect(x: bounds.midX - width / 2, y: bounds.midY - side / 2,
width: width, height: side)
MenuBarText.drawSymbol(currentSymbol, color: color, in: rect)
drawChargingBolt(color: color, over: rect)
}
/// 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)
}
/// Beim Laden ein kleiner Blitz über dem Füllstand. Zwei Zeichnungen statt
/// eines Symbols, weil es die kombinierten Glyphen nur bei 100 % gibt.
private func drawChargingBolt(color: NSColor, over rect: NSRect) {
guard metric == .battery, snapshot.battery?.isCharging == true else { return }
let side: CGFloat = 8
MenuBarText.drawSymbol(BatterySymbol.chargingBolt, color: color,
in: NSRect(x: rect.maxX - side * 0.75,
y: rect.maxY - side * 0.7,
width: side, height: side))
}
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
}
}
}