diff --git a/src/app.rs b/src/app.rs index 137081f..c9e4fb8 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2,14 +2,15 @@ use std::time::{Duration, Instant}; -use eframe::egui::{self, Align, Layout, RichText, ScrollArea, Sense}; -use vidya::{apply, body, dim_label, status_dot, title, title_2, Mode, Theme}; +use eframe::egui::{self, Align, Layout, RichText}; +use vidya::{ + apply, body, compact_card, data_table, dim_label, lead_trail, metric_bps, metric_cell_px, + metric_rate, pack, page_body, status_dot, table_metric, table_text, title, title_2, Col, + ColKind, Mode, Theme, METRIC_BPS_CHARS, METRIC_RATE_CHARS, +}; use crate::gauge::{arc_gauge, heat_color, mini_bar, PeakScale}; -use crate::io::{ - format_bps, format_bps_col, format_hz_col, format_local_hms, top_reader, top_writer, - IoSampler, IoSnapshot, -}; +use crate::io::{format_bps, format_local_hms, top_reader, top_writer, IoSampler, IoSnapshot}; const SAMPLE_EVERY: Duration = Duration::from_millis(500); /// Gauge fill at/above this of the *previous* scale counts as abnormal. @@ -182,32 +183,28 @@ impl eframe::App for UsageApp { egui::CentralPanel::default() .frame(th.page_frame()) .show(ctx, |ui| { - ScrollArea::vertical() - .auto_shrink([false, false]) - .show(ui, |ui| { - self.gauges_row(ui, &th); - if !self.disk_read_anomalies.is_empty() - || !self.disk_write_anomalies.is_empty() - { - ui.add_space(th.spacing.md); - self.anomaly_section(ui, &th); - } - ui.add_space(th.spacing.lg); - self.proc_table(ui, &th); - ui.add_space(th.spacing.lg); - self.breakdown(ui, &th); - }); + page_body(ui, |ui| { + self.gauges_row(ui, &th); + if !self.disk_read_anomalies.is_empty() + || !self.disk_write_anomalies.is_empty() + { + ui.add_space(th.spacing.md); + self.anomaly_section(ui, &th); + } + ui.add_space(th.spacing.lg); + self.proc_table(ui, &th); + ui.add_space(th.spacing.lg); + self.breakdown(ui, &th); + }); }); } } impl UsageApp { fn gauges_row(&self, ui: &mut egui::Ui, th: &Theme) { - // Compact fixed-size cards — don't stretch to fill the window width. + // Compact fixed-size cards via vidya::pack — no window-width stretch. const GAUGE_SIZE: f32 = 148.0; - let pad = th.spacing.md * 2.0; - let card_w = GAUGE_SIZE + pad; - let gap = th.spacing.md; + let card_w = GAUGE_SIZE + th.spacing.md * 2.0; let disk_r = self.snap.disk_total.read_bps; let disk_w = self.snap.disk_total.write_bps; @@ -226,10 +223,7 @@ impl UsageApp { ("Network up", net_w, nw_max, "Net Up"), ]; - // Explicit sized slots avoid horizontal_wrapped overlap / stretch bugs. - ui.horizontal_wrapped(|ui| { - ui.spacing_mut().item_spacing = egui::vec2(gap, gap); - ui.style_mut().spacing.item_spacing = egui::vec2(gap, gap); + pack(ui, th, |ui| { for (caption, value, max, label) in cards { let frac = if max > 0.0 { (value / max) as f32 @@ -237,28 +231,19 @@ impl UsageApp { 0.0 }; let color = heat_color(th, frac); - // Reserve a fixed cell so frames cannot paint over neighbors. - let cell = egui::vec2(card_w, GAUGE_SIZE + 64.0); - ui.allocate_ui_with_layout(cell, Layout::top_down(Align::Center), |ui| { - ui.set_min_size(cell); - ui.set_max_size(cell); - th.card_frame().show(ui, |ui| { - ui.set_min_width(card_w - 4.0); - ui.set_max_width(card_w - 4.0); - ui.spacing_mut().item_spacing.y = th.spacing.xs.max(2.0); - ui.vertical_centered(|ui| { - dim_label(ui, th, caption); - arc_gauge( - ui, - th, - GAUGE_SIZE, - frac, - &format_bps(value), - label, - color, - ); - dim_label(ui, th, &format!("scale {}", format_bps(max))); - }); + compact_card(ui, th, card_w, |ui| { + ui.vertical_centered(|ui| { + dim_label(ui, th, caption); + arc_gauge( + ui, + th, + GAUGE_SIZE, + frac, + &format_bps(value), + label, + color, + ); + dim_label(ui, th, &format!("scale {}", format_bps(max))); }); }); } @@ -269,25 +254,18 @@ impl UsageApp { fn anomaly_section(&self, ui: &mut egui::Ui, th: &Theme) { let read = &self.disk_read_anomalies; let write = &self.disk_write_anomalies; - // Fixed card width — no need to span the whole window. const CARD_W: f32 = 340.0; - let gap = th.spacing.md; - ui.horizontal_wrapped(|ui| { - ui.spacing_mut().item_spacing = egui::vec2(gap, gap); + pack(ui, th, |ui| { if !read.is_empty() { - th.card_frame().show(ui, |ui| { - ui.set_width(CARD_W); + compact_card(ui, th, CARD_W, |ui| { title_2(ui, th, "Abnormal disk read"); - ui.add_space(th.spacing.xs.max(2.0)); anomaly_table(ui, th, read, "anomaly_read"); }); } if !write.is_empty() { - th.card_frame().show(ui, |ui| { - ui.set_width(CARD_W); + compact_card(ui, th, CARD_W, |ui| { title_2(ui, th, "Abnormal disk write"); - ui.add_space(th.spacing.xs.max(2.0)); anomaly_table(ui, th, write, "anomaly_write"); }); } @@ -316,30 +294,29 @@ impl UsageApp { } fn proc_table(&self, ui: &mut egui::Ui, th: &Theme) { - th.card_frame().show(ui, |ui| { - ui.horizontal(|ui| { - title_2(ui, th, "Processes"); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + vidya::card(ui, th, |ui| { + lead_trail( + ui, + |ui| title_2(ui, th, "Processes"), + |ui| { dim_label( ui, th, &format!("{} readable", self.snap.processes.len()), ); - }); - }); + }, + ); dim_label( ui, th, "Disk write rate and write syscall frequency (from /proc/[pid]/io)", ); - ui.add_space(th.spacing.sm); if self.snap.processes.is_empty() { dim_label(ui, th, "No process I/O readable (try running as root)"); return; } - // Active writers first; floor of top N when idle. let active: Vec<_> = self .snap .processes @@ -352,248 +329,75 @@ impl UsageApp { active.into_iter().take(40).collect() }; - // Grid + fixed-width monospace metrics → no left/right staircase. - debug_assert_eq!(proc_metric_align(), ProcMetricAlign::RightEdge); - egui::Grid::new("process_io_table") - .num_columns(4) - .spacing([th.spacing.md, 2.0]) - .min_col_width(60.0) - .striped(true) - .show(ui, |ui| { - proc_grid_header(ui, th); - ui.end_row(); - for p in rows { - proc_grid_row( - ui, - th, - &p.name, - &p.path, - &format_bps_col(p.write_bps), - &format_hz_col(p.write_freq), - ); - ui.end_row(); - } - }); + let write_w = metric_cell_px(th, METRIC_BPS_CHARS); + let freq_w = metric_cell_px(th, METRIC_RATE_CHARS); + let cols = [ + Col { + header: "Name", + kind: ColKind::Flex, + }, + Col { + header: "Path", + kind: ColKind::Flex, + }, + Col { + header: "Write", + kind: ColKind::Metric { width: write_w }, + }, + Col { + header: "Write freq", + kind: ColKind::Metric { width: freq_w }, + }, + ]; + let n = rows.len(); + data_table(ui, th, "process_io_table", &cols, |ui, i| { + let p = rows[i]; + table_text(ui, th, &truncate_chars(&p.name, 28), true); + let path_show = if p.path.is_empty() { + "—".to_string() + } else { + truncate_middle(&p.path, 64) + }; + table_text(ui, th, &path_show, false); + table_metric(ui, th, write_w, &metric_bps(p.write_bps), false); + table_metric(ui, th, freq_w, &metric_rate(p.write_freq), true); + }, n); }); } } -/// Process | Time | Usage — single-line cells, compact fixed columns. +/// Abnormal I/O list via vidya [`data_table`] (fixed metric column, no stretch). fn anomaly_table(ui: &mut egui::Ui, th: &Theme, hits: &[AnomalyHit], id: &str) { - const NAME_W: f32 = 120.0; - const TIME_W: f32 = 100.0; - const USAGE_W: f32 = 80.0; - let row_h = th.type_scale.caption + 10.0; - let name_w = NAME_W; - - // Header - { - let (rect, _) = - ui.allocate_exact_size(egui::vec2(ui.available_width(), row_h), Sense::hover()); - ui.painter() - .rect_filled(rect, th.spacing.radius_sm, th.palette.headerbar_bg); - ui.scope_builder(egui::UiBuilder::new().max_rect(rect), |ui| { - anomaly_cells( - ui, - th, - row_h, - true, - "Process", - "Time", - "Usage", - name_w, - TIME_W, - USAGE_W, - ); - }); - } - - for (i, hit) in hits.iter().rev().enumerate() { - let zebra = if i % 2 == 0 { - th.palette.view_bg - } else { - th.palette.card_bg - }; - let (rect, _) = - ui.allocate_exact_size(egui::vec2(ui.available_width(), row_h), Sense::hover()); - ui.painter().rect_filled(rect, 0.0, zebra); - ui.scope_builder(egui::UiBuilder::new().max_rect(rect).id_salt((id, i)), |ui| { - anomaly_cells( - ui, - th, - row_h, - false, - &hit.proc_name, - &hit.stamp, - &format_bps(hit.rate_bps), - name_w, - TIME_W, - USAGE_W, - ); - }); - } -} - -fn anomaly_cells( - ui: &mut egui::Ui, - th: &Theme, - row_h: f32, - is_header: bool, - process: &str, - time: &str, - usage: &str, - name_w: f32, - time_w: f32, - usage_w: f32, -) { - let head = |s: &str| { - RichText::new(s) - .size(th.type_scale.caption) - .strong() - .color(th.palette.text_secondary) - }; - let warn = |s: &str| { - RichText::new(s) - .size(th.type_scale.caption) - .strong() - .color(th.palette.warning) - }; - let dim = |s: &str| { - RichText::new(s) - .size(th.type_scale.caption) - .color(th.palette.text_secondary) - }; - - ui.horizontal(|ui| { - ui.set_min_height(row_h); - ui.set_max_height(row_h); - ui.spacing_mut().item_spacing.x = th.spacing.sm; - ui.add_space(th.spacing.sm); - - let name = if is_header { - process.to_string() - } else { - truncate_chars(process, 36) - }; - // `truncate()` keeps a single line — no wrap of "chromium" / time. - ui.add_sized( - [name_w, row_h], - egui::Label::new(if is_header { head(&name) } else { warn(&name) }) - .truncate(), - ); - ui.add_sized( - [time_w, row_h], - egui::Label::new(if is_header { head(time) } else { dim(time) }).truncate(), - ); - ui.add_sized( - [usage_w, row_h], - egui::Label::new(if is_header { head(usage) } else { dim(usage) }) - .halign(Align::RIGHT) - .truncate(), - ); - }); -} - -/// Fixed metric column widths for the process table (Write, Write freq). -pub(crate) const PROC_WRITE_COL_W: f32 = 110.0; -pub(crate) const PROC_FREQ_COL_W: f32 = 90.0; -pub(crate) const PROC_NAME_COL_W: f32 = 140.0; - -/// How process-table rate cells pin text. Exported for waterfall regression tests. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum ProcMetricAlign { - /// Fixed-width monospace strings + grid cells (no length staircase). - RightEdge, -} - -pub(crate) fn proc_metric_align() -> ProcMetricAlign { - ProcMetricAlign::RightEdge -} - -fn proc_grid_header(ui: &mut egui::Ui, th: &Theme) { - let h = |s: &str| { - RichText::new(s) - .size(th.type_scale.caption) - .strong() - .color(th.palette.text_secondary) - }; - ui.add_sized( - [PROC_NAME_COL_W, 0.0], - egui::Label::new(h("Name")).truncate(), - ); - ui.label(h("Path")); - // Right-aligned header labels over fixed metric columns. - ui.allocate_ui_with_layout( - egui::vec2(PROC_WRITE_COL_W, th.type_scale.caption + 6.0), - Layout::right_to_left(Align::Center), - |ui| { - ui.set_min_width(PROC_WRITE_COL_W); - ui.label(h("Write")); - }, - ); - ui.allocate_ui_with_layout( - egui::vec2(PROC_FREQ_COL_W, th.type_scale.caption + 6.0), - Layout::right_to_left(Align::Center), - |ui| { - ui.set_min_width(PROC_FREQ_COL_W); - ui.label(h("Write freq")); + let usage_w = metric_cell_px(th, METRIC_BPS_CHARS); + let cols = [ + Col { + header: "Process", + kind: ColKind::Flex, }, - ); -} - -fn proc_grid_row(ui: &mut egui::Ui, th: &Theme, name: &str, path: &str, write: &str, freq: &str) { - // `write` / `freq` are already fixed-width via format_*_col (space-padded). - ui.add_sized( - [PROC_NAME_COL_W, 0.0], - egui::Label::new( - RichText::new(truncate_chars(name, 28)) - .size(th.type_scale.body) - .color(th.palette.text), - ) - .truncate(), - ); - let path_label = if path.is_empty() { - "—".to_string() - } else { - truncate_middle(path, 64) - }; - ui.add( - egui::Label::new( - RichText::new(path_label) - .size(th.type_scale.caption) - .color(th.palette.text_secondary), - ) - .truncate(), - ); - // Monospace fixed-width cells: every row same glyph width → no waterfall. - ui.allocate_ui_with_layout( - egui::vec2(PROC_WRITE_COL_W, th.type_scale.caption + 6.0), - Layout::left_to_right(Align::Center), - |ui| { - ui.set_min_width(PROC_WRITE_COL_W); - ui.set_max_width(PROC_WRITE_COL_W); - ui.label( - RichText::new(write) - .size(th.type_scale.caption) - .monospace() - .color(th.palette.text), - ); + Col { + header: "Time", + kind: ColKind::Flex, }, - ); - ui.allocate_ui_with_layout( - egui::vec2(PROC_FREQ_COL_W, th.type_scale.caption + 6.0), - Layout::left_to_right(Align::Center), - |ui| { - ui.set_min_width(PROC_FREQ_COL_W); - ui.set_max_width(PROC_FREQ_COL_W); - ui.label( - RichText::new(freq) - .size(th.type_scale.caption) - .monospace() - .color(th.palette.text_secondary), - ); + Col { + header: "Usage", + kind: ColKind::Metric { width: usage_w }, }, - ); + ]; + // Newest first for display. + let view: Vec<&AnomalyHit> = hits.iter().rev().collect(); + let n = view.len(); + data_table(ui, th, id, &cols, |ui, i| { + let hit = view[i]; + // Warning-colored process name. + ui.label( + RichText::new(truncate_chars(&hit.proc_name, 28)) + .size(th.type_scale.caption) + .strong() + .color(th.palette.warning), + ); + table_text(ui, th, &hit.stamp, false); + table_metric(ui, th, usage_w, &metric_bps(hit.rate_bps), true); + }, n); } fn truncate_chars(s: &str, max_chars: usize) -> String { @@ -714,102 +518,51 @@ fn device_card( }); } -/// Waterfall regression guards — process-table Write / Write-freq layout. +/// Consumer uses Vidya layout DSL for process/anomaly tables. #[cfg(test)] -mod waterfall_guards { - use super::{ - proc_metric_align, ProcMetricAlign, PROC_FREQ_COL_W, PROC_WRITE_COL_W, - }; - use crate::io::{ - format_bps, format_bps_col, format_hz, format_hz_col, BPS_COL_CHARS, HZ_COL_CHARS, - }; - +mod layout_dsl_consumer { fn app_src() -> &'static str { include_str!("app.rs") } - fn proc_table_src() -> &'static str { + #[test] + fn process_table_uses_vidya_data_table_and_metrics() { let src = app_src(); - let start = src - .find("fn proc_table(") - .expect("proc_table must exist in shipped app.rs"); - let rest = &src[start..]; - let end = rest - .find("\n/// Process | Time | Usage") - .or_else(|| rest.find("\nfn anomaly_table(")) - .unwrap_or(rest.len()); - &rest[..end] + let start = src.find("fn proc_table(").expect("proc_table"); + let body = &src[start..]; + let end = body.find("\nfn anomaly_table(").unwrap_or(body.len()); + let body = &body[..end]; + assert!(body.contains("data_table"), "must use vidya::data_table"); + assert!(body.contains("metric_bps") && body.contains("metric_rate")); + assert!(body.contains("table_metric") && body.contains("table_text")); + assert!(!body.contains("max_write") && !body.contains("bar_w")); + assert!(body.contains("\"Name\"") && body.contains("\"Write\"")); } #[test] - fn no_rate_proportional_bars_under_process_rows() { - let body = proc_table_src(); - assert!( - !body.contains("max_write"), - "proc_table must not compute max_write for bar scaling" - ); - assert!( - !body.contains("bar_w") && !body.contains("bar_h"), - "proc_table must not draw rate-proportional underlines" - ); - assert!( - body.contains("Grid::new(\"process_io_table\")") - || body.contains("Grid::new(\"process_io_table\")"), - "proc_table should use a Grid for columns" - ); - // Accept either quoting style - assert!( - body.contains("process_io_table"), - "proc_table must use process_io_table grid id" - ); + fn anomaly_section_uses_compact_card_and_pack() { + let src = app_src(); + let start = src.find("fn anomaly_section(").expect("anomaly_section"); + let body = &src[start..]; + let end = body.find("\n fn breakdown(").unwrap_or(body.len()); + let body = &body[..end]; + assert!(body.contains("compact_card")); + assert!(body.contains("pack")); + assert!(!body.contains("set_width(CARD_W)") && !body.contains("card_frame().show")); } #[test] - fn write_and_freq_use_fixed_width_monospace_cols() { - assert_eq!(proc_metric_align(), ProcMetricAlign::RightEdge); - assert!(PROC_WRITE_COL_W > 0.0 && PROC_FREQ_COL_W > 0.0); - - let body = proc_table_src(); - assert!( - body.contains("format_bps_col") && body.contains("format_hz_col"), - "proc_table must use fixed-width format_*_col helpers" - ); - assert!( - body.contains("proc_grid_row"), - "proc_table must render via proc_grid_row" - ); - - // Shipped formatters: every cell same char width (no length staircase). - let samples = [0.0, 100.0, 44.8 * 1024.0, 2.0 * 1024.0 * 1024.0]; - for bps in samples { - let col = format_bps_col(bps); - assert_eq!( - col.chars().count(), - BPS_COL_CHARS, - "format_bps_col({bps}) = {col:?} wrong width" - ); - assert!(col.ends_with(format_bps(bps).as_str()) || col.contains(format_bps(bps).trim())); + fn shipped_vidya_metric_widths_are_stable() { + // Drive real Vidya formatters (path dep), not local copies. + for bps in [0.0_f64, 1024.0, 2.0 * 1024.0 * 1024.0] { + let s = vidya::metric_bps(bps); + assert_eq!(s.chars().count(), vidya::METRIC_BPS_CHARS); } - let hz_samples = [0.0, 134.0, 2600.0, 1.5]; - for hz in hz_samples { - let col = format_hz_col(hz); - assert_eq!( - col.chars().count(), - HZ_COL_CHARS, - "format_hz_col({hz}) = {col:?} wrong width" - ); - assert!(col.contains(format_hz(hz).as_str()) || col.ends_with(format_hz(hz).as_str())); + for r in [0.0_f64, 134.0, 2600.0] { + let s = vidya::metric_rate(r); + assert_eq!(s.chars().count(), vidya::METRIC_RATE_CHARS); } - } - - #[test] - fn process_table_still_lists_required_columns() { - let src = app_src(); - assert!(src.contains("\"Name\"")); - assert!(src.contains("\"Path\"")); - assert!(src.contains("\"Write\"")); - assert!(src.contains("\"Write freq\"")); - assert!(src.contains("format_bps_col")); - assert!(src.contains("format_hz_col")); + assert!(vidya::side_by_side(800.0, 160.0, 12.0)); + assert!(!vidya::side_by_side(200.0, 160.0, 12.0)); } } diff --git a/src/io.rs b/src/io.rs index ccdca0a..302b9a9 100644 --- a/src/io.rs +++ b/src/io.rs @@ -403,58 +403,9 @@ fn read_proc_counters() -> HashMap { map } -/// Human-readable throughput (B/s → KiB/s, MiB/s, …). +/// Human-readable throughput for gauges (prefer `vidya::metric_bps` in tables). pub fn format_bps(bps: f64) -> String { - const UNITS: [&str; 5] = ["B/s", "KiB/s", "MiB/s", "GiB/s", "TiB/s"]; - let mut v = bps.max(0.0); - let mut i = 0; - while v >= 1024.0 && i < UNITS.len() - 1 { - v /= 1024.0; - i += 1; - } - if i == 0 { - format!("{v:.0} {}", UNITS[i]) - } else if v >= 100.0 { - format!("{v:.0} {}", UNITS[i]) - } else if v >= 10.0 { - format!("{v:.1} {}", UNITS[i]) - } else { - format!("{v:.2} {}", UNITS[i]) - } -} - -/// Table column width (chars) for [`format_bps_col`] — keeps monospace columns flush. -pub const BPS_COL_CHARS: usize = 14; - -/// Fixed-width throughput for tables (left-padded). With a monospace font both -/// edges of the column stay vertical — no length "waterfall"/staircase. -pub fn format_bps_col(bps: f64) -> String { - let s = format_bps(bps); - format!("{s:>BPS_COL_CHARS$}") -} - -/// Human-readable event rate (writes/s). -pub fn format_hz(rate: f64) -> String { - if rate < 0.05 { - "0/s".into() - } else if rate < 10.0 { - format!("{rate:.1}/s") - } else if rate < 1000.0 { - format!("{rate:.0}/s") - } else if rate < 1_000_000.0 { - format!("{:.1}k/s", rate / 1000.0) - } else { - format!("{:.1}M/s", rate / 1_000_000.0) - } -} - -/// Table column width (chars) for [`format_hz_col`]. -pub const HZ_COL_CHARS: usize = 10; - -/// Fixed-width write-frequency for tables (left-padded, monospace-safe). -pub fn format_hz_col(rate: f64) -> String { - let s = format_hz(rate); - format!("{s:>HZ_COL_CHARS$}") + vidya::format_bps(bps) } /// Local wall-clock 12-hour stamp for anomaly labels (`3:04:05 PM`).