The VIP editor put routing and traffic observations in a dense text block, while nested layouts in the devices grid could let text escape its row. Separate the VIP overview from configuration and draw both live tables with explicit cell bounds, font-aware row heights, clipping and horizontal overflow. Measure columns before painting and keep widths stable as live values change. The overview reuses the existing traffic sparklines for service totals and individual listener backends, with TX/RX rates, health, RTT, open flows and counters. Retain bounded histories by service/listener/backend identity and release them when their resources disappear. Recreated listeners start fresh. Use egui 0.31 Scene and Painter for a pannable, zoomable routing diagram with Fit, clickable backend details and linked table selection. Highlight recent counter growth separately from the backend selected for new connections; idle bindings alone do not imply traffic, and stale activity expires. Draw legend markers directly rather than depending on optional font glyphs. The overview only reads saved observations; edits and deletion still require Save. No dependencies, wire formats, IPC or persistent schemas change. Validation on Linux through nix develop: - cargo fmt --all -- --check - cargo clippy --locked --workspace --all-targets -- -D warnings - cargo test --locked --workspace --all-targets: 566 passed, 3 ignored - cargo build --locked -p tsunagi-tray The inherited TSUNAGI_CONTROL_SOCKET was unset for Clippy and tests. Seven new GUI tests cover enlarged fonts, mixed online/offline rows, long names, large counters, narrow/wide layouts, scene clicks, traffic selection, stale samples, counter resets, listener recreation and history cleanup. The first enlarged-font regression exposed an override-font height mismatch; row sizing now resolves the actual selected font and the regression passes. A disposable fixture preview rendered successfully with software OpenGL on an isolated Xvfb display. Initial Xvfb attempts needed explicit xkbcommon and OpenGL runtime paths in the Nix environment. The preview never contacted an agent; its temporary source was removed. macOS, Windows, Wayland and live host TUN traffic were not exercised by this UI change.
89 lines
3.5 KiB
Rust
89 lines
3.5 KiB
Rust
//! Fixed row geometry for dense live tables. Children cannot resize a row or
|
|
//! paint into its neighbours; the enclosing scroll area owns overflow.
|
|
use eframe::egui;
|
|
|
|
pub(crate) const GAP: f32 = 10.0;
|
|
|
|
pub(crate) fn row(
|
|
ui: &mut egui::Ui,
|
|
id: impl std::hash::Hash,
|
|
widths: &[f32],
|
|
stripe: bool,
|
|
mut cell: impl FnMut(usize, &mut egui::Ui),
|
|
) {
|
|
let font = egui::FontSelection::Default.resolve(ui.style());
|
|
let text_height = ui.fonts(|fonts| fonts.row_height(&font));
|
|
let height = ui.spacing().interact_size.y.max(text_height) + 10.0;
|
|
let width = widths.iter().sum::<f32>() + GAP * widths.len().saturating_sub(1) as f32;
|
|
let (rect, _) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::hover());
|
|
if stripe {
|
|
ui.painter()
|
|
.rect_filled(rect, 2.0, ui.visuals().faint_bg_color);
|
|
}
|
|
let mut x = rect.left();
|
|
for (column, width) in widths.iter().enumerate() {
|
|
let bounds = egui::Rect::from_min_size(
|
|
egui::pos2(x, rect.top() + 3.0),
|
|
egui::vec2(*width, height - 6.0),
|
|
);
|
|
let mut child = ui.new_child(
|
|
egui::UiBuilder::new()
|
|
.id_salt((&id, column))
|
|
.max_rect(bounds)
|
|
.layout(egui::Layout::left_to_right(egui::Align::Center)),
|
|
);
|
|
child.set_clip_rect(ui.clip_rect().intersect(bounds));
|
|
child.style_mut().wrap_mode = Some(egui::TextWrapMode::Truncate);
|
|
cell(column, &mut child);
|
|
x += width + GAP;
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn oversized_content_is_clipped_to_its_cell_at_every_scale() {
|
|
for size in [13.0, 20.0, 28.0] {
|
|
let ctx = egui::Context::default();
|
|
ctx.style_mut(|style| {
|
|
style.override_font_id = Some(egui::FontId::proportional(size));
|
|
});
|
|
let output = ctx.run(egui::RawInput::default(), |ctx| {
|
|
egui::CentralPanel::default().show(ctx, |ui| {
|
|
for index in 0..4 {
|
|
row(ui, index, &[90.0, 120.0], index % 2 == 0, |column, ui| {
|
|
if column == 0 {
|
|
ui.horizontal(|ui| {
|
|
ui.label("very long device name that cannot fit");
|
|
ui.button("Forget").on_hover_text("Forget");
|
|
});
|
|
} else {
|
|
ui.label(format!("row {index}"));
|
|
}
|
|
});
|
|
}
|
|
});
|
|
});
|
|
let mut previous_bottom = 0.0;
|
|
for index in 0..4 {
|
|
let shape = output.shapes.iter().find(|shape| matches!(&shape.shape, egui::Shape::Text(t) if t.galley.text() == format!("row {index}"))).unwrap();
|
|
let egui::Shape::Text(text) = &shape.shape else {
|
|
unreachable!()
|
|
};
|
|
assert!(text.visual_bounding_rect().top() >= previous_bottom);
|
|
assert!(
|
|
shape.clip_rect.contains_rect(text.visual_bounding_rect()),
|
|
"font {size}: clip {:?}, text {:?}",
|
|
shape.clip_rect,
|
|
text.visual_bounding_rect()
|
|
);
|
|
assert!(shape.clip_rect.left() >= 108.0);
|
|
previous_bottom = shape.clip_rect.bottom();
|
|
}
|
|
}
|
|
}
|
|
}
|