Die Mediensteuerung stand nebeneinander: Cover links, alles andere in den verbleibenden gut hundert Punkten. Daher „Bernhard H…" statt eines Titels. Jetzt übereinander, wie es zu einer hochkanten Kachel passt. Das Cover ragte zusätzlich über die Kachel hinaus, weil `clipShape` vor dem Rahmen stand — beschnitten wurde damit auf die Eigengröße des Bildes, und die ist bei einem Podcast-Cover ein Vielfaches. Erst Rahmen, dann beschneiden. Menüleistensymbole waren kaum zu sehen. `color.set()` vor `image.draw(in:)` färbt ein Template-Bild nicht: es wird schwarz gezeichnet, auf einer dunklen Menüleiste also unsichtbar. AppKit färbt Template-Bilder nur ein, wenn sie als `image` eines Buttons gesetzt sind — in einer selbst gezeichneten Ansicht muss die Farbe in die Symbolkonfiguration. Betraf alle fünf Zeichenstellen, nicht nur die Lüfter. „fans.auto" stand als roher Schlüssel da: Xcode hatte ihn extrahiert, gefüllt wurde er nie. Beim Nachsehen waren es 35 solche Einträge über alle Kataloge hinweg — die meisten reine Formatschlüssel, die als Durchreicher trotzdem einen Wert brauchen. Der Automatik-Knopf ist jetzt eine gefüllte Pille, wenn sie greift. Blauer Text sieht aus wie ein Link und nicht wie ein Zustand. Und ein Absturz, der noch niemandem passiert ist: meldet der Helfer für einen stehenden Lüfter keine Grenzen, hätte `Slider(in: 0...0)` die App beendet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
160 lines
5.9 KiB
Swift
160 lines
5.9 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 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 %".
|
|
/// Gemessen an der breitesten Zeichenfolge, die vorkommen kann.
|
|
///
|
|
/// „↓ 999,9 MB/s" ist der Extremfall — meistens steht dort „↓ 12 KB/s".
|
|
/// Vorher waren 76 Punkte fest verdrahtet, und daneben blieb dauerhaft
|
|
/// Platz für ein weiteres Symbol frei, in dem nichts stand.
|
|
static func width(_ presentation: MenuBarPresentation) -> CGFloat {
|
|
switch presentation {
|
|
case .symbol: MenuBarText.minimumWidth
|
|
case .graph, .bars: 28
|
|
case .value: ratesWidth
|
|
case .valueAndGraph: ratesWidth + 26
|
|
}
|
|
}
|
|
|
|
/// Die Raten stehen zweizeilig in 9 pt — dafür misst `MenuBarText` mit
|
|
/// seiner 11-pt-Schrift zu breit, also hier eigens gemessen.
|
|
private static var ratesWidth: CGFloat {
|
|
let font = NSFont.monospacedDigitSystemFont(ofSize: 9, weight: .regular)
|
|
let widest = NSAttributedString(string: "↓ 999,9 MB/s", attributes: [.font: font])
|
|
return ceil(widest.size().width) + 2 * MenuBarText.sidePadding
|
|
}
|
|
|
|
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:
|
|
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 .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()
|
|
}
|
|
}
|
|
|