diff --git a/src/app.rs b/src/app.rs index b85ac59..4ea8ca5 100644 --- a/src/app.rs +++ b/src/app.rs @@ -4,7 +4,7 @@ use std::time::{Duration, Instant}; use eframe::egui::{self, Align, Layout, RichText}; use vidya::{ - apply, body, compact_card, dim_label, grid_cols, lead_trail, pack, page_body, status_dot, + apply, body, card, compact_card, dim_label, grid_cols, lead_trail, page_body, status_dot, title, title_2, ColSpec, Mode, Theme, }; @@ -250,41 +250,95 @@ impl UsageApp { let write = &self.disk_write_anomalies; const CARD_W: f32 = 340.0; - pack(ui, th, |ui| { - if !read.is_empty() { - compact_card(ui, th, CARD_W, |ui| { - title_2(ui, th, "Abnormal disk read"); - anomaly_table(ui, th, read, "anomaly_read"); + let has_read = !read.is_empty(); + let has_write = !write.is_empty(); + if !has_read && !has_write { + return; + } + + // Side-by-side when both present; single cell otherwise — always grid DSL. + if has_read && has_write { + grid_cols( + ui, + th, + "anomaly_pair", + &[ColSpec::Fixed(CARD_W), ColSpec::Fixed(CARD_W)], + |g| { + g.row(|r| { + r.cell(|ui| { + compact_card(ui, th, CARD_W, |ui| { + title_2(ui, th, "Abnormal disk read"); + anomaly_table(ui, th, read, "anomaly_read"); + }); + }); + r.cell(|ui| { + compact_card(ui, th, CARD_W, |ui| { + title_2(ui, th, "Abnormal disk write"); + anomaly_table(ui, th, write, "anomaly_write"); + }); + }); + }); + }, + ); + } else if has_read { + grid_cols(ui, th, "anomaly_read_only", &[ColSpec::Fixed(CARD_W)], |g| { + g.row(|r| { + r.cell(|ui| { + compact_card(ui, th, CARD_W, |ui| { + title_2(ui, th, "Abnormal disk read"); + anomaly_table(ui, th, read, "anomaly_read"); + }); + }); }); - } - if !write.is_empty() { - compact_card(ui, th, CARD_W, |ui| { - title_2(ui, th, "Abnormal disk write"); - anomaly_table(ui, th, write, "anomaly_write"); + }); + } else { + grid_cols(ui, th, "anomaly_write_only", &[ColSpec::Fixed(CARD_W)], |g| { + g.row(|r| { + r.cell(|ui| { + compact_card(ui, th, CARD_W, |ui| { + title_2(ui, th, "Abnormal disk write"); + anomaly_table(ui, th, write, "anomaly_write"); + }); + }); }); - } - }); + }); + } } fn breakdown(&self, ui: &mut egui::Ui, th: &Theme) { - ui.columns(2, |cols| { - device_card( - &mut cols[0], - th, - "Disks", - &self.snap.disks, - self.disk_read_scale.current().max(self.disk_write_scale.current()), - true, - ); - device_card( - &mut cols[1], - th, - "Interfaces", - &self.snap.nets, - self.net_rx_scale.current().max(self.net_tx_scale.current()), - false, - ); - }); + // Disks | Interfaces as one grid row. + grid_cols( + ui, + th, + "device_breakdown", + &[ColSpec::Flex, ColSpec::Flex], + |g| { + g.row(|r| { + r.cell(|ui| { + device_card( + ui, + th, + "Disks", + &self.snap.disks, + self.disk_read_scale + .current() + .max(self.disk_write_scale.current()), + true, + ); + }); + r.cell(|ui| { + device_card( + ui, + th, + "Interfaces", + &self.snap.nets, + self.net_rx_scale.current().max(self.net_tx_scale.current()), + false, + ); + }); + }); + }, + ); } fn proc_table(&self, ui: &mut egui::Ui, th: &Theme) { @@ -459,9 +513,8 @@ fn device_card( scale: f64, is_disk: bool, ) { - th.card_frame().show(ui, |ui| { + card(ui, th, |ui| { title_2(ui, th, heading); - ui.add_space(th.spacing.sm); if devices.is_empty() { dim_label( @@ -477,56 +530,53 @@ fn device_card( } let max_rows = 8; - for (i, d) in devices.iter().take(max_rows).enumerate() { - if i > 0 { - ui.add_space(th.spacing.sm); - } - ui.horizontal(|ui| { - body(ui, th, &d.name); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - let total = d.rates.read_bps + d.rates.write_bps; + let shown: Vec<&crate::io::DeviceIo> = devices.iter().take(max_rows).collect(); + // Each device: name+total on one grid row of bars via nested grid. + for d in &shown { + lead_trail( + ui, + |ui| body(ui, th, &d.name), + |ui| { ui.label( - RichText::new(format_bps(total)) + RichText::new(format_bps(d.rates.read_bps + d.rates.write_bps)) .size(th.type_scale.caption) .color(th.palette.text_secondary), ); - }); - }); - ui.horizontal(|ui| { - let rw = ui.available_width(); - let half = ((rw - th.spacing.sm) * 0.5).max(40.0); - ui.vertical(|ui| { - dim_label( - ui, - th, - &format!("↓ {}", format_bps(d.rates.read_bps)), - ); - let f = if scale > 0.0 { - (d.rates.read_bps / scale) as f32 - } else { - 0.0 - }; - mini_bar(ui, th, f, th.palette.accent, half); - }); - ui.add_space(th.spacing.sm); - ui.vertical(|ui| { - dim_label( - ui, - th, - &format!("↑ {}", format_bps(d.rates.write_bps)), - ); - let f = if scale > 0.0 { - (d.rates.write_bps / scale) as f32 - } else { - 0.0 - }; - mini_bar(ui, th, f, th.palette.success, half); - }); - }); + }, + ); + grid_cols( + ui, + th, + ("dev_bars", d.name.as_str()), + &[ColSpec::Flex, ColSpec::Flex], + |g| { + g.row(|r| { + r.cell(|ui| { + dim_label(ui, th, &format!("↓ {}", format_bps(d.rates.read_bps))); + let f = if scale > 0.0 { + (d.rates.read_bps / scale) as f32 + } else { + 0.0 + }; + let w = ui.available_width().max(40.0); + mini_bar(ui, th, f, th.palette.accent, w); + }); + r.cell(|ui| { + dim_label(ui, th, &format!("↑ {}", format_bps(d.rates.write_bps))); + let f = if scale > 0.0 { + (d.rates.write_bps / scale) as f32 + } else { + 0.0 + }; + let w = ui.available_width().max(40.0); + mini_bar(ui, th, f, th.palette.success, w); + }); + }); + }, + ); } if devices.len() > max_rows { - ui.add_space(th.spacing.sm); dim_label( ui, th, @@ -536,37 +586,87 @@ fn device_card( }); } -/// Consumer uses Vidya grid layout DSL for process/anomaly tables. +/// Consumer: all multi-column UI surfaces go through Vidya `grid_cols` DSL. #[cfg(test)] mod layout_dsl_consumer { fn app_src() -> &'static str { include_str!("app.rs") } + fn section<'a>(src: &'a str, start_pat: &str, end_pat: &str) -> &'a str { + let start = src.find(start_pat).unwrap_or_else(|| panic!("missing {start_pat}")); + let rest = &src[start..]; + let end = rest.find(end_pat).unwrap_or(rest.len()); + &rest[..end] + } + #[test] - fn process_table_uses_vidya_grid_cols() { - let src = app_src(); - 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("grid_cols"), "must use vidya::grid_cols"); + fn gauges_use_grid_cols_not_pack() { + let body = section(app_src(), "fn gauges_row(", "\n fn anomaly_section("); + assert!(body.contains("grid_cols")); + assert!(body.contains("g.row") || body.contains(".row(|")); + assert!( + !body.contains("pack("), + "gauges must not use pack for multi-tile row" + ); + assert!(!body.contains("horizontal_wrapped")); + } + + #[test] + fn anomaly_section_uses_grid_cols_not_pack_or_columns() { + let body = section(app_src(), "fn anomaly_section(", "\n fn breakdown("); + assert!(body.contains("grid_cols")); + assert!(body.contains("g.row") || body.contains(".row(|")); + assert!( + !body.contains("pack("), + "anomaly multi-column layout must not use pack" + ); + assert!(!body.contains("ui.columns(")); + assert!(body.contains("compact_card")); + } + + #[test] + fn breakdown_uses_grid_cols_not_ui_columns() { + let body = section(app_src(), "fn breakdown(", "\n fn proc_table("); + assert!(body.contains("grid_cols")); + assert!(body.contains("device_breakdown") || body.contains("ColSpec::Flex")); + assert!( + !body.contains("ui.columns("), + "breakdown must not use raw Ui::columns" + ); + } + + #[test] + fn process_table_uses_grid_cols_metrics() { + let body = section(app_src(), "fn proc_table(", "\nfn gauge_tile("); + assert!(body.contains("grid_cols")); assert!(body.contains("ColSpec::MetricBps") && body.contains("ColSpec::MetricRate")); assert!(body.contains("metric_bps") && body.contains("metric_rate")); assert!(!body.contains("max_write") && !body.contains("bar_w")); - assert!(body.contains("\"Name\"") && body.contains("\"Write\"")); } #[test] - fn anomaly_section_uses_compact_card_pack_and_grid() { - let src = app_src(); - assert!(src.contains("compact_card") && src.contains("pack")); - let start = src.find("fn anomaly_table(").expect("anomaly_table"); - let body = &src[start..]; - let end = body.find("\nfn truncate_chars(").unwrap_or(body.len()); - let body = &body[..end]; + fn anomaly_table_uses_grid_row_dsl() { + let body = section(app_src(), "fn anomaly_table(", "\nfn truncate_chars("); assert!(body.contains("grid_cols")); - assert!(body.contains("r.warn") || body.contains(".warn(")); + assert!(body.contains(".warn(") || body.contains("r.warn")); + assert!(body.contains("metric_bps")); + } + + #[test] + fn no_raw_multi_column_helpers_for_main_surfaces() { + // Production code only (exclude this test module's string literals). + let src = app_src(); + let prod = src + .split("mod layout_dsl_consumer") + .next() + .expect("production src"); + for forbidden in ["ui.columns(", "horizontal_wrapped(", "pack("] { + assert!( + !prod.contains(forbidden), + "shipped app production code must not use {forbidden}" + ); + } } #[test] @@ -575,11 +675,5 @@ mod layout_dsl_consumer { assert!(!vidya::side_by_side(200.0, 160.0, 12.0)); let th = vidya::Theme::dark(); assert!(vidya::ColSpec::MetricBps.px(&th).unwrap() > 40.0); - for bps in [0.0_f64, 1024.0, 2.0 * 1024.0 * 1024.0] { - assert_eq!( - vidya::metric_bps(bps).chars().count(), - vidya::METRIC_BPS_CHARS - ); - } } }