Nachgemessen statt geraten. Das Netzwerkelement war 69 Punkte breit, meine Rechnung ergab 47 — und die anderen Module lagen konstant 16 darüber. Sechs Punkte hatte allein das Netzwerk zusätzlich. Zwei Ursachen. Die Hysterese hielt die Breite auf dem Höchstwert der letzten zwanzig Sekunden; bei einem Wert, der im Sekundentakt schwankt, ist der fast immer größer als der aktuelle. Und der Text wurde linksbündig gezeichnet, die Differenz stand also komplett rechts als Lücke. Jetzt wird die Breite für die **Gestalt** reserviert: alle Ziffern auf 9 gesetzt, das ergibt die breiteste Zeichenfolge derselben Form. Von 14 auf 37 KB/s bewegt sich damit nichts, und breiter wird es nur, wenn eine Stelle dazukommt. Gezeichnet wird mittig, damit ein Rest sich auf beide Seiten verteilt statt rechts zu stehen. Ergebnis: 69 → 57 Punkte. Die verbleibenden rund 16 Punkte je Element legt macOS selbst um jedes Statuselement; die Lücken zwischen benachbarten Elementen sind gemessen 0. Neue Menüleistenmodule starten jetzt mit „Symbol + Wert" statt „Wert" — bestehende Einstellungen bleiben, wie sie sind. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
203 lines
8.0 KiB
Swift
203 lines
8.0 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)
|
|
// Die **Gestalt** reservieren, nicht den Höchstwert der letzten
|
|
// Messungen: der Durchsatz schwankt im Sekundentakt, und der Höchstwert
|
|
// ist fast immer größer als das, was gerade dasteht. Die Differenz
|
|
// stand als Lücke daneben.
|
|
let lines = ["↓ " + Throughput.formatted(snapshot.downloadRate),
|
|
"↑ " + Throughput.formatted(snapshot.uploadRate)]
|
|
.map(MenuBarText.widestOfSameShape)
|
|
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 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 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()
|
|
}
|
|
}
|
|
|