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