From d1de61ba29bc9344472153278074d0bafd29ab7a Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Fri, 2 Oct 2026 15:08:29 +0100 Subject: [PATCH] Tray UI: start hidden, fixed window, grid layout, traffic + peers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworks the tray window per feedback and fills it in. - Starts hidden in the tray (no window on launch); the window opens from the menu. On macOS the app now uses the Accessory activation policy, so it lives in the menu bar with no Dock icon (via a winit event-loop hook, pinned to eframe's winit). Fixed, non-resizable 360x600 window; the layout targets it. - Tidied layout: top bar (title + refresh), a bottom panel with the join form and the action banner, and a scrolling list of network tiles between them. Labels and values sit on two-column grids so they align; the join name and secret inputs are the same fixed width. - A ⟳ button beside the secret generates a fresh random secret (NetworkSecret::generate), for creating a network. - Each network tile shows id, range, this device's address, peers (connected/known), the active and broadcast switches, a Leave button, and a collapsing "peers (N)" section with a formatted per-peer table (short key, address, handshake age, tx/rx packets, rate). - Traffic: a new sampler turns the agent's cumulative packet counters into a packets-per-second rate per network and per peer, from the deltas between snapshots (the snapshot now carries a generation and timestamp so each refresh is sampled once). The network tile shows the current rate and a small hand-drawn sparkline of recent rates (no extra dependency). Note: rates are packets/s — the report has no byte counters yet; bytes would need an agent change. Builds and is clippy-clean (-D warnings) on macOS; the UI has not been run in this headless session. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 52 ++++++ crates/tsunagi-tray/Cargo.toml | 5 + crates/tsunagi-tray/src/agent.rs | 9 +- crates/tsunagi-tray/src/app.rs | 17 +- crates/tsunagi-tray/src/main.rs | 21 ++- crates/tsunagi-tray/src/stats.rs | 89 +++++++++ crates/tsunagi-tray/src/ui.rs | 298 +++++++++++++++++++++++-------- 7 files changed, 416 insertions(+), 75 deletions(-) create mode 100644 crates/tsunagi-tray/src/stats.rs diff --git a/Cargo.lock b/Cargo.lock index b3912a8..4ae8d6a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -200,6 +200,12 @@ dependencies = [ "rustversion", ] +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + [[package]] name = "arrayvec" version = "0.7.8" @@ -5175,6 +5181,19 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit 0.19.2", + "tiny-skia", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -5575,6 +5594,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + [[package]] name = "strsim" version = "0.11.1" @@ -5849,6 +5874,31 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + [[package]] name = "tinystr" version = "0.8.4" @@ -6274,6 +6324,7 @@ dependencies = [ "tracing-subscriber", "tray-icon", "tsunagi", + "winit", ] [[package]] @@ -7365,6 +7416,7 @@ dependencies = [ "raw-window-handle", "redox_syscall 0.4.1", "rustix 0.38.44", + "sctk-adwaita", "smithay-client-toolkit 0.19.2", "smol_str", "tracing", diff --git a/crates/tsunagi-tray/Cargo.toml b/crates/tsunagi-tray/Cargo.toml index c649151..87db689 100644 --- a/crates/tsunagi-tray/Cargo.toml +++ b/crates/tsunagi-tray/Cargo.toml @@ -25,5 +25,10 @@ tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", tracing.workspace = true tracing-subscriber.workspace = true +# macOS only: set the app's activation policy to Accessory so it lives in the +# menu bar with no Dock icon. Pinned to eframe's winit so the types unify. +[target.'cfg(target_os = "macos")'.dependencies] +winit = "0.30" + [lints] workspace = true diff --git a/crates/tsunagi-tray/src/agent.rs b/crates/tsunagi-tray/src/agent.rs index 35f0142..7558637 100644 --- a/crates/tsunagi-tray/src/agent.rs +++ b/crates/tsunagi-tray/src/agent.rs @@ -9,7 +9,7 @@ use std::path::PathBuf; use std::sync::{Arc, Mutex}; -use std::time::Duration; +use std::time::{Duration, Instant}; use eframe::egui; use tokio::sync::mpsc; @@ -43,6 +43,11 @@ pub(crate) struct Snapshot { pub last_action: Option>, /// Whether a command is currently being executed. pub busy: bool, + /// Bumped each time `status` is refreshed, so the UI can tell new data from + /// a repaint of the same data and sample traffic rates only on change. + pub generation: u64, + /// When this status was read, for computing rates between refreshes. + pub at: Option, } /// Handle the UI holds: send commands, read the latest snapshot. @@ -121,6 +126,8 @@ async fn refresh(socket: &std::path::Path, shared: &Arc>, ctx: & { let mut guard = lock(shared); guard.status = Some(status); + guard.generation = guard.generation.wrapping_add(1); + guard.at = Some(Instant::now()); } ctx.request_repaint(); } diff --git a/crates/tsunagi-tray/src/app.rs b/crates/tsunagi-tray/src/app.rs index 18e0865..59fda26 100644 --- a/crates/tsunagi-tray/src/app.rs +++ b/crates/tsunagi-tray/src/app.rs @@ -5,6 +5,7 @@ use std::time::Duration; use eframe::egui; use crate::agent::{AgentClient, resolve_socket}; +use crate::stats::Traffic; use crate::tray::{Action, Tray}; use crate::ui::{self, JoinForm}; @@ -13,6 +14,11 @@ pub(crate) struct App { agent: AgentClient, tray: Tray, join: JoinForm, + /// Traffic rates derived from successive snapshots. + traffic: Traffic, + /// The snapshot generation last folded into `traffic`, so each refresh is + /// sampled exactly once rather than on every repaint. + sampled: u64, /// Set when the user chose Quit, so the next close actually exits instead /// of hiding to the tray. quitting: bool, @@ -31,6 +37,8 @@ impl App { agent, tray, join: JoinForm::default(), + traffic: Traffic::default(), + sampled: 0, quitting: false, }) } @@ -58,7 +66,14 @@ impl eframe::App for App { } let snapshot = self.agent.snapshot(); - ui::draw(ctx, &self.agent, &mut self.join, &snapshot); + // Fold each fresh status into the traffic rates exactly once. + if snapshot.generation != self.sampled + && let (Some(Ok(report)), Some(at)) = (&snapshot.status, snapshot.at) + { + self.traffic.observe(report, at); + self.sampled = snapshot.generation; + } + ui::draw(ctx, &self.agent, &mut self.join, &snapshot, &self.traffic); // Keep ticking so fresh status and tray actions are picked up even when // nothing else requests a repaint. diff --git a/crates/tsunagi-tray/src/main.rs b/crates/tsunagi-tray/src/main.rs index 50bfb37..d2de2cd 100644 --- a/crates/tsunagi-tray/src/main.rs +++ b/crates/tsunagi-tray/src/main.rs @@ -11,6 +11,7 @@ mod agent; mod app; +mod stats; mod tray; mod ui; @@ -28,14 +29,28 @@ fn main() -> Result<(), Box> { .build()?; let handle = runtime.handle().clone(); - let options = eframe::NativeOptions { + #[cfg_attr(not(target_os = "macos"), expect(unused_mut))] + let mut options = eframe::NativeOptions { + // A fixed, small window; the whole layout targets this size. viewport: egui::ViewportBuilder::default() .with_title("tsunagi") - .with_inner_size([380.0, 540.0]) - .with_min_inner_size([320.0, 360.0]), + .with_inner_size([360.0, 600.0]) + .with_resizable(false) + // Start hidden: the app lives in the tray and the window opens from + // the menu. + .with_visible(false), ..Default::default() }; + // macOS: live in the menu bar with no Dock icon (tray-only). + #[cfg(target_os = "macos")] + { + options.event_loop_builder = Some(Box::new(|builder| { + use winit::platform::macos::{ActivationPolicy, EventLoopBuilderExtMacOS}; + builder.with_activation_policy(ActivationPolicy::Accessory); + })); + } + eframe::run_native( "tsunagi", options, diff --git a/crates/tsunagi-tray/src/stats.rs b/crates/tsunagi-tray/src/stats.rs new file mode 100644 index 0000000..2158761 --- /dev/null +++ b/crates/tsunagi-tray/src/stats.rs @@ -0,0 +1,89 @@ +//! Traffic rates derived from successive status snapshots. +//! +//! The agent reports cumulative packet counters, not rates. The GUI samples +//! them each time a fresh snapshot arrives and turns the deltas into a +//! packets-per-second rate plus a short rolling history for a sparkline. One +//! series per key: a network total, and each peer within it. + +use std::collections::{HashMap, VecDeque}; +use std::time::Instant; + +use tsunagi::ipc::StatusReport; + +/// How many rate samples to keep for a sparkline. +const HISTORY: usize = 48; + +/// One series' rolling rate. +#[derive(Default)] +pub(crate) struct Series { + last: Option<(Instant, u64)>, + /// Most recent packets-per-second (tx + rx). + pub rate: f32, + /// Recent rates, oldest first, for a sparkline. + pub history: VecDeque, +} + +impl Series { + fn sample(&mut self, now: Instant, total: u64) { + if let Some((then, previous)) = self.last { + let dt = now.saturating_duration_since(then).as_secs_f32(); + if dt > 0.0 { + let delta = total.saturating_sub(previous) as f32; + self.rate = delta / dt; + self.history.push_back(self.rate); + while self.history.len() > HISTORY { + self.history.pop_front(); + } + } + } + self.last = Some((now, total)); + } +} + +/// All traffic series, keyed by a stable string. +#[derive(Default)] +pub(crate) struct Traffic { + series: HashMap, +} + +impl Traffic { + /// Folds a fresh snapshot into the rates. Call once per new generation. + pub(crate) fn observe(&mut self, report: &StatusReport, now: Instant) { + for network in &report.networks { + let Some(overlay) = &network.overlay else { + continue; + }; + let mut net_total = 0u64; + for peer in &overlay.peers { + let total = peer.tx_packets.saturating_add(peer.rx_packets); + net_total = net_total.saturating_add(total); + self.series + .entry(peer_key(&network.network_id, &peer.public_key)) + .or_default() + .sample(now, total); + } + self.series + .entry(net_key(&network.network_id)) + .or_default() + .sample(now, net_total); + } + } + + /// The network-total series, if sampled. + pub(crate) fn network(&self, network_id: &str) -> Option<&Series> { + self.series.get(&net_key(network_id)) + } + + /// One peer's series, if sampled. + pub(crate) fn peer(&self, network_id: &str, public_key: &str) -> Option<&Series> { + self.series.get(&peer_key(network_id, public_key)) + } +} + +fn net_key(network_id: &str) -> String { + format!("net:{network_id}") +} + +fn peer_key(network_id: &str, public_key: &str) -> String { + format!("peer:{network_id}:{public_key}") +} diff --git a/crates/tsunagi-tray/src/ui.rs b/crates/tsunagi-tray/src/ui.rs index 59b00f0..8229ed9 100644 --- a/crates/tsunagi-tray/src/ui.rs +++ b/crates/tsunagi-tray/src/ui.rs @@ -1,15 +1,27 @@ //! The window contents, drawn from the latest agent snapshot. //! -//! Pure rendering: the current state comes from the snapshot, and a change to a -//! switch or a button sends a [`Command`] to the worker. egui is immediate -//! mode, so the switches reflect the agent's state and only a user change -//! dispatches a command. +//! Pure rendering: state comes from the snapshot and the traffic sampler, and a +//! change to a switch or a button sends a [`Command`] to the worker. The layout +//! targets one fixed, small window, so everything is laid out on a grid and the +//! peer list hides behind a collapsing header. + +use std::collections::VecDeque; use eframe::egui; -use tsunagi::ipc::{NetworkReport, StatusReport}; +use tsunagi::identity::NetworkSecret; +use tsunagi::ipc::{NetworkReport, OverlayPeerReport, StatusReport}; use crate::agent::{AgentClient, Command, Snapshot}; +use crate::stats::Traffic; + +/// Width of the value column's input fields, so name and secret line up. +const INPUT_WIDTH: f32 = 208.0; +/// Accent used for the traffic sparkline. +const ACCENT: egui::Color32 = egui::Color32::from_rgb(0x2f, 0x80, 0xd8); +const OK: egui::Color32 = egui::Color32::from_rgb(0x3c, 0xb0, 0x4a); +const WARN: egui::Color32 = egui::Color32::from_rgb(0xd9, 0x9a, 0x00); +const BAD: egui::Color32 = egui::Color32::from_rgb(0xd0, 0x4a, 0x3c); /// Join form state, kept across frames. #[derive(Default)] @@ -24,8 +36,10 @@ pub(crate) fn draw( agent: &AgentClient, join: &mut JoinForm, snapshot: &Snapshot, + traffic: &Traffic, ) { - egui::CentralPanel::default().show(ctx, |ui| { + egui::TopBottomPanel::top("top").show(ctx, |ui| { + ui.add_space(4.0); ui.horizontal(|ui| { ui.heading("tsunagi"); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { @@ -37,89 +51,129 @@ pub(crate) fn draw( } }); }); + ui.add_space(4.0); + }); - match &snapshot.status { - None => { - ui.add_space(8.0); - ui.label("Connecting to the agent…"); - } - Some(Err(error)) => draw_unreachable(ui, agent, error), - Some(Ok(report)) => { - draw_header(ui, report); - ui.separator(); - egui::ScrollArea::vertical().show(ui, |ui| { + egui::TopBottomPanel::bottom("bottom").show(ctx, |ui| { + if let Some(action) = &snapshot.last_action { + ui.add_space(4.0); + match action { + Ok(message) => ui.colored_label(OK, message), + Err(error) => ui.colored_label(BAD, error), + }; + } + ui.add_space(4.0); + draw_join(ui, agent, join); + ui.add_space(4.0); + }); + + egui::CentralPanel::default().show(ctx, |ui| match &snapshot.status { + None => { + ui.add_space(8.0); + ui.label("Connecting to the agent…"); + } + Some(Err(error)) => draw_unreachable(ui, agent, error), + Some(Ok(report)) => { + draw_header(ui, report); + ui.separator(); + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| { if report.networks.is_empty() { - ui.label("No networks yet. Join one below."); + ui.add_space(6.0); + ui.weak("No networks yet — join one below."); } for network in &report.networks { - draw_network(ui, agent, network); + draw_network(ui, agent, traffic, network); } }); - ui.separator(); - draw_join(ui, agent, join); - } - } - - if let Some(action) = &snapshot.last_action { - ui.add_space(6.0); - match action { - Ok(message) => ui.colored_label(egui::Color32::from_rgb(0x3c, 0xb0, 0x4a), message), - Err(error) => ui.colored_label(egui::Color32::from_rgb(0xd0, 0x4a, 0x3c), error), - }; } }); } -/// The "agent cannot be reached" state, with the socket it tried. +/// The "agent cannot be reached" state, naming the socket it tried. fn draw_unreachable(ui: &mut egui::Ui, agent: &AgentClient, error: &str) { ui.add_space(8.0); - ui.colored_label( - egui::Color32::from_rgb(0xd0, 0x4a, 0x3c), - "The agent is not reachable.", - ); - ui.add_space(4.0); - ui.label(format!("socket: {}", agent.socket().display())); - ui.label(error); + ui.colored_label(BAD, "The agent is not reachable."); ui.add_space(6.0); - ui.label( + kv(ui, "socket", &agent.socket().display().to_string()); + ui.add_space(4.0); + ui.weak(error); + ui.add_space(6.0); + ui.weak( "Start the agent, or check that this user may open its control socket \ (its group, mode 0660).", ); } -/// The device line: id, hostname, cache health. +/// The device line. fn draw_header(ui: &mut egui::Ui, report: &StatusReport) { ui.add_space(4.0); - ui.label(format!("device {}", short(&report.endpoint_id))); - ui.label(format!("host {}", report.hostname)); + egui::Grid::new("device") + .num_columns(2) + .spacing([10.0, 3.0]) + .show(ui, |ui| { + kv_row(ui, "device", &short(&report.endpoint_id)); + kv_row(ui, "host", &report.hostname); + }); if !report.cache_healthy { - ui.colored_label( - egui::Color32::from_rgb(0xd9, 0x9a, 0x00), - "cache unavailable", - ); + ui.colored_label(WARN, "cache unavailable"); } } -/// One network: name, range, this device's address, switches, peers, leave. -fn draw_network(ui: &mut egui::Ui, agent: &AgentClient, network: &NetworkReport) { +/// One network tile. +fn draw_network( + ui: &mut egui::Ui, + agent: &AgentClient, + traffic: &Traffic, + network: &NetworkReport, +) { ui.add_space(6.0); - ui.group(|ui| { + egui::Frame::group(ui.style()).show(ui, |ui| { ui.horizontal(|ui| { ui.strong(network.name.as_str()); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.button("Leave").clicked() { + if ui.small_button("Leave").clicked() { agent.send(Command::Leave { network_id: network.network_id.clone(), }); } }); }); - ui.label(format!("id {}", short(&network.network_id))); - if let Some(range) = &network.range { - ui.label(format!("range {range}")); - } - if let Some(address) = network.overlay.as_ref().and_then(|o| o.address.as_ref()) { - ui.label(format!("addr {address}")); + + let overlay = network.overlay.as_ref(); + let address = overlay.and_then(|o| o.address.as_ref()); + let series = traffic.network(&network.network_id); + + egui::Grid::new(("net", &network.network_id)) + .num_columns(2) + .spacing([10.0, 3.0]) + .show(ui, |ui| { + kv_row(ui, "id", &short(&network.network_id)); + if let Some(range) = &network.range { + kv_row(ui, "range", range); + } + if let Some(address) = address { + kv_row(ui, "addr", address); + } + kv_row( + ui, + "peers", + &format!( + "{} connected · {} known", + network.peers.len(), + network.members.len() + ), + ); + if let Some(series) = series { + kv_row(ui, "traffic", &rate(series.rate)); + } + }); + + if let Some(series) = series + && series.history.len() >= 2 + { + sparkline(ui, &series.history); } ui.horizontal(|ui| { @@ -139,26 +193,81 @@ fn draw_network(ui: &mut egui::Ui, agent: &AgentClient, network: &NetworkReport) } }); - let connected = network.peers.len(); - let known = network.members.len(); - ui.label(format!("peers {connected} connected · {known} known")); + if let Some(overlay) = overlay + && !overlay.peers.is_empty() + { + egui::CollapsingHeader::new(format!("peers ({})", overlay.peers.len())) + .id_salt(("peers", &network.network_id)) + .show(ui, |ui| { + draw_peers(ui, traffic, &network.network_id, &overlay.peers); + }); + } }); } -/// The join form: name + secret + button. +/// The per-peer table inside a network's collapsing header. +fn draw_peers(ui: &mut egui::Ui, traffic: &Traffic, network_id: &str, peers: &[OverlayPeerReport]) { + egui::Grid::new(("peertable", network_id)) + .num_columns(5) + .striped(true) + .spacing([10.0, 3.0]) + .show(ui, |ui| { + for header in ["peer", "address", "age", "tx/rx", "rate"] { + ui.weak(header); + } + ui.end_row(); + + for peer in peers { + ui.monospace(short(&peer.public_key)); + ui.label(peer.address.clone().unwrap_or_else(|| "—".into())); + ui.label(match peer.handshake_secs_ago { + Some(secs) => format!("{secs}s"), + None => "—".into(), + }); + ui.label(format!("{}/{}", peer.tx_packets, peer.rx_packets)); + let pps = traffic + .peer(network_id, &peer.public_key) + .map_or(0.0, |series| series.rate); + ui.label(rate(pps)); + ui.end_row(); + } + }); +} + +/// The join form: aligned name and secret, a generate button, and Join. fn draw_join(ui: &mut egui::Ui, agent: &AgentClient, join: &mut JoinForm) { - ui.add_space(4.0); - ui.label("Join a network"); - ui.horizontal(|ui| { - ui.label("name"); - ui.text_edit_singleline(&mut join.name); - }); - ui.horizontal(|ui| { - ui.label("secret"); - ui.add(egui::TextEdit::singleline(&mut join.secret).password(true)); - }); + ui.label("Join or create a network"); + egui::Grid::new("join") + .num_columns(2) + .spacing([8.0, 6.0]) + .show(ui, |ui| { + ui.label("name"); + ui.add(egui::TextEdit::singleline(&mut join.name).desired_width(INPUT_WIDTH)); + ui.end_row(); + + ui.label("secret"); + ui.horizontal(|ui| { + ui.add( + egui::TextEdit::singleline(&mut join.secret) + .desired_width(INPUT_WIDTH - 26.0) + .password(true), + ); + if ui + .button("⟳") + .on_hover_text("Generate a new random secret") + .clicked() + { + join.secret = NetworkSecret::generate().encode().as_str().to_owned(); + } + }); + ui.end_row(); + }); + let ready = !join.name.trim().is_empty() && !join.secret.trim().is_empty(); - if ui.add_enabled(ready, egui::Button::new("Join")).clicked() { + if ui + .add_enabled(ready, egui::Button::new("Join / create")) + .clicked() + { agent.send(Command::Join { name: join.name.trim().to_string(), secret: join.secret.trim().to_string(), @@ -167,6 +276,55 @@ fn draw_join(ui: &mut egui::Ui, agent: &AgentClient, join: &mut JoinForm) { } } +/// A small filled-ish line chart of recent rates, drawn with the painter. +fn sparkline(ui: &mut egui::Ui, history: &VecDeque) { + let width = ui.available_width(); + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, 26.0), egui::Sense::hover()); + let painter = ui.painter_at(rect); + let peak = history.iter().copied().fold(1.0_f32, f32::max); + let count = history.len(); + if count < 2 { + return; + } + let points: Vec = history + .iter() + .enumerate() + .map(|(i, value)| { + let x = rect.left() + rect.width() * (i as f32 / (count - 1) as f32); + let y = rect.bottom() - (rect.height() - 2.0) * (value / peak); + egui::pos2(x, y) + }) + .collect(); + painter.add(egui::Shape::line( + points, + egui::Stroke::new(1.5_f32, ACCENT), + )); +} + +/// A key/value label pair outside a grid. +fn kv(ui: &mut egui::Ui, key: &str, value: &str) { + ui.horizontal(|ui| { + ui.weak(key); + ui.monospace(value); + }); +} + +/// A key/value row inside a two-column grid. +fn kv_row(ui: &mut egui::Ui, key: &str, value: &str) { + ui.weak(key); + ui.label(value); + ui.end_row(); +} + +/// Formats a packets-per-second rate. +fn rate(pps: f32) -> String { + if pps >= 1000.0 { + format!("{:.1}k pkt/s", pps / 1000.0) + } else { + format!("{pps:.0} pkt/s") + } +} + /// A short, readable prefix of a long identifier (char-safe, no panic). fn short(id: &str) -> String { const KEEP: usize = 12;