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 } } }