Frisch gestartet hat der Verlauf fünf Messwerte statt sechzig. Gerechnet wurde aber „Breite geteilt durch Anzahl" — also war jeder Balken ein Fünftel breit, und mit jedem weiteren wurden sie schmaler. Das Diagramm sah nach einem Ausschlag aus und bedeutete nichts. Jetzt kommt die Schrittweite aus der vollen Länge. Fehlende Werte bleiben als freie Fläche links stehen; der Verlauf wächst nach links weg, wie bei jedem Zeitdiagramm. Das steckte an **drei** Stellen, alle mit derselben Rechnung: dem Balkendiagramm im Netzwerk-Popover, der Sparkline in den Kacheln und der Linie in den Menüleisten-Modulen. Die gemeinsame Rechnung liegt jetzt in `GraphLayout` und ist geprüft, statt dreimal danebenzugehen. Nebenbei: die Fläche unter der Sparkline begann am linken Rand statt am ersten Messwert — ein Keil über die ganze Breite, wo noch nichts gemessen war. CPU und GPU bekommen eigene Symbole aus chip.svg und graphic-card.svg. SF Symbols hat für beides nur denselben Chip, und „cpu" neben „cpu.fill" ist kein Unterschied, den man in der Menüleiste erkennt. Die Beschriftung unter den Ringen klebte am Rand und sah aus, als gehörte sie noch hinein — sechs Punkte Abstand statt zwei. Und die Popover für CPU und GPU tragen jetzt einen Titel: bei drei gleich aussehenden Ringen sieht man sonst nicht, vor welchem man steht. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
207 lines
8.2 KiB
Swift
207 lines
8.2 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.
|
|
/// Feste Breite, bemessen am breitesten Wert, der vorkommen kann.
|
|
///
|
|
/// Nicht am aktuellen: aus „14 KB/s" wird „431 KB/s", und mit jeder
|
|
/// zusätzlichen Stelle wanderte die halbe Menüleiste. „999 MB/s" kostet
|
|
/// gegenüber einem dreistelligen KB-Wert nur wenige Punkte — das ist der
|
|
/// Preis dafür, dass nie etwas springt.
|
|
private static var ratesWidth: CGFloat {
|
|
let font = NSFont.monospacedDigitSystemFont(ofSize: 9, weight: .regular)
|
|
let widest = NSAttributedString(string: "↓ 999 MB/s", attributes: [.font: font])
|
|
return ceil(widest.size().width) + 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
|
|
case .valueAndGraph: Self.ratesWidth + 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 + symbolSlot + MenuBarText.innerGap
|
|
}
|
|
}
|
|
|
|
private var symbolSlot: CGFloat {
|
|
MenuBarText.symbolWidth(snapshot.interfaceKind.symbolName, height: 13)
|
|
}
|
|
|
|
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 = max(neededWidth(), MenuBarText.minimumWidth)
|
|
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)
|
|
// Mittig statt links: bleibt doch einmal Platz übrig — etwa weil die
|
|
// zweite Zeile schmaler ist —, verteilt er sich auf beide Seiten statt
|
|
// als Lücke rechts zu stehen.
|
|
down.draw(at: NSPoint(x: rect.midX - down.size().width / 2, y: rect.midY - 0.5))
|
|
up.draw(at: NSPoint(x: rect.midX - up.size().width / 2, y: rect.midY - 10.5))
|
|
}
|
|
|
|
private func drawGraph(color: NSColor, in rect: NSRect) {
|
|
guard let context = NSGraphicsContext.current?.cgContext else { return }
|
|
// Die Schrittweite kommt aus der vollen Länge des Verlaufs, nicht aus
|
|
// der Anzahl vorhandener Werte: frisch gestartet sind es zwei statt
|
|
// sechzig, und daraus gerechnet spannte sich der Weg über die ganze
|
|
// Breite.
|
|
let xs = GraphLayout.positions(count: history.count,
|
|
capacity: NetworkModel.historyLength, in: rect)
|
|
guard !xs.isEmpty else { return }
|
|
context.setStrokeColor(color.withAlphaComponent(0.85).cgColor)
|
|
context.setLineWidth(1)
|
|
for (index, x) in xs.enumerated() {
|
|
let value = history[history.count - xs.count + index]
|
|
let point = CGPoint(x: x,
|
|
y: rect.minY + rect.height * CGFloat(min(max(value, 0), 1)))
|
|
index == 0 ? context.move(to: point) : context.addLine(to: point)
|
|
}
|
|
context.strokePath()
|
|
}
|
|
}
|
|
|