Die Netzwerkkachel war auf „↓ 999,9 MB/s" dimensioniert und stand damit neben „↓ 13 KB/s" zur Hälfte leer. Auf den schlimmsten Fall auszulegen ist für einen Wert, der sich um den Faktor tausend ändert, die falsche Antwort. Gemessen wird jetzt, was dasteht — mit unsymmetrischer Hysterese: sofort wachsen, denn abgeschnittene Messwerte sind falsche Messwerte, und erst nach zehn ruhigeren Messungen schrumpfen, damit ein Lastausschlag die Leiste nicht zum Pumpen bringt. Das Akkusymbol war zerquetscht. Ein Batteriesymbol ist doppelt so breit wie hoch und wurde in ein Quadrat gezeichnet. Symbole behalten jetzt ihr Seitenverhältnis, und die Breite des Elements richtet sich nach dem, was das Symbol tatsächlich braucht — sonst hätte ein breites Glyph dieselbe Spalte wie ein schmales. Bei den Sensoren stand stumm das Maximum über alle Punkte: eine Zahl, von der niemand weiß, woher sie kommt. Jetzt wählbar, und der gewählte Sensor gibt sein eigenes Kürzel — „Temp 46°" sagt weniger als „Heatpipe 46°". Voreinstellung bleibt der wärmste Punkt, weil das die häufigste Frage ist. Und die Lüfterkachel zeigte „2500" ohne Einheit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
290 lines
11 KiB
Swift
290 lines
11 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(metric.symbolName, 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(metric.symbolName, 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(metric.symbolName, height: 13)
|
|
MenuBarText.drawSymbol(metric.symbolName, 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) {
|
|
let side: CGFloat = 15
|
|
MenuBarText.drawSymbol(metric.symbolName, color: color,
|
|
in: NSRect(x: bounds.midX - side / 2,
|
|
y: bounds.midY - side / 2,
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|