Das Grün, das überall auftauchte, war die System-Akzentfarbe: `.tint` und `.accentColor` werden ohne eigene Angabe dorthin durchgereicht, und das betraf alle Popovers und das ganze Einstellungsfenster. Die Notch-Widgets dagegen benutzten Onyx' eigenes Blau. Zwei Farben, ungewollt. Jetzt eine, und die ist einstellbar. Ein neuer Reiter „Farben": Akzentfarbe mit sechs Vorschlägen und einem Knopf, der die Systemfarbe übernimmt, dazu das Paar für Herunter- und Hochladen samt Tauschknopf. Die Signalfarben bleiben fest und stehen nur zur Ansicht dort. Gelb heißt Warnung, Rot heißt kritisch, Grün heißt in Ordnung — bei Temperatur, Akku, Lüfter und Fehlern. Wer sie umstellen kann, kann ihre Bedeutung zerstören; ein rotes „alles gut" liest niemand richtig. Sichtbar sind sie trotzdem, sonst sucht man die Warnfarbe im Reiter und findet sie nirgends. Umgesetzt über die Tokens statt über vierzig Fundstellen: `Onyx.Color.accent` ist keine Konstante mehr, sondern liest bei jedem Zugriff aus `OnyxTheme`. Damit merkt SwiftUI die Abhängigkeit und zeichnet neu, sobald die Farbe sich ändert. Dazu ein `.tint` an den vier Wurzeln — Panel, Popovers, Einstellungen, Einrichtung —, damit auch Haken, Regler und Auswahlfelder mitziehen. Nebenbei die Beschriftung: „Je Programm" heißt jetzt „Programme". Großgesetzt las sich das als englisches „J-E". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
204 lines
8.6 KiB
Swift
204 lines
8.6 KiB
Swift
import SwiftUI
|
|
import MetricsProvider
|
|
import OnyxDesign
|
|
import OnyxMenuBar
|
|
|
|
@MainActor
|
|
struct NetworkPopoverContent: View {
|
|
let model: NetworkModel
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack(alignment: .top) {
|
|
rate(model.snapshot.downloadRate, "network.download", Onyx.Color.download)
|
|
Spacer()
|
|
rate(model.snapshot.uploadRate, "network.upload", Onyx.Color.upload)
|
|
}
|
|
|
|
TrafficChart(history: model.history)
|
|
.frame(height: 56)
|
|
|
|
HStack {
|
|
peak(model.history.map(\.downloadRate).max() ?? 0, "network.peak.download")
|
|
Spacer()
|
|
peak(model.history.map(\.uploadRate).max() ?? 0, "network.peak.upload")
|
|
}
|
|
|
|
Divider()
|
|
|
|
Label(connectionName, systemImage: model.snapshot.interfaceKind.symbolName)
|
|
.font(.callout)
|
|
|
|
if model.snapshot.interfaceKind == .vpn {
|
|
// Ein VPN verändert, was die Zahlen darüber bedeuten — der
|
|
// Verkehr läuft dann durch den Tunnel, nicht direkt.
|
|
// Über Text statt Label(_:systemImage:): letzteres kennt kein
|
|
// Bundle und zeigt sonst den rohen Schlüssel an.
|
|
Label {
|
|
Text("network.vpn.active", bundle: .module)
|
|
} icon: {
|
|
Image(systemName: "lock.shield")
|
|
}
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
section("network.addresses")
|
|
field("network.field.interface", model.snapshot.interfaceName)
|
|
field("network.field.ipv4", model.snapshot.ipv4)
|
|
field("network.field.ipv6", model.snapshot.ipv6)
|
|
field("network.field.router", model.snapshot.router)
|
|
|
|
section("network.transferred")
|
|
field("network.total.received", bytes(model.snapshot.totalReceived))
|
|
field("network.total.sent", bytes(model.snapshot.totalSent))
|
|
|
|
section("network.public")
|
|
if model.showsPublicAddress {
|
|
HStack {
|
|
if model.publicAddress.isLoading {
|
|
ProgressView().controlSize(.small)
|
|
} else if let address = model.publicAddress.address {
|
|
Text(address).font(.callout.monospacedDigit()).textSelection(.enabled)
|
|
} else if model.publicAddress.failed {
|
|
Text("network.public.failed", bundle: .module)
|
|
.font(.callout).foregroundStyle(.secondary)
|
|
}
|
|
Spacer()
|
|
Button { model.publicAddress.refresh(force: true) } label: {
|
|
Image(systemName: "arrow.clockwise")
|
|
}
|
|
.buttonStyle(.borderless)
|
|
}
|
|
// Wer die Adresse erfährt, gehört sichtbar dazu.
|
|
Text(verbatim: PublicAddressLookup.serviceName)
|
|
.font(.caption2).foregroundStyle(.tertiary)
|
|
} else {
|
|
Toggle(isOn: Binding(get: { model.showsPublicAddress },
|
|
set: { model.showsPublicAddress = $0 })) {
|
|
Text("network.public.enable", bundle: .module).font(.callout)
|
|
}
|
|
.toggleStyle(.switch)
|
|
.controlSize(.small)
|
|
Text("network.public.hint", bundle: .module)
|
|
.font(.caption2).foregroundStyle(.tertiary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
|
|
if !model.processes.isEmpty {
|
|
section("network.processes")
|
|
ForEach(model.processes) { process in
|
|
HStack {
|
|
Text(process.name).font(.callout).lineLimit(1)
|
|
Spacer(minLength: 8)
|
|
Text(Throughput.formatted(process.downloadRate))
|
|
.font(.caption.monospacedDigit())
|
|
.foregroundStyle(Onyx.Color.download)
|
|
Text(Throughput.formatted(process.uploadRate))
|
|
.font(.caption.monospacedDigit())
|
|
.foregroundStyle(Onyx.Color.upload)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(14)
|
|
.frame(width: 300)
|
|
.onAppear { model.addDetailConsumer() }
|
|
.onDisappear { model.removeDetailConsumer() }
|
|
}
|
|
|
|
private var connectionName: String {
|
|
if let ssid = model.snapshot.ssid { return ssid }
|
|
if model.snapshot.isWiFiConnected {
|
|
return String(localized: "network.wifi.connected", bundle: .module)
|
|
}
|
|
return model.snapshot.interfaceName
|
|
?? String(localized: "network.offline", bundle: .module)
|
|
}
|
|
|
|
private func rate(_ value: Double, _ key: LocalizedStringKey, _ tint: Color) -> some View {
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
Text(Throughput.formatted(value))
|
|
.font(.system(size: 18, weight: .medium).monospacedDigit())
|
|
HStack(spacing: 4) {
|
|
Circle().fill(tint).frame(width: 6, height: 6)
|
|
Text(key, bundle: .module).font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func peak(_ value: Double, _ key: LocalizedStringKey) -> some View {
|
|
HStack(spacing: 4) {
|
|
Text(key, bundle: .module).font(.caption2).foregroundStyle(.secondary)
|
|
Text(Throughput.formatted(value)).font(.caption2.monospacedDigit())
|
|
}
|
|
}
|
|
|
|
private func section(_ key: LocalizedStringKey) -> some View {
|
|
Text(key, bundle: .module)
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(Onyx.Color.accent)
|
|
.textCase(.uppercase)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func field(_ key: LocalizedStringKey, _ value: String?) -> some View {
|
|
if let value {
|
|
HStack {
|
|
Text(key, bundle: .module).font(.callout).foregroundStyle(.secondary)
|
|
Spacer(minLength: 12)
|
|
Text(value).font(.callout.monospacedDigit()).textSelection(.enabled)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func bytes(_ value: UInt64) -> String {
|
|
ByteCountFormatter.string(fromByteCount: Int64(value), countStyle: .binary)
|
|
}
|
|
}
|
|
|
|
/// Verlauf in beide Richtungen: Download nach unten, Upload nach oben.
|
|
///
|
|
/// Zwei getrennte Kurven übereinander wären platzsparender, aber schwerer zu
|
|
/// lesen — hier sieht man sofort, ob gerade geladen oder gesendet wird, und in
|
|
/// welchem Verhältnis. Beide Richtungen teilen sich denselben Maßstab, sonst
|
|
/// sähe ein Upload von 20 KB/s genauso hoch aus wie ein Download von 20 MB/s.
|
|
private struct TrafficChart: View {
|
|
let history: [NetworkSnapshot]
|
|
|
|
var body: some View {
|
|
GeometryReader { geometry in
|
|
let points = history.suffix(NetworkModel.historyLength)
|
|
let peak = max(points.map { max($0.downloadRate, $0.uploadRate) }.max() ?? 1, 1)
|
|
// Die Balkenbreite kommt aus der **vollen** Länge des Verlaufs.
|
|
// Vorher war sie `Breite / Anzahl`: bei fünf Messwerten also ein
|
|
// Fünftel je Balken, und mit jedem weiteren wurden sie schmaler.
|
|
// Frisch gestartet sah das Diagramm dadurch aus wie ein Ausschlag.
|
|
let width = GraphLayout.barWidth(capacity: NetworkModel.historyLength,
|
|
totalWidth: geometry.size.width, spacing: 1)
|
|
let half = geometry.size.height / 2
|
|
|
|
HStack(alignment: .center, spacing: 1) {
|
|
// Fehlende Messwerte bleiben als freie Fläche links stehen; der
|
|
// Verlauf wächst nach links weg, wie bei jedem Zeitdiagramm.
|
|
Spacer(minLength: 0)
|
|
|
|
ForEach(Array(points.enumerated()), id: \.offset) { _, snapshot in
|
|
VStack(spacing: 1) {
|
|
Rectangle()
|
|
.fill(Onyx.Color.upload)
|
|
.frame(height: max(half * CGFloat(snapshot.uploadRate / peak), 0.5))
|
|
.frame(maxHeight: .infinity, alignment: .bottom)
|
|
Rectangle()
|
|
.fill(Onyx.Color.download)
|
|
.frame(height: max(half * CGFloat(snapshot.downloadRate / peak), 0.5))
|
|
.frame(maxHeight: .infinity, alignment: .top)
|
|
}
|
|
.frame(width: width)
|
|
}
|
|
}
|
|
.frame(width: geometry.size.width, height: geometry.size.height)
|
|
}
|
|
}
|
|
}
|