Files
onyx/Packages/OnyxKit/Sources/NetworkProvider/NetworkMenuBarModule.swift
Scarriffle 70a665f532 Menüleiste: Breite mitwachsend, Symbole unverzerrt, Sensor wählbar
Die Netzwerkkachel war auf „↓ 999,9 MB/s" dimensioniert und stand damit
neben „↓ 13 KB/s" zur Hälfte leer. Auf den schlimmsten Fall auszulegen ist
für einen Wert, der sich um den Faktor tausend ändert, die falsche Antwort.
Gemessen wird jetzt, was dasteht — mit unsymmetrischer Hysterese: sofort
wachsen, denn abgeschnittene Messwerte sind falsche Messwerte, und erst nach
zehn ruhigeren Messungen schrumpfen, damit ein Lastausschlag die Leiste nicht
zum Pumpen bringt.

Das Akkusymbol war zerquetscht. Ein Batteriesymbol ist doppelt so breit wie
hoch und wurde in ein Quadrat gezeichnet. Symbole behalten jetzt ihr
Seitenverhältnis, und die Breite des Elements richtet sich nach dem, was das
Symbol tatsächlich braucht — sonst hätte ein breites Glyph dieselbe Spalte
wie ein schmales.

Bei den Sensoren stand stumm das Maximum über alle Punkte: eine Zahl, von
der niemand weiß, woher sie kommt. Jetzt wählbar, und der gewählte Sensor
gibt sein eigenes Kürzel — „Temp 46°" sagt weniger als „Heatpipe 46°".
Voreinstellung bleibt der wärmste Punkt, weil das die häufigste Frage ist.

Und die Lüfterkachel zeigte „2500" ohne Einheit.

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

196 lines
7.6 KiB
Swift

