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 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() } }