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) case .temperature: String(localized: "menubar.short.temperature", bundle: .module) } } public func makeStatusView(presentation: MenuBarPresentation) -> NSView { let view = MetricStatusView(metric: metric, presentation: presentation, label: shortLabel) 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: MenuBarText.minimumWidth 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, includesSymbol: true) } } /// 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) } /// 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)? 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(MetricSummary.value(metric, snapshot, mode: mode), 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) \(MetricSummary.value(metric, snapshot, mode: mode))", color: color) case .symbolAndValue: MenuBarText.drawSymbol(metric.symbolName, color: color, in: NSRect(x: 0, y: bounds.midY - 6.5, width: 13, height: 13)) drawText(MetricSummary.value(metric, snapshot, mode: mode), color: color, in: NSRect(x: 15, y: 0, width: bounds.width - 15, height: bounds.height)) case .valueAndGraph: let split = bounds.width * 0.55 drawText(MetricSummary.value(metric, snapshot, mode: mode), 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 } } }