//! Formatting and small shared widgets: humanized ages, byte sizes and rates, //! a fixed-size sparkline, and a click-to-copy field with a green-flash feedback. use std::time::{Duration, Instant}; use eframe::egui; use crate::stats::{Series, Unit}; /// Sparkline accent. const ACCENT: egui::Color32 = egui::Color32::from_rgb(0x2f, 0x80, 0xd8); /// The green a field flashes to when its value is copied. const COPIED_GREEN: egui::Color32 = egui::Color32::from_rgb(0x3c, 0xb0, 0x4a); /// How long the copy flash takes to fade back to normal. const FLASH: Duration = Duration::from_secs(1); /// A short, readable prefix of a long identifier (char-safe, no panic). pub(crate) fn short(id: &str) -> String { const KEEP: usize = 12; if id.chars().count() <= KEEP { id.to_string() } else { format!("{}…", id.chars().take(KEEP).collect::()) } } /// A duration in seconds as a compact age (`5s`, `12m`, `3h 20m`, `2d 4h`). pub(crate) fn age(secs: u64) -> String { if secs < 60 { format!("{secs}s") } else if secs < 3_600 { format!("{}m", secs / 60) } else if secs < 86_400 { format!("{}h {}m", secs / 3_600, (secs % 3_600) / 60) } else { format!("{}d {}h", secs / 86_400, (secs % 86_400) / 3_600) } } /// A byte count in binary units. pub(crate) fn bytes(n: f64) -> String { const UNITS: [&str; 5] = ["B", "kB", "MB", "GB", "TB"]; let mut value = n.max(0.0); let mut unit = 0; while value >= 1024.0 && unit < UNITS.len() - 1 { value /= 1024.0; unit += 1; } if unit == 0 { format!("{} B", value as u64) } else { format!("{value:.1} {}", UNITS[unit]) } } /// A rate in the chosen unit, per second. pub(crate) fn rate(value: f32, unit: Unit) -> String { match unit { Unit::Packets => { if value >= 1000.0 { format!("{:.1}k pkt/s", value / 1000.0) } else { format!("{value:.0} pkt/s") } } Unit::Bytes => format!("{}/s", bytes(f64::from(value))), } } /// The current rate of a series as a string, or a dash when unsampled. pub(crate) fn series_rate(series: Option<&Series>, unit: Unit) -> String { series.map_or_else(|| "—".to_string(), |s| rate(s.rate(unit), unit)) } /// A label whose text is `display` and which copies `value` to the clipboard /// when clicked. Feedback is a green flash that fades back over a second — the /// text itself never changes, so a dense table does not reflow on a copy. pub(crate) fn copy_field(ui: &mut egui::Ui, display: &str, value: &str) { let id = ui.make_persistent_id(("copy", value, display)); let copied_at: Option = ui.memory(|memory| memory.data.get_temp(id)); // 1.0 right after the click, fading to 0.0 a second later. let flash = copied_at.and_then(|at| { let elapsed = at.elapsed().as_secs_f32(); let span = FLASH.as_secs_f32(); (elapsed < span).then(|| 1.0 - elapsed / span) }); let text = match flash { Some(factor) => { let base = ui.visuals().text_color(); egui::RichText::new(display).color(lerp_color(base, COPIED_GREEN, factor)) } None => egui::RichText::new(display), }; let response = ui .add(egui::Label::new(text).sense(egui::Sense::click())) .on_hover_cursor(egui::CursorIcon::PointingHand) .on_hover_text("click to copy"); if response.clicked() { ui.ctx().copy_text(value.to_owned()); ui.memory_mut(|memory| memory.data.insert_temp(id, Instant::now())); } if flash.is_some() { // Animate the fade smoothly until it completes. ui.ctx().request_repaint(); } } /// Linear blend between two colours, `t` in `0.0..=1.0` toward `b`. fn lerp_color(a: egui::Color32, b: egui::Color32, t: f32) -> egui::Color32 { let t = t.clamp(0.0, 1.0); let mix = |x: u8, y: u8| (f32::from(x) + (f32::from(y) - f32::from(x)) * t).round() as u8; egui::Color32::from_rgb(mix(a.r(), b.r()), mix(a.g(), b.g()), mix(a.b(), b.b())) } /// A fixed-height sparkline of a series' recent rate, with the peak (top-left) /// and current value (bottom-right) overlaid. The space is always reserved — /// even with no data — so the layout never jumps when data arrives. pub(crate) fn sparkline(ui: &mut egui::Ui, series: Option<&Series>, unit: Unit, height: f32) { let width = ui.available_width(); let (rect, _) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::hover()); let painter = ui.painter_at(rect); let visuals = ui.visuals(); painter.rect_filled(rect, 2.0, visuals.extreme_bg_color); let Some(series) = series else { painter.text( rect.center(), egui::Align2::CENTER_CENTER, "no data yet", egui::FontId::proportional(10.0), visuals.weak_text_color(), ); return; }; let history = series.history(unit); let peak = history.iter().copied().fold(1.0_f32, f32::max); if history.len() >= 2 { let count = history.len(); 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() - 2.0 - (rect.height() - 12.0) * (value / peak); egui::pos2(x, y) }) .collect(); painter.add(egui::Shape::line( points, egui::Stroke::new(1.5_f32, ACCENT), )); } painter.text( rect.left_top() + egui::vec2(4.0, 2.0), egui::Align2::LEFT_TOP, format!("peak {}", rate(peak, unit)), egui::FontId::proportional(9.0), visuals.weak_text_color(), ); painter.text( rect.right_bottom() + egui::vec2(-4.0, -2.0), egui::Align2::RIGHT_BOTTOM, rate(series.rate(unit), unit), egui::FontId::proportional(10.0), visuals.strong_text_color(), ); }