Die Breite ist fest für „100 %" reserviert, damit nichts springt, wenn der Wert eine Stelle mehr bekommt. Das Leerzeichen kostet in dieser Reservierung drei Punkte je Element — in der Menüleiste ist Platz das knappste Gut. Nur dort. Im Panel und in den Popovern bleibt das Leerzeichen stehen; da ist Platz, und typografisch gehört es hin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
409 lines
17 KiB
Swift
409 lines
17 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)
|
|
}
|
|
// In der Menüleiste ohne Leerzeichen vor dem Prozentzeichen — siehe
|
|
// `MetricFormat.percentCompact`.
|
|
let value = MetricSummary.value(metric, snapshot, mode: mode)
|
|
return value.replacingOccurrences(of: " %", with: "%")
|
|
}
|
|
|
|
|
|
/// 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 if let image = Self.customGlyphs[metric] {
|
|
MenuBarText.drawTemplate(image, color: color,
|
|
in: NSRect(x: 0, y: bounds.midY - 6.5,
|
|
width: glyph, height: 13))
|
|
} 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)
|
|
}
|
|
|
|
/// Eigene Vorlagen für CPU und GPU — SF Symbols hat für beides nur den
|
|
/// gleichen Chip, und „cpu" neben „cpu.fill" ist kein Unterschied, den man
|
|
/// in der Menüleiste erkennt.
|
|
private static let customGlyphs: [MetricKind: NSImage] = {
|
|
var glyphs: [MetricKind: NSImage] = [:]
|
|
for (metric, name) in [(MetricKind.cpu, "chip"), (.gpu, "graphic-card")] {
|
|
guard let url = Bundle.main.url(forResource: name, withExtension: "svg"),
|
|
let image = NSImage(contentsOf: url) else { continue }
|
|
glyphs[metric] = image
|
|
}
|
|
return glyphs
|
|
}()
|
|
|
|
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 rect = NSRect(x: bounds.midX - side / 2, y: bounds.midY - side / 2,
|
|
width: side, height: side)
|
|
if let glyph = Self.customGlyphs[metric] {
|
|
MenuBarText.drawTemplate(glyph, color: color, in: rect)
|
|
return
|
|
}
|
|
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 {
|
|
if metric == .battery { return BatteryGlyph.size.width }
|
|
// Eigene Vorlagen sind quadratisch; ihre Breite folgt der Höhe.
|
|
if Self.customGlyphs[metric] != nil { return 13 }
|
|
return 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 let context = NSGraphicsContext.current?.cgContext else { return }
|
|
// Die Schrittweite kommt aus der vollen Länge des Verlaufs, nicht aus
|
|
// der Anzahl vorhandener Werte: frisch gestartet sind es zwei statt
|
|
// sechzig, und daraus gerechnet spannte sich der Weg über die ganze
|
|
// Breite.
|
|
let xs = GraphLayout.positions(count: history.count,
|
|
capacity: MetricsModel.historyLength, in: rect)
|
|
guard !xs.isEmpty else { return }
|
|
|
|
context.setStrokeColor(color.withAlphaComponent(0.85).cgColor)
|
|
context.setLineWidth(1)
|
|
context.setLineJoin(.round)
|
|
for (index, x) in xs.enumerated() {
|
|
let value = history[history.count - xs.count + index]
|
|
let point = CGPoint(x: x,
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|