From 94318e7c1649cae935cbd872801f9ffc91ecde6b Mon Sep 17 00:00:00 2001 From: Aleksandr Bogomiakov Date: Wed, 14 Oct 2026 23:37:38 +0100 Subject: [PATCH] Keep tray device metrics on one line The device table sized handshake/RTT and packet columns from fixed samples. Its own header exceeded the handshake sample, and six-digit counters wrapped at the slash, making device and total rows taller and misaligning the plots. Measure actual headers, displayed values and totals before drawing, retain the widest values across frames, and disable wrapping inside table cells. When a column grows, request a second egui layout pass so the grid's cached positions settle before larger text is painted over the next column. Keep the plot as the stretch column and horizontal scrolling for narrow windows. This changes only tray presentation. A headless rendering regression checks the actual text shapes for the header, handshake age/control RTT, per-device packet counts and totals as counts grow past five digits, reach billions, shrink again, and the table gets a narrower container. It checks one-line text and non-overlap with the next column; it failed on the original wrapped header. Existing resize and long-relay tests remain in place. Checks on Linux in nix develop: - cargo fmt --all -- --check - cargo clippy --locked --workspace --all-targets -- -D warnings - cargo test --locked --workspace --all-targets, with inherited TSUNAGI_CONTROL_SOCKET unset so tests use their temporary sockets - git diff --cached --check The suite's root/internet-dependent tests remain ignored. No native GUI session or checks on macOS/Windows were run; rendering was checked through egui's headless output. No installed tray binary was replaced. --- crates/tsunagi-tray/src/devices.rs | 238 ++++++++++++++++++++++++----- 1 file changed, 200 insertions(+), 38 deletions(-) diff --git a/crates/tsunagi-tray/src/devices.rs b/crates/tsunagi-tray/src/devices.rs index 0404626..ba5bc51 100644 --- a/crates/tsunagi-tray/src/devices.rs +++ b/crates/tsunagi-tray/src/devices.rs @@ -462,8 +462,20 @@ fn devices<'a>(network: &'a NetworkReport, own_id: &str) -> Vec> { devices } -/// The widest value each column is expected to hold, so the table starts out -/// wide enough and does not reflow as numbers grow. +const COLUMN_HEADERS: [&str; 10] = [ + "device", + "exit", + "end-to-end", + "handshake / RTT", + "address", + "data path", + "pkts", + "bytes", + "rate", + "plot", +]; + +/// Initial column floors; actual headers, values and totals can grow them. const COLUMN_SAMPLES: [&str; 10] = [ "device", "exit", @@ -511,6 +523,7 @@ fn cell(ui: &mut egui::Ui, column: usize, widths: &[f32; 10], add: impl FnOnce(& |ui| { ui.set_min_width(floor); ui.set_max_width(floor); + ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); add(ui); }, ); @@ -588,7 +601,20 @@ fn device_table( } let widths_id = egui::Id::new(("devices column widths", network_id)); let mut widths = column_widths(ui, widths_id); + let previous_widths = widths; let font = egui::TextStyle::Body.resolve(ui.style()); + let measure = |text: String| { + ui.fonts(|fonts| { + fonts + .layout_no_wrap(text, font.clone(), egui::Color32::WHITE) + .size() + .x + }) + }; + for (column, header) in COLUMN_HEADERS.into_iter().enumerate() { + widths[column] = widths[column].max(measure(header.to_owned())); + } + let (mut tx_packets, mut rx_packets, mut tx_bytes, mut rx_bytes) = (0u64, 0, 0, 0); for device in devices { let name = format!( "{} ({}){}", @@ -596,21 +622,65 @@ fn device_table( short_id(device.id), if !device.online { " Forget" } else { "" } ); - let natural = ui.fonts(|fonts| { - fonts - .layout_no_wrap(name, font.clone(), egui::Color32::WHITE) - .size() - .x - }); - widths[0] = widths[0].max(natural + 24.0); + widths[0] = widths[0].max(measure(name) + 24.0); let (path, _) = data_path_text(device.path, device.via_name.as_deref(), device.online); - let path_width = ui.fonts(|fonts| { - fonts - .layout_no_wrap(path, font.clone(), egui::Color32::WHITE) - .size() - .x - }); - widths[5] = widths[5].max(path_width); + for (column, text) in [ + (2, displayed_protocol(device.tunnel_protocol).to_owned()), + ( + 3, + handshake_rtt_text( + device.handshake_secs, + device.control_rtt_ms, + displayed_protocol(device.tunnel_protocol) != "—", + ), + ), + (4, device.address.clone().unwrap_or_default()), + (5, path), + ] { + widths[column] = widths[column].max(measure(text)); + } + if let Some(peer) = device.overlay { + tx_packets += peer.tx_packets; + rx_packets += peer.rx_packets; + tx_bytes += peer.tx_bytes; + rx_bytes += peer.rx_bytes; + for (column, text) in [ + (6, format!("{}/{}", peer.tx_packets, peer.rx_packets)), + ( + 7, + format!( + "{} / {}", + format::bytes(peer.tx_bytes as f64), + format::bytes(peer.rx_bytes as f64) + ), + ), + ( + 8, + format::series_rate(traffic.peer(network_id, &peer.public_key), unit), + ), + ] { + widths[column] = widths[column].max(measure(text)); + } + } + } + for (column, text) in [ + (6, format!("{tx_packets}/{rx_packets}")), + ( + 7, + format!( + "{} / {}", + format::bytes(tx_bytes as f64), + format::bytes(rx_bytes as f64) + ), + ), + (8, format::series_rate(traffic.network(network_id), unit)), + ] { + widths[column] = widths[column].max(measure(text)); + } + if widths != previous_widths { + // Grid positions use the previous pass's widths. Let it settle + // before painting newly widened values over the next column. + ui.ctx().request_discard("device table columns widened"); } ui.memory_mut(|m| m.data.insert_temp(widths_id, widths)); let widths = stretch_columns(widths, ui.available_width()); @@ -620,21 +690,7 @@ fn device_table( .striped(true) .spacing([10.0, 4.0]) .show(ui, |ui| { - for (column, header) in [ - "device", - "exit", - "end-to-end", - "handshake / RTT", - "address", - "data path", - "pkts", - "bytes", - "rate", - "plot", - ] - .into_iter() - .enumerate() - { + for (column, header) in COLUMN_HEADERS.into_iter().enumerate() { cell(ui, column, &widths, |ui| { let response = ui.weak(header); match column { @@ -744,13 +800,6 @@ fn device_table( // Everything this agent has moved through the overlay, including // devices that have since gone away. - let (mut tx_packets, mut rx_packets, mut tx_bytes, mut rx_bytes) = (0u64, 0, 0, 0); - for peer in devices.iter().filter_map(|d| d.overlay) { - tx_packets += peer.tx_packets; - rx_packets += peer.rx_packets; - tx_bytes += peer.tx_bytes; - rx_bytes += peer.rx_bytes; - } cell(ui, 0, &widths, |ui| { ui.strong("total"); }); @@ -1034,6 +1083,119 @@ mod tests { ); } + #[test] + fn counters_headers_and_handshake_stay_on_one_line_without_overlapping() { + use tsunagi::ipc::{OverlayReport, PeerReport}; + + fn collect_text<'a>( + shapes: &'a [egui::Shape], + texts: &mut Vec<&'a egui::epaint::TextShape>, + ) { + for shape in shapes { + match shape { + egui::Shape::Text(shape) => texts.push(shape), + egui::Shape::Vec(shapes) => collect_text(shapes, texts), + _ => {} + } + } + } + + let context = egui::Context::default(); + for (width, packets, age) in [ + (1600.0, 1569, 56), + (1600.0, 55_555, 172_800), + (1600.0, 137_245, 172_800), + (900.0, 9_876_543_210, 172_800), + (1600.0, 42, 56), + ] { + let network = NetworkReport { + network_id: "network".into(), + peers: ["peer", "other"] + .into_iter() + .map(|id| PeerReport { + endpoint_id: id.into(), + rtt_ms: Some(12_345), + ..Default::default() + }) + .collect(), + overlay: Some(OverlayReport { + peers: ["peer", "other"] + .into_iter() + .map(|id| OverlayPeerReport { + endpoint_id: id.into(), + protocol: "wg-quic".into(), + address: Some("10.13.37.100".into()), + handshake_secs_ago: Some(age), + tx_packets: packets, + rx_packets: packets + 1, + tx_bytes: 123_456_789, + rx_bytes: 987_654_321, + ..Default::default() + }) + .collect(), + ..Default::default() + }), + ..Default::default() + }; + let rows = devices(&network, "own"); + for _ in 0..3 { + let output = context.run( + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(2400.0, 640.0), + )), + ..Default::default() + }, + |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + ui.allocate_ui(egui::vec2(width, 480.0), |ui| { + device_table( + ui, + Unit::Packets, + &network, + &rows, + &Traffic::default(), + &mut None, + &mut None, + ); + }); + }); + }, + ); + let shapes: Vec<_> = output.shapes.into_iter().map(|s| s.shape).collect(); + let mut texts = Vec::new(); + collect_text(&shapes, &mut texts); + for (label, next_column) in [ + ("handshake / RTT".to_string(), "address"), + (handshake_rtt_text(Some(age), Some(12_345), true), "address"), + (format!("{packets}/{}", packets + 1), "bytes"), + (format!("{}/{}", packets * 2, (packets + 1) * 2), "bytes"), + ] { + let right = texts + .iter() + .find(|shape| shape.galley.text() == next_column) + .unwrap() + .pos + .x; + let matching: Vec<_> = texts + .iter() + .filter(|shape| shape.galley.text() == label) + .collect(); + assert!(!matching.is_empty(), "missing {label}"); + for shape in &matching { + assert_eq!(shape.galley.rows.len(), 1, "wrapped {label}"); + assert!( + shape.visual_bounding_rect().right() <= right, + "overlapping {label}: {} > {right} at width {width}", + shape.visual_bounding_rect().right() + ); + } + } + } + } + } + #[test] fn a_long_relay_name_gets_its_full_column_width() { let mut relay = member("near", true, DataPathReport::None);