Files
onyx/Packages/OnyxKit/Sources/NetworkProvider/NetworkPopover.swift
T
ScarriffleandClaude Opus 5 edb327cabe Kacheln und Fenster sprechen dieselbe Sprache
Alle Kacheln, Popovers und Fenster auf die Designschicht umgestellt: rund
hundert gesetzte Schriftgrößen und 159 gesetzte Abstände und Radien sind jetzt
Token. Übrig bleibt `spacing: 0` — das ist eine Aussage, keine geratene Zahl.

Die Einstellungen bekommen eine Seitenleiste statt sieben Reitern, deren
deutsche Beschriftungen zusammen 880 Punkte brauchten. Panel-Token stehen nicht
mehr in Fenstern: dort gilt Systemsemantik, damit der Hellmodus nicht schwarz
auf schwarz endet.

Neu ist ein Schaufenster, das alle Bauteile nebeneinander zeigt — ohne das
lässt sich eine Änderung an einem Token nur prüfen, indem man die App bedient,
bis jede Kachel einmal zu sehen war.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 21:05:51 +02:00

197 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: Onyx.Space.lg) {
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)
}
OnyxPopoverTitle(String(localized: "network.addresses", bundle: .module))
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)
OnyxPopoverTitle(String(localized: "network.transferred", bundle: .module))
field("network.total.received", bytes(model.snapshot.totalReceived))
field("network.total.sent", bytes(model.snapshot.totalSent))
OnyxPopoverTitle(String(localized: "network.public", bundle: .module))
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 {
OnyxPopoverTitle(String(localized: "network.processes", bundle: .module))
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(Onyx.Space.xl)
.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: Onyx.Space.xxs) {
Text(Throughput.formatted(value))
.font(Onyx.Font.display.monospacedDigit())
HStack(spacing: Onyx.Space.xs) {
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: Onyx.Space.xs) {
Text(key, bundle: .module).font(.caption2).foregroundStyle(.secondary)
Text(Throughput.formatted(value)).font(.caption2.monospacedDigit())
}
}
@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: Onyx.Space.xxs)
let half = geometry.size.height / 2
HStack(alignment: .center, spacing: Onyx.Space.xxs) {
// 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: Onyx.Space.xxs) {
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)
}
}
}