Files
onyx/Packages/OnyxKit/Sources/MetricsProvider/MetricMenuBarModule.swift
Scarriffle 93fe95827f Panel wächst in die Breite, Menüleiste wird schmaler
Das Raster ist weg. Widgets stehen nebeneinander, alle gleich groß, in
der Reihenfolge aus den Einstellungen. Das Panel sitzt am oberen Rand —
nach unten zu wachsen bedeckt den Bildschirm, nach rechts und links legt
es sich in den ohnehin leeren Streifen neben der Notch. Eine zweite Reihe
entsteht erst, wenn der Bildschirm keine weitere Kachel mehr hergibt.

Die Größenauswahl fällt damit weg, und das ist kein Verlust: ein 2×1
neben einem 2×2 lässt oben rechts eine Lücke, die niemand füllen kann und
die aussieht wie ein Fehler. Genau daher kam auch die Überlappung. Ein
Test prüft jetzt für ein bis zwölf Kacheln, dass sich keine zwei
schneiden und alle vollständig im Panel liegen — außerhalb heißt oben:
hinter der Notch. Kacheln werden zusätzlich beschnitten, damit
überquellender Inhalt nicht über den Nachbarn zeichnet.

Der Kalender wählt jetzt zwischen Monatsraster und Terminliste. Das war
vorher an die Kachelgröße gekoppelt und ist in Wahrheit eine Frage
dessen, was man sehen will.

In der Menüleiste wird die Breite gemessen statt geschätzt. Vorher stand
je Darstellungsart eine feste Zahl im Code, großzügig gewählt — mit dem
Ergebnis, dass neben jedem Wert Platz für ein weiteres Symbol blieb, in
dem nichts stand. Gemessen wird die breiteste vorkommende Zeichenfolge,
nicht die gerade angezeigte, sonst springt die Leiste bei jedem Messwert.
Die Balkendarstellung bekommt ihre Breite aus der Kernzahl, die erst nach
der ersten Messung feststeht.

Popover schließen jetzt beim Klick daneben. `behavior = .transient`
genügt nicht: es greift, solange die eigene App aktiv ist, aber Onyx läuft
als .accessory und wird durch einen Klick auf ein Statuselement nicht
aktiviert. Ein Klick in ein fremdes Fenster erreichte das Popover gar
nicht. Ein globaler Beobachter sieht solche Klicks, ein lokaler die in der
eigenen App, Escape schließt ebenfalls — und es ist immer nur eines offen.

Mixer und Lüfter haben eigene Menüleistenelemente. Beides regelt man
mitten in etwas anderem; der Weg über ein Einstellungsfenster war zu weit.
Die Lüfter sind damit auch als Panel-Kachel echt statt Platzhalter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 11:07:20 +02:00

242 lines
9.1 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)"
}
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, WidthReporting {
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: 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
}
}
/// 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 .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) {
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 {
}
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
}
}
}