2026-10-02 15:37:08 +01:00
|
|
|
//! Formatting and small shared widgets: humanized ages, byte sizes and rates,
|
2026-10-02 16:09:33 +01:00
|
|
|
//! a fixed-size sparkline, and a click-to-copy field with a green-flash feedback.
|
2026-10-02 16:00:23 +01:00
|
|
|
|
|
|
|
|
use std::time::{Duration, Instant};
|
2026-10-02 15:37:08 +01:00
|
|
|
|
|
|
|
|
use eframe::egui;
|
|
|
|
|
|
|
|
|
|
use crate::stats::{Series, Unit};
|
|
|
|
|
|
|
|
|
|
/// Sparkline accent.
|
|
|
|
|
const ACCENT: egui::Color32 = egui::Color32::from_rgb(0x2f, 0x80, 0xd8);
|
2026-10-02 16:09:33 +01:00
|
|
|
/// The green a field flashes to when its value is copied.
|
2026-10-02 16:00:23 +01:00
|
|
|
const COPIED_GREEN: egui::Color32 = egui::Color32::from_rgb(0x3c, 0xb0, 0x4a);
|
2026-10-02 16:09:33 +01:00
|
|
|
/// How long the copy flash takes to fade back to normal.
|
2026-10-02 16:00:23 +01:00
|
|
|
const FLASH: Duration = Duration::from_secs(1);
|
2026-10-02 15:37:08 +01:00
|
|
|
|
|
|
|
|
/// 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::<String>())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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))),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-02 16:00:23 +01:00
|
|
|
/// 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
|
2026-10-02 16:09:33 +01:00
|
|
|
/// 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.
|
2026-10-02 16:00:23 +01:00
|
|
|
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<Instant> = ui.memory(|memory| memory.data.get_temp(id));
|
2026-10-02 16:09:33 +01:00
|
|
|
// 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)
|
|
|
|
|
});
|
2026-10-02 16:00:23 +01:00
|
|
|
|
2026-10-02 16:09:33 +01:00
|
|
|
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),
|
2026-10-02 16:00:23 +01:00
|
|
|
};
|
|
|
|
|
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()));
|
|
|
|
|
}
|
2026-10-02 16:09:33 +01:00
|
|
|
if flash.is_some() {
|
|
|
|
|
// Animate the fade smoothly until it completes.
|
|
|
|
|
ui.ctx().request_repaint();
|
2026-10-02 16:00:23 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-02 16:09:33 +01:00
|
|
|
/// 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()))
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-02 15:37:08 +01:00
|
|
|
/// 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<egui::Pos2> = 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(),
|
|
|
|
|
);
|
|
|
|
|
}
|