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 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 { 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: Self.width(presentation), height: 22)) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError() } /// Breiter als die Hardwaremodule: „↓ 2,4 MB/s" braucht schlicht mehr Platz /// als „46 %". static func width(_ presentation: MenuBarPresentation) -> CGFloat { switch presentation { case .symbol: 22 case .graph: 34 case .bars: 30 case .value, .valueAndGraph: 76 } } override var intrinsicContentSize: NSSize { NSSize(width: Self.width(presentation), height: 22) } func update(_ snapshot: NetworkSnapshot, history: [Double]) { self.snapshot = snapshot self.history = history needsDisplay = true } override func draw(_ dirtyRect: NSRect) { let color = NSColor.labelColor switch presentation { case .symbol: guard let image = NSImage(systemSymbolName: snapshot.interfaceKind.symbolName, accessibilityDescription: nil) else { return } image.isTemplate = true color.set() image.draw(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 .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() } }