Der Blitz war ein Loch in der Füllung — über dem gefüllten Teil liest man ihn, über dem leeren ist es Transparenz auf Transparenz. Bei wenig Ladung sitzt er fast ganz im Leeren und verschwindet. Statt zwischen Schwarz und Weiß zu faden geht er den Weg von macOS: geteilt gezeichnet, über der Füllung ausgestanzt, daneben ausgemalt. Damit ist er bei jedem Ladestand lesbar, ohne Bewegung in der Menüleiste. Ein Test prüft, dass sich die beiden Teile an der Kante treffen statt zu überlappen — sonst wäre der Blitz an der Nahtstelle dicker. Die Auslösefläche der Notch lässt sich jetzt einstellen, mit Vorschau. Wie genau jemand die Notch trifft, hängt an Hand und Zeigegerät; feste Werte im Code waren dafür die falsche Antwort. Die Vorschau zeigt einen nachgebildeten Bildschirm mit Notch und eingefärbter Fläche — „24 Punkte unter der Notch" ist keine Vorstellung, die jemand im Kopf hat. Die Menüleistenbreiten folgen jetzt dem, was dasteht. „Temp 53°" stand in einer Box für „Temp 100 °C", und in der Menüleiste ist Platz das knappste Gut. Dieselbe Hysterese wie beim Netzwerk: sofort wachsen, zögernd schrumpfen. Das Mixer-Symbol war ausgeschaltet auf 55 % abgeblendet. Das war meine Idee und ein Fehlgriff: über einem hellen Schreibtischhintergrund ist es damit praktisch weg. Ob der Mixer läuft, erfährt man beim Klick — dafür muss man das Symbol aber erst finden. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
330 lines
13 KiB
Swift
330 lines
13 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() }
|
|
|
|
private var adaptive = AdaptiveWidth(minimum: MenuBarText.minimumWidth)
|
|
private var shownWidth: CGFloat = MenuBarText.minimumWidth
|
|
|
|
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: displayValue)
|
|
case .valueAndGraph: MenuBarText.width(for: displayValue) + 2 + 28
|
|
case .labelAndValue: MenuBarText.width(for: "\(label) \(displayValue)")
|
|
case .symbolAndValue:
|
|
MenuBarText.width(for: displayValue) + 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
|
|
|
|
let next = adaptive.update(needed: width)
|
|
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, color: color,
|
|
in: CGRect(origin: origin, size: BatteryGlyph.size))
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
}
|
|
}
|
|
|