import AppKit
import SwiftUI
import OnyxMenuBar
import MetricsProvider
/// Das Netzwerk in der Menüleiste.
///
/// Anders als die Hardwaregrößen hat es **zwei** Werte, die beide interessieren.
/// Sie untereinander zu setzen ist der einzige Weg, beide in der Höhe einer
/// Menüleiste unterzubringen nebeneinander bräuchte es die doppelte Breite,
/// und die hat man dort nicht.
@MainActor
public final class NetworkMenuBarModule: MenuBarModule {
public let id = "network"
public var displayName: String { String(localized: "network.name", bundle: .module) }
private let model: NetworkModel
private var token: UUID?
private var view: NetworkStatusView?
public init(model: NetworkModel) { self.model = model }
public var shortLabel: String { String(localized: "menubar.short.network", bundle: .module) }
public func makeStatusView(presentation: MenuBarPresentation) -> NSView {
let view = NetworkStatusView(presentation: presentation)
view.update(model.snapshot, history: model.series(download: true))
self.view = view
return view
}
public func makePopoverView() -> AnyView {
AnyView(NetworkPopoverContent(model: model))
}
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
view = nil
}
private func startObserving() {
withObservationTracking {
_ = model.snapshot
} onChange: {
Task { @MainActor [weak self] in
guard let self, token != nil else { return }
view?.update(model.snapshot, history: model.series(download: true))
startObserving()
}
}
}
}
final class NetworkStatusView: NSView, WidthReporting {
private let presentation: MenuBarPresentation
private var snapshot = NetworkSnapshot()
private var history: [Double] = []
init(presentation: MenuBarPresentation) {
self.presentation = presentation
super.init(frame: NSRect(x: 0, y: 0, width: MenuBarText.minimumWidth, height: 22))
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
/// Breiter als die Hardwaremodule: 2,4 MB/s" braucht schlicht mehr Platz
/// als 46 %".
/// Der Platz für die **aktuellen** Raten.
///
/// Auf 999,9 MB/s" zu dimensionieren hieße, neben 13 KB/s" dauerhaft
/// die halbe Breite leer zu lassen und genau so sah es aus. Gemessen wird
/// deshalb, was gerade dasteht; `AdaptiveWidth` sorgt dafür, dass die
/// Leiste dabei nicht zappelt.
private static func ratesWidth(_ snapshot: NetworkSnapshot) -> CGFloat {
let font = NSFont.monospacedDigitSystemFont(ofSize: 9, weight: .regular)
let lines = ["" + Throughput.formatted(snapshot.downloadRate),
"" + Throughput.formatted(snapshot.uploadRate)]
let widest = lines
.map { NSAttributedString(string: $0, attributes: [.font: font]).size().width }
.max() ?? 0
return ceil(widest) + 2 * MenuBarText.sidePadding
}
private func neededWidth() -> CGFloat {
switch presentation {
case .symbol:
max(MenuBarText.minimumWidth,
MenuBarText.symbolWidth(snapshot.interfaceKind.symbolName, height: 15) + 4)
case .graph, .bars: 28
case .value: Self.ratesWidth(snapshot)
case .valueAndGraph: Self.ratesWidth(snapshot) + 26
// Die Raten tragen mit und ihre Beschriftung schon in sich; ein
// Kürzel davor wäre doppelt gemoppelt, ein Symbol dagegen nützlich
// es zeigt WLAN, Ethernet oder VPN.
case .labelAndValue, .symbolAndValue:
Self.ratesWidth(snapshot) + symbolSlot + MenuBarText.innerGap
}
}
private var symbolSlot: CGFloat {
MenuBarText.symbolWidth(snapshot.interfaceKind.symbolName, height: 13)
}
private var adaptive = AdaptiveWidth(minimum: MenuBarText.minimumWidth)
private var width: CGFloat = MenuBarText.minimumWidth
var onWidthChange: ((CGFloat) -> Void)?
override var intrinsicContentSize: NSSize {
NSSize(width: width, height: 22)
}
func update(_ snapshot: NetworkSnapshot, history: [Double]) {
self.snapshot = snapshot
self.history = history
needsDisplay = true
let next = adaptive.update(needed: neededWidth())
guard next != width else { return }
width = next
invalidateIntrinsicContentSize()
onWidthChange?(next)
}
override func draw(_ dirtyRect: NSRect) {
let color = NSColor.labelColor
switch presentation {
case .symbol:
MenuBarText.drawSymbol(snapshot.interfaceKind.symbolName, color: color,
in: NSRect(x: bounds.midX - 7.5, y: bounds.midY - 7.5,
width: 15, height: 15))
case .graph, .bars:
drawGraph(color: color, in: bounds.insetBy(dx: 2, dy: 5))
case .labelAndValue, .symbolAndValue:
let glyph = symbolSlot
MenuBarText.drawSymbol(snapshot.interfaceKind.symbolName, color: color,
in: NSRect(x: 0, y: bounds.midY - 6.5,
width: glyph, height: 13))
drawRates(color: color,
in: NSRect(x: glyph + MenuBarText.innerGap, y: 0,
width: bounds.width - glyph - MenuBarText.innerGap,
height: bounds.height))
case .value, .valueAndGraph:
drawRates(color: color,
in: presentation == .value ? bounds
: NSRect(x: 0, y: 0, width: bounds.width - 26, height: bounds.height))
if presentation == .valueAndGraph {
drawGraph(color: color,
in: NSRect(x: bounds.width - 24, y: 5, width: 22, height: 12))
}
}
}
/// Zwei Zeilen à 9 pt die einzige Art, Hoch und Runter in 22 pt Höhe
/// unterzubringen.
private func drawRates(color: NSColor, in rect: NSRect) {
let attributes: [NSAttributedString.Key: Any] = [
.font: NSFont.monospacedDigitSystemFont(ofSize: 9, weight: .regular),
.foregroundColor: color,
]
let down = NSAttributedString(string: "" + Throughput.formatted(snapshot.downloadRate),
attributes: attributes)
let up = NSAttributedString(string: "" + Throughput.formatted(snapshot.uploadRate),
attributes: attributes)
down.draw(at: NSPoint(x: rect.minX + 2, y: rect.midY - 0.5))
up.draw(at: NSPoint(x: rect.minX + 2, y: rect.midY - 10.5))
}
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)
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()
}
}