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)" } public func makeStatusView(presentation: MenuBarPresentation) -> NSView { let view = MetricStatusView(metric: metric, presentation: presentation) 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 { private let metric: MetricKind private let presentation: MenuBarPresentation private var snapshot = MetricsSnapshot() private var history: [Double] = [] private var mode: MetricDisplayMode = .usage init(metric: MetricKind, presentation: MenuBarPresentation) { self.metric = metric self.presentation = presentation super.init(frame: NSRect(x: 0, y: 0, width: presentation.width, height: 22)) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError() } override var intrinsicContentSize: NSSize { NSSize(width: presentation.width, height: 22) } func update(snapshot: MetricsSnapshot, history: [Double], mode: MetricDisplayMode) { self.snapshot = snapshot self.history = history self.mode = mode needsDisplay = true } 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 .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) { let attributes: [NSAttributedString.Key: Any] = [ // Feste Ziffernbreite: ohne sie wackelt der Text bei jeder Messung. .font: NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .regular), .foregroundColor: color, ] let string = NSAttributedString(string: text, attributes: attributes) let area = rect ?? bounds let size = string.size() string.draw(at: NSPoint(x: area.midX - size.width / 2, y: area.midY - size.height / 2)) } private func drawSymbol(color: NSColor) { guard let image = NSImage(systemSymbolName: metric.symbolName, accessibilityDescription: nil) else { return } image.isTemplate = true let side: CGFloat = 15 let rect = NSRect(x: bounds.midX - side / 2, y: bounds.midY - side / 2, width: side, height: side) color.set() image.draw(in: rect) } 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 { /// Feste Breite je Darstellungsart. /// /// Ohne sie ändert das Element bei jedem Messwert seine Größe und schiebt /// alle Symbole rechts davon hin und her — das fällt in der Menüleiste /// sofort unangenehm auf. var width: CGFloat { switch self { case .value: 42 case .symbol: 22 case .graph: 34 case .bars: 30 case .valueAndGraph: 74 } } } 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 } } }