nandi/jolt-nativepublic Fork 0
dce285fb5a5ec1f331b8afa7b2bdc4ed5e1bbd46
Commits
Clone
git clone https://git.rickub.com/nandi/jolt-native.git
git clone ssh://git@rickub.com/nandi/jolt-native.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

layout.rs · 1149 lines · 38.6 KBRust Blame HistoryRaw
Bring vidya in cfd3e36 nandi 19d ago1//! Layout composition surface — width-safe, compact-by-default primitives.
2//!
3//! Apps should prefer these helpers over raw `set_max_width` / `Layout::…`
4//! plumbing. Escape hatches stay (egui is still available); this module makes
5//! the **good defaults** the short path.
6//!
7//! # Patterns → primitives
8//!
9//! | App footgun | Primitive |
10//! |-------------|-----------|
11//! | Page scrolls off / under edge | [`page_body`] / [`central_page`] |
12//! | Free-stack app UI at top level | **enforced:** [`page_body`] / [`central_page`] take a grid DSL |
13//! | Card overflows column | [`card`] / [`compact_card`] |
14//! | Giant gaps when parent is tall | [`vstack`] (non-justified) |
15//! | Fixed tiles stretch across the window | [`pack`] (wrap, hug content) |
16//! | Actions clipped off the right | [`lead_trail`] |
17//! | Side-by-side vs stack breakpoint | [`two_col`] / [`side_by_side`] |
18//! | Rate columns staircase ("waterfall") | [`metric_bps`] / [`metric_rate`] / [`metric_cell`] / [`grid_cols`] / [`data_table`] |
19//!
20//! # Top-level application UI (enforced)
21//!
22//! Application content in the central panel **must** be composed through the
23//! grid DSL. [`page_body`] and [`central_page`] only accept a [`GridCtx`]
24//! callback — free stacking of widgets at the page root is not part of the
25//! public path. Nested cards, nested [`grid_cols`], gauges, and text go
26//! **inside** cells / [`GridCtx::section`]s.
27//!
28//! ```ignore
29//! vidya::central_page(ctx, &th, "main", |g| {
30//! g.section(|ui| { /* gauges row — often a nested grid_cols */ });
31//! g.section(|ui| { /* process table */ });
32//! });
33//! ```
34//!
35//! Escape hatch (scroll + width pin only, no grid): [`page_scroll`].
36
37use std::hash::Hash;
38
39use egui::{
40 Align, FontId, Frame, Grid, InnerResponse, Layout, Margin, RichText, ScrollArea, Sense, Stroke,
41 Ui, Vec2,
42};
43
44use crate::Theme;
45
46// ── Pure policy (unit-tested without a window) ──────────────────────────────
47
48/// Minimum residual width (px) before two equal columns fit with `gap`.
49///
50/// `true` means place side-by-side; `false` means stack vertically.
51pub fn side_by_side(avail: f32, min_col: f32, gap: f32) -> bool {
52 avail >= min_col * 2.0 + gap && min_col > 0.0 && avail > 0.0
53}
54
55/// Default min column width for [`two_col`] when apps pass theme spacing.
56pub fn default_min_col(theme: &Theme) -> f32 {
57 // Roughly one compact card: control + padding.
58 theme.spacing.control_height * 6.0 + theme.spacing.page
59}
60
61/// Character width of a fixed monospace throughput cell ([`metric_bps`]).
62pub const METRIC_BPS_CHARS: usize = 14;
63
64/// Character width of a fixed monospace event-rate cell ([`metric_rate`]).
65pub const METRIC_RATE_CHARS: usize = 10;
66
67/// Minimum pixel width for a metric cell of `chars` monospace glyphs + padding.
68///
69/// Used as a **floor** for [`ColSpec::MetricBps`] / [`MetricRate`]. Actual cells
70/// still grow via [`metric_cell`] if the painted string is wider.
71pub fn metric_cell_px(theme: &Theme, chars: usize) -> f32 {
72 // Monospace advance is typically ~0.6–0.65em; use 0.72 so columns never
73 // undershoot common caption sizes (was 0.62 and clipped padded metrics).
74 let advance = theme.type_scale.caption * 0.72;
75 let pad = theme.spacing.md + theme.spacing.sm;
76 advance * chars as f32 + pad
77}
78
79/// Left-pad `s` to exactly `width` characters (Unicode scalar count).
80///
81/// Longer strings are returned unchanged (width is a minimum for alignment).
82pub fn pad_metric(s: &str, width: usize) -> String {
83 let n = s.chars().count();
84 if n >= width {
85 s.to_string()
86 } else {
87 format!("{:>width$}", s, width = width)
88 }
89}
90
91/// Human-readable throughput (B/s → KiB/s, …) without padding.
92pub fn format_bps(bps: f64) -> String {
93 const UNITS: [&str; 5] = ["B/s", "KiB/s", "MiB/s", "GiB/s", "TiB/s"];
94 let mut v = bps.max(0.0);
95 let mut i = 0;
96 while v >= 1024.0 && i < UNITS.len() - 1 {
97 v /= 1024.0;
98 i += 1;
99 }
100 if i == 0 {
101 format!("{v:.0} {}", UNITS[i])
102 } else if v >= 100.0 {
103 format!("{v:.0} {}", UNITS[i])
104 } else if v >= 10.0 {
105 format!("{v:.1} {}", UNITS[i])
106 } else {
107 format!("{v:.2} {}", UNITS[i])
108 }
109}
110
111/// Fixed-width throughput for tables (monospace-safe; both edges flush).
112pub fn metric_bps(bps: f64) -> String {
113 pad_metric(&format_bps(bps), METRIC_BPS_CHARS)
114}
115
116/// Human-readable event rate (e.g. write syscalls/s) without padding.
117pub fn format_rate(rate: f64) -> String {
118 if rate < 0.05 {
119 "0/s".into()
120 } else if rate < 10.0 {
121 format!("{rate:.1}/s")
122 } else if rate < 1000.0 {
123 format!("{rate:.0}/s")
124 } else if rate < 1_000_000.0 {
125 format!("{:.1}k/s", rate / 1000.0)
126 } else {
127 format!("{:.1}M/s", rate / 1_000_000.0)
128 }
129}
130
131/// Fixed-width event rate for tables.
132pub fn metric_rate(rate: f64) -> String {
133 pad_metric(&format_rate(rate), METRIC_RATE_CHARS)
134}
135
136// ── Width-safe scopes ───────────────────────────────────────────────────────
137
138/// Pin this scope so children cannot expand past the current available width.
139pub fn fit_width(ui: &mut Ui, add: impl FnOnce(&mut Ui)) -> InnerResponse<()> {
140 let w = ui.available_width().max(1.0);
141 ui.scope(|ui| {
142 ui.set_max_width(w);
143 add(ui);
144 })
145}
146
147/// Same as [`fit_width`], but also set `min_width` so framed children fill the residual.
148pub fn fill_width(ui: &mut Ui, add: impl FnOnce(&mut Ui)) -> InnerResponse<()> {
149 let w = ui.available_width().max(1.0);
150 ui.scope(|ui| {
151 ui.set_min_width(w);
152 ui.set_max_width(w);
153 add(ui);
154 })
155}
156
157/// Non-justified vertical stack — **no giant gaps** when the parent is taller
158/// than the content (the usual egui “waterfall” inside stretched cards).
159pub fn vstack(ui: &mut Ui, theme: &Theme, add: impl FnOnce(&mut Ui)) -> InnerResponse<()> {
160 let gap = theme.spacing.sm;
161 ui.with_layout(Layout::top_down(Align::Min), |ui| {
162 ui.spacing_mut().item_spacing = Vec2::new(ui.spacing().item_spacing.x, gap);
163 add(ui);
164 })
165}
166
167// ── Surfaces ────────────────────────────────────────────────────────────────
168
169/// Themed card that **fills** the parent column without overflowing past it.
170///
171/// Content is stacked with [`vstack`] so tall parents do not justify gaps.
172pub fn card(ui: &mut Ui, theme: &Theme, add: impl FnOnce(&mut Ui)) -> InnerResponse<()> {
173 let outer = ui.available_width().max(1.0);
174 ui.set_max_width(outer);
175 theme.card_frame().show(ui, |ui| {
176 let inner = ui.available_width().max(1.0);
177 ui.set_min_width(inner);
178 ui.set_max_width(inner);
179 vstack(ui, theme, add);
180 })
181}
182
183/// Horizontal chrome (inner margin + stroke) added by [`Theme::card_frame`].
184///
185/// Outer card width = content width + this value. Pure / testable.
186pub fn card_frame_chrome_x(theme: &Theme) -> f32 {
187 // card_frame: Margin::same(spacing.md) + Stroke width 1 on each side.
188 theme.spacing.md * 2.0 + 2.0
189}
190
191/// Compact card with a **fixed outer width** that hugs content height.
192///
193/// Use for gauge tiles and anomaly panels that must not stretch across the
194/// window or absorb leftover horizontal space between siblings.
195///
196/// Outer size is clamped to residual width; content width subtracts frame
197/// chrome so the painted card never exceeds the budget (avoids grid overflow).
198pub fn compact_card(
199 ui: &mut Ui,
200 theme: &Theme,
201 width: f32,
202 add: impl FnOnce(&mut Ui),
203) -> InnerResponse<()> {
204 let chrome = card_frame_chrome_x(theme);
205 // Outer size must fit the residual (grid cell / viewport).
206 let outer = width.min(ui.available_width()).max(1.0);
207 let inner = (outer - chrome).max(1.0);
208
209 ui.allocate_ui_with_layout(Vec2::new(outer, 0.0), Layout::top_down(Align::Min), |ui| {
210 ui.set_min_width(outer);
211 ui.set_max_width(outer);
212 // Do NOT set_clip_rect here: max_rect starts with height 0 before
213 // children run, which would hide all content (blank window).
214 theme.card_frame().show(ui, |ui| {
215 ui.set_min_width(inner);
216 ui.set_max_width(inner);
217 vstack(ui, theme, add);
218 });
219 })
220}
221
222/// Soft-bordered inset row (popover surface) capped to parent width.
223pub fn inset_row(ui: &mut Ui, theme: &Theme, add: impl FnOnce(&mut Ui)) -> InnerResponse<()> {
224 let outer = ui.available_width().max(1.0);
225 ui.set_max_width(outer);
226 Frame::new()
227 .fill(theme.palette.popover_bg)
228 .stroke(Stroke::new(1.0, theme.palette.border_soft))
229 .corner_radius(theme.spacing.radius_sm)
230 .inner_margin(Margin::symmetric(
231 theme.spacing.md as i8,
232 theme.spacing.sm as i8,
233 ))
234 .show(ui, |ui| {
235 let w = ui.available_width().max(1.0);
236 ui.set_min_width(w);
237 ui.set_max_width(w);
238 vstack(ui, theme, add);
239 })
240}
241
242// ── Horizontal composition ──────────────────────────────────────────────────
243
244/// Horizontal flow that **wraps** before clipping the edge (toolbars / chips).
245pub fn hflow(ui: &mut Ui, theme: &Theme, add: impl FnOnce(&mut Ui)) -> InnerResponse<()> {
246 let w = ui.available_width().max(1.0);
247 let gap = theme.spacing.sm;
248 ui.scope(|ui| {
249 ui.set_max_width(w);
250 ui.spacing_mut().item_spacing = Vec2::new(gap, gap);
251 ui.horizontal_wrapped(add);
252 })
253}
254
255/// Pack of **compact** children (fixed-size cards/tiles) that wrap without
256/// stretching leftover horizontal space into empty gaps between items.
257///
258/// Same wrapping as [`hflow`], but spacing defaults to `md` so packs match
259/// gauge / anomaly card groups.
260pub fn pack(ui: &mut Ui, theme: &Theme, add: impl FnOnce(&mut Ui)) -> InnerResponse<()> {
261 let w = ui.available_width().max(1.0);
262 let gap = theme.spacing.md;
263 ui.scope(|ui| {
264 ui.set_max_width(w);
265 ui.spacing_mut().item_spacing = Vec2::new(gap, gap);
266 ui.horizontal_wrapped(add);
267 })
268}
269
270/// Leading content grows into remaining width; trailing actions stay visible.
271///
272/// Height hugs content. Do **not** use bare `with_layout(… Align::Center)` here:
273/// egui expands centered horizontal frames to the parent's full available
274/// height, so `min_rect` eats the panel and everything below (toolbars,
275/// tables, scroll areas) gets zero height — a blank detail pane.
276pub fn lead_trail(
277 ui: &mut Ui,
278 leading: impl FnOnce(&mut Ui),
279 trailing: impl FnOnce(&mut Ui),
280) -> InnerResponse<()> {
281 let w = ui.available_width().max(1.0);
282 ui.allocate_ui_with_layout(
283 Vec2::new(w, 0.0),
284 Layout::right_to_left(Align::Center),
285 |ui| {
286 trailing(ui);
287 let rest = ui.available_width().max(1.0);
288 ui.allocate_ui_with_layout(
289 Vec2::new(rest, 0.0),
290 Layout::left_to_right(Align::Center),
291 |ui| {
292 ui.set_max_width(rest);
293 ui.set_min_width(rest);
294 leading(ui);
295 },
296 );
297 },
298 )
299}
300
301/// Two columns when [`side_by_side`] says so; otherwise stack.
302pub fn two_col(
303 ui: &mut Ui,
304 theme: &Theme,
305 min_col: f32,
306 left: impl FnOnce(&mut Ui),
307 right: impl FnOnce(&mut Ui),
308) {
309 let gap = theme.spacing.md;
310 let avail = ui.available_width();
311 if side_by_side(avail, min_col, gap) {
312 ui.columns(2, |cols| {
313 cols[0].set_width(cols[0].available_width());
314 left(&mut cols[0]);
315 cols[1].set_width(cols[1].available_width());
316 right(&mut cols[1]);
317 });
318 } else {
319 left(ui);
320 ui.add_space(gap);
321 right(ui);
322 }
323}
324
325// ── Page shell ──────────────────────────────────────────────────────────────
326//
327// Application UI at the top level is **grid-only**. `page_body` /
328// `central_page` take a `GridCtx` callback so free-form stacking cannot be
329// the supported root composition path. Nested content lives inside cells.
330
331/// Escape hatch: pin width + vertical scroll **without** a top-level grid.
332///
333/// Prefer [`page_body`] / [`central_page`] for application UI. Use this only
334/// when a demo or special chrome needs free-form scrolling content.
335pub fn page_scroll(ui: &mut Ui, add: impl FnOnce(&mut Ui)) {
336 let w = ui.available_width().max(1.0);
337 ui.set_max_width(w);
338 ScrollArea::vertical()
339 .auto_shrink([false, false])
340 .id_salt("vidya_page_scroll")
341 .show(ui, |ui| {
342 let inner = ui.available_width().max(1.0);
343 ui.set_max_width(inner);
344 add(ui);
345 });
346}
347
348/// Scrollable application page whose **top-level content is a grid**.
349///
350/// Defaults to a single flex column: each [`GridCtx::section`] (or `g.row`)
351/// is a full-width page block. Nested multi-column layouts use [`grid_cols`]
352/// inside a section/cell.
353///
354/// This is the supported root for app UI — the callback cannot receive a raw
355/// free-stack `Ui` at the page root.
356pub fn page_body(
357 ui: &mut Ui,
358 theme: &Theme,
359 id: impl Hash,
360 add: impl FnOnce(&mut GridCtx<'_, '_>),
361) {
362 page_body_cols(ui, theme, id, &[ColSpec::Flex], add);
363}
364
365/// Like [`page_body`], but with explicit top-level column specs.
366pub fn page_body_cols(
367 ui: &mut Ui,
368 theme: &Theme,
369 id: impl Hash,
370 cols: &[ColSpec],
371 add: impl FnOnce(&mut GridCtx<'_, '_>),
372) {
373 page_scroll(ui, |ui| {
374 // Page grid: no zebra striping, larger vertical gap between sections.
375 grid_cols_with(ui, theme, id, cols, GridOpts::page(theme), add);
376 });
377}
378
379/// Full central page: themed page frame + grid-enforced [`page_body`].
380///
381/// Application central content **must** be composed via the grid DSL
382/// (`g.section` / `g.row`). See module docs.
383pub fn central_page(
384 ctx: &egui::Context,
385 theme: &Theme,
386 id: impl Hash,
387 add: impl FnOnce(&mut GridCtx<'_, '_>),
388) -> egui::InnerResponse<()> {
389 egui::CentralPanel::default()
390 .frame(theme.page_frame())
391 .show(ctx, |ui| {
392 page_body(ui, theme, id, add);
393 })
394}
395
396/// Like [`central_page`], but with explicit top-level column specs.
397pub fn central_page_cols(
398 ctx: &egui::Context,
399 theme: &Theme,
400 id: impl Hash,
401 cols: &[ColSpec],
402 add: impl FnOnce(&mut GridCtx<'_, '_>),
403) -> egui::InnerResponse<()> {
404 egui::CentralPanel::default()
405 .frame(theme.page_frame())
406 .show(ctx, |ui| {
407 page_body_cols(ui, theme, id, cols, add);
408 })
409}
410
411// ── Grid layout DSL ─────────────────────────────────────────────────────────
412//
413// Declarative rows/columns so apps do not hand-roll `egui::Grid`:
414//
415// ```ignore
416// vidya::grid_cols(ui, &th, "procs", &[
417// ColSpec::Flex,
418// ColSpec::Flex,
419// ColSpec::MetricBps,
420// ColSpec::MetricRate,
421// ], |g| {
422// g.row(|r| {
423// r.heading("Name");
424// r.heading("Path");
425// r.heading("Write");
426// r.heading("Write freq");
427// });
428// g.row(|r| {
429// r.text("chrome");
430// r.dim("/usr/bin/chrome");
431// r.metric_bps(write);
432// r.metric_rate(freq);
433// });
434// });
435// ```
436
437/// Column width hint for the grid DSL.
438#[derive(Debug, Clone, Copy)]
439pub enum ColSpec {
440 /// Grow / shrink with content and leftover space.
441 Flex,
442 /// Fixed pixel width (e.g. custom metric column).
443 Fixed(f32),
444 /// Throughput metric column width from theme.
445 MetricBps,
446 /// Event-rate metric column width from theme.
447 MetricRate,
448}
449
450impl ColSpec {
451 /// Desired / minimum width for fixed metric columns; `None` = flex.
452 pub fn px(self, theme: &Theme) -> Option<f32> {
453 match self {
454 ColSpec::Flex => None,
455 ColSpec::Fixed(w) => Some(w),
456 ColSpec::MetricBps => Some(metric_cell_px(theme, METRIC_BPS_CHARS)),
457 ColSpec::MetricRate => Some(metric_cell_px(theme, METRIC_RATE_CHARS)),
458 }
459 }
460}
461
462/// Minimum width (px) reserved for a flex column when distributing space.
463pub const FLEX_COL_MIN_PX: f32 = 48.0;
464
465/// Distribute per-column **max** widths so `sum(widths) + gaps ≤ avail`.
466///
467/// - Fixed specs (`Some(w)`) keep `w` when the budget allows; otherwise they
468/// scale down proportionally after flex mins are reserved.
469/// - Flex specs (`None`) share the remaining budget equally (each ≥ [`FLEX_COL_MIN_PX`]
470/// when possible).
471///
472/// Pure policy — unit-tested without a window.
473pub fn distribute_col_max(specs: &[Option<f32>], avail: f32, gap: f32) -> Vec<f32> {
474 let n = specs.len().max(1);
475 let gaps = gap * (n.saturating_sub(1) as f32);
476 let budget = (avail.max(1.0) - gaps).max(1.0);
477
478 // No explicit specs → equal flex slices.
479 if specs.is_empty() {
480 return vec![budget / n as f32; n];
481 }
482
483 let flex_n = specs.iter().filter(|s| s.is_none()).count();
484 let fixed_sum: f32 = specs.iter().filter_map(|s| *s).sum();
485
486 if flex_n == 0 {
487 if fixed_sum <= budget {
488 return specs.iter().map(|s| s.unwrap_or(0.0)).collect();
489 }
490 let scale = budget / fixed_sum.max(1.0);
491 return specs.iter().map(|s| s.unwrap_or(0.0) * scale).collect();
492 }
493
494 let flex_floor = FLEX_COL_MIN_PX * flex_n as f32;
495 let fixed_budget = if fixed_sum + flex_floor <= budget {
496 fixed_sum
497 } else {
498 (budget - flex_floor).max(0.0)
499 };
500 let fixed_scale = if fixed_sum > fixed_budget && fixed_sum > 0.0 {
501 fixed_budget / fixed_sum
502 } else {
503 1.0
504 };
505
506 let mut out = vec![0.0_f32; n];
507 let mut used_fixed = 0.0_f32;
508 for (i, s) in specs.iter().enumerate() {
509 if let Some(w) = s {
510 out[i] = (*w * fixed_scale).max(1.0);
511 used_fixed += out[i];
512 }
513 }
514 let flex_each = ((budget - used_fixed) / flex_n as f32).max(1.0);
515 for (i, s) in specs.iter().enumerate() {
516 if s.is_none() {
517 out[i] = flex_each;
518 }
519 }
520 out
521}
522
523/// Live grid session (inside `egui::Grid`).
524pub struct GridCtx<'ui, 'th> {
525 ui: &'ui mut Ui,
526 theme: &'th Theme,
527 /// Desired floors for fixed columns (`None` = flex).
528 col_widths: Vec<Option<f32>>,
529 /// Hard max width per column so the grid fits the residual viewport.
530 col_max: Vec<f32>,
531}
532
533/// Options for [`grid_cols_with`] / the page shell grid.
534#[derive(Debug, Clone, Copy)]
535pub struct GridOpts {
536 /// Zebra striping (tables on; page shell off).
537 pub striped: bool,
538 /// Column gap (x) and row gap (y).
539 pub spacing: Vec2,
540}
541
542impl GridOpts {
543 /// Defaults for nested data tables / multi-column surfaces.
544 pub fn table(theme: &Theme) -> Self {
545 Self {
546 striped: true,
547 spacing: Vec2::new(theme.spacing.md, 2.0),
548 }
549 }
550
551 /// Defaults for top-level [`page_body`] (no striping, section-sized row gap).
552 pub fn page(theme: &Theme) -> Self {
553 Self {
554 striped: false,
555 spacing: Vec2::new(theme.spacing.md, theme.spacing.lg),
556 }
557 }
558}
559
560/// Grid with explicit column specs (recommended for metric tables).
561///
562/// The grid container is pinned to `ui.available_width()` so it cannot grow
563/// past the parent / viewport residual. Column max widths are distributed via
564/// [`distribute_col_max`].
565pub fn grid_cols(
566 ui: &mut Ui,
567 theme: &Theme,
568 id: impl Hash,
569 cols: &[ColSpec],
570 add: impl FnOnce(&mut GridCtx<'_, '_>),
571) {
572 grid_cols_with(ui, theme, id, cols, GridOpts::table(theme), add);
573}
574
575/// Grid with explicit column specs and layout options.
576pub fn grid_cols_with(
577 ui: &mut Ui,
578 theme: &Theme,
579 id: impl Hash,
580 cols: &[ColSpec],
581 opts: GridOpts,
582 add: impl FnOnce(&mut GridCtx<'_, '_>),
583) {
584 let avail = ui.available_width().max(1.0);
585 let n = if cols.is_empty() { 16 } else { cols.len() };
586 let col_widths: Vec<Option<f32>> = cols.iter().map(|c| c.px(theme)).collect();
587 let spacing = opts.spacing;
588 let col_max = if cols.is_empty() {
589 distribute_col_max(&vec![None; n], avail, spacing.x)
590 } else {
591 distribute_col_max(&col_widths, avail, spacing.x)
592 };
593 let cell_max = col_max.iter().copied().fold(24.0_f32, f32::max);
594
595 // Pin container to residual width (no early clip_rect — that can zero out
596 // height before layout and blank the whole page).
597 ui.scope(|ui| {
598 ui.set_max_width(avail);
599 Grid::new(id)
600 .num_columns(n)
601 .spacing(spacing)
602 .min_col_width(24.0)
603 .max_col_width(cell_max)
604 .striped(opts.striped)
605 .show(ui, |ui| {
606 ui.set_max_width(avail);
607 let mut ctx = GridCtx {
608 ui,
609 theme,
610 col_widths,
611 col_max,
612 };
613 add(&mut ctx);
614 });
615 });
616}
617
618/// Grid with all-flex columns. Prefer [`grid_cols`] when you have metrics.
619pub fn grid(ui: &mut Ui, theme: &Theme, id: impl Hash, add: impl FnOnce(&mut GridCtx<'_, '_>)) {
620 grid_cols(ui, theme, id, &[], add);
621}
622
623impl<'ui, 'th> GridCtx<'ui, 'th> {
624 /// Full-width page section: one row containing one cell.
625 ///
626 /// Primary building block for [`page_body`] / [`central_page`]. Put nested
627 /// grids, cards, and free-form widgets **inside** the section — not as
628 /// siblings of the page grid.
629 pub fn section(&mut self, add: impl FnOnce(&mut Ui)) {
630 self.row(|r| {
631 r.cell(add);
632 });
633 }
634
635 /// One table row. Cells are written left→right; `end_row` is automatic.
636 pub fn row(&mut self, add: impl FnOnce(&mut RowDsl<'_, 'th>)) {
637 let mut col_i = 0usize;
638 let mut row = RowDsl {
639 ui: self.ui,
640 theme: self.theme,
641 col_widths: &self.col_widths,
642 col_max: &self.col_max,
643 col_i: &mut col_i,
644 };
645 add(&mut row);
646 self.ui.end_row();
647 }
648
649 /// Access the underlying grid `Ui` (escape hatch).
650 pub fn ui(&mut self) -> &mut Ui {
651 self.ui
652 }
653
654 pub fn theme(&self) -> &Theme {
655 self.theme
656 }
657}
658
659/// One row inside [`GridCtx::row`].
660pub struct RowDsl<'ui, 'th> {
661 ui: &'ui mut Ui,
662 theme: &'th Theme,
663 col_widths: &'ui [Option<f32>],
664 col_max: &'ui [f32],
665 col_i: &'ui mut usize,
666}
667
668impl<'ui, 'th> RowDsl<'ui, 'th> {
669 fn advance(&mut self) {
670 *self.col_i += 1;
671 }
672
673 fn width_hint(&self) -> Option<f32> {
674 self.col_widths.get(*self.col_i).copied().flatten()
675 }
676
677 /// Hard max for this column (viewport residual budget).
678 fn col_max(&self) -> f32 {
679 self.col_max
680 .get(*self.col_i)
681 .copied()
682 .unwrap_or(self.ui.available_width().max(1.0))
683 }
684
685 fn metric_width(&self) -> f32 {
686 let floor = self
687 .width_hint()
688 .unwrap_or_else(|| metric_cell_px(self.theme, METRIC_BPS_CHARS));
689 floor.min(self.col_max()).max(1.0)
690 }
691
692 /// Free-form cell, sized to the column max width (top-aligned in the row).
693 pub fn cell(&mut self, add: impl FnOnce(&mut Ui)) {
694 let max_w = self.col_max().max(1.0);
695 // Width-capped, top-down group = one grid cell.
696 //
697 // Do **not** use `allocate_ui_with_layout(Vec2::new(max_w, 0.0), …)`.
698 // egui::Grid places desired sizes with `Align2::LEFT_CENTER`, so a
699 // zero-height seed is parked mid-row and content grows downward —
700 // leaving a large empty band above (page sections look vertically
701 // centered in the panel). `with_layout` uses the cell's available
702 // rect from the top of the row instead.
703 self.ui.with_layout(Layout::top_down(Align::Min), |ui| {
704 ui.set_min_width(max_w);
705 ui.set_max_width(max_w);
706 add(ui);
707 });
708 self.advance();
709 }
710
711 /// Column header (caption, strong, secondary). Capped to column max.
712 pub fn heading(&mut self, text: &str) {
713 let size = self.theme.type_scale.caption;
714 let max_w = self.col_max();
715 let rt = RichText::new(text)
716 .size(size)
717 .strong()
718 .color(self.theme.palette.text_secondary);
719 let need = measure_text(self.ui, text, size, false) + self.theme.spacing.sm;
720 let width = if let Some(hint) = self.width_hint() {
721 hint.max(need).min(max_w)
722 } else {
723 need.min(max_w)
724 };
725 self.ui.allocate_ui_with_layout(
726 Vec2::new(width.max(1.0), size + 6.0),
727 Layout::right_to_left(Align::Center),
728 |ui| {
729 ui.set_max_width(width.max(1.0));
730 ui.add(egui::Label::new(rt).truncate());
731 },
732 );
733 self.advance();
734 }
735
736 /// Primary body text (flex) — truncates within column max.
737 pub fn text(&mut self, text: &str) {
738 table_text_capped(self.ui, self.theme, text, true, self.col_max());
739 self.advance();
740 }
741
742 /// Secondary caption text (flex) — truncates within column max.
743 pub fn dim(&mut self, text: &str) {
744 table_text_capped(self.ui, self.theme, text, false, self.col_max());
745 self.advance();
746 }
747
748 /// Warning-colored strong caption (e.g. anomaly process name).
749 pub fn warn(&mut self, text: &str) {
750 let max_w = self.col_max();
751 let rt = RichText::new(text)
752 .size(self.theme.type_scale.caption)
753 .strong()
754 .color(self.theme.palette.warning);
755 self.ui.scope(|ui| {
756 ui.set_max_width(max_w);
757 ui.add(egui::Label::new(rt).truncate());
758 });
759 self.advance();
760 }
761
762 /// Right-aligned monospace metric (`text` from [`metric_bps`] / [`metric_rate`]).
763 pub fn metric(&mut self, text: &str) {
764 metric_cell(self.ui, self.theme, self.metric_width(), text, false);
765 self.advance();
766 }
767
768 /// Secondary (dim) metric.
769 pub fn metric_dim(&mut self, text: &str) {
770 metric_cell(self.ui, self.theme, self.metric_width(), text, true);
771 self.advance();
772 }
773
774 /// Throughput from raw B/s.
775 pub fn metric_bps(&mut self, bps: f64) {
776 let w = self
777 .width_hint()
778 .unwrap_or_else(|| metric_cell_px(self.theme, METRIC_BPS_CHARS))
779 .min(self.col_max())
780 .max(1.0);
781 metric_cell(self.ui, self.theme, w, &metric_bps(bps), false);
782 self.advance();
783 }
784
785 /// Event rate from raw 1/s.
786 pub fn metric_rate(&mut self, rate: f64) {
787 let w = self
788 .width_hint()
789 .unwrap_or_else(|| metric_cell_px(self.theme, METRIC_RATE_CHARS))
790 .min(self.col_max())
791 .max(1.0);
792 metric_cell(self.ui, self.theme, w, &metric_rate(rate), true);
793 self.advance();
794 }
795}
796
797// ── Metrics / tables ────────────────────────────────────────────────────────
798
799/// Measure laid-out width of text (no wrap).
800fn measure_text(ui: &Ui, text: &str, size: f32, mono: bool) -> f32 {
801 let family = if mono {
802 egui::FontFamily::Monospace
803 } else {
804 egui::FontFamily::Proportional
805 };
806 let font = FontId::new(size, family);
807 ui.fonts(|f| {
808 f.layout_no_wrap(text.to_owned(), font, egui::Color32::WHITE)
809 .size()
810 .x
811 })
812}
813
814/// Measure monospace caption width for `text`.
815pub fn measure_mono_caption(ui: &Ui, theme: &Theme, text: &str) -> f32 {
816 measure_text(ui, text, theme.type_scale.caption, true)
817}
818
819/// Paint a monospace metric string, right-edge aligned.
820///
821/// `min_width` is the cell size (from ColSpec floor, clamped by
822/// [`distribute_col_max`] to the residual viewport). Text is clip-rect'd so it
823/// never paints past the cell.
824pub fn metric_cell(ui: &mut Ui, theme: &Theme, min_width: f32, text: &str, secondary: bool) {
825 let width = min_width.max(1.0);
826 let h = theme.type_scale.caption + 8.0;
827 let (rect, _) = ui.allocate_exact_size(Vec2::new(width, h), Sense::hover());
828 if !ui.is_rect_visible(rect) {
829 return;
830 }
831 let color = if secondary {
832 theme.palette.text_secondary
833 } else {
834 theme.palette.text
835 };
836 let font = FontId::monospace(theme.type_scale.caption);
837 let painter = ui.painter().with_clip_rect(rect);
838 let pos = egui::pos2(rect.right() - theme.spacing.xs.max(2.0), rect.center().y);
839 painter.text(pos, egui::Align2::RIGHT_CENTER, text, font, color);
840}
841
842/// Column kind for [`data_table`] (thin table helper over the grid DSL).
843#[derive(Debug, Clone, Copy)]
844pub enum ColKind {
845 Flex,
846 Metric { width: f32 },
847}
848
849/// One column header + kind for [`data_table`].
850#[derive(Debug, Clone, Copy)]
851pub struct Col {
852 pub header: &'static str,
853 pub kind: ColKind,
854}
855
856/// Striped data table built on [`grid_cols`].
857///
858/// Prefer the row DSL (`grid_cols` + `g.row`) for new code; this keeps the
859/// index-callback shape used by existing consumers.
860pub fn data_table(
861 ui: &mut Ui,
862 theme: &Theme,
863 id: impl Hash,
864 columns: &[Col],
865 mut row: impl FnMut(&mut Ui, usize),
866 row_count: usize,
867) {
868 let specs: Vec<ColSpec> = columns
869 .iter()
870 .map(|c| match c.kind {
871 ColKind::Flex => ColSpec::Flex,
872 ColKind::Metric { width } => ColSpec::Fixed(width),
873 })
874 .collect();
875
876 // Same viewport pin + col-max policy as [`grid_cols`].
877 let avail = ui.available_width().max(1.0);
878 let n = columns.len().max(1);
879 let col_widths: Vec<Option<f32>> = specs.iter().map(|c| c.px(theme)).collect();
880 let spacing = Vec2::new(theme.spacing.md, 2.0);
881 let col_max = distribute_col_max(&col_widths, avail, spacing.x);
882 let cell_max = col_max.iter().copied().fold(40.0_f32, f32::max);
883
884 ui.scope(|ui| {
885 ui.set_max_width(avail);
886 Grid::new(id)
887 .num_columns(n)
888 .spacing(spacing)
889 .min_col_width(24.0)
890 .max_col_width(cell_max)
891 .striped(true)
892 .show(ui, |ui| {
893 ui.set_max_width(avail);
894 let mut col_i = 0usize;
895 {
896 let mut r = RowDsl {
897 ui,
898 theme,
899 col_widths: &col_widths,
900 col_max: &col_max,
901 col_i: &mut col_i,
902 };
903 for col in columns {
904 r.heading(col.header);
905 }
906 }
907 ui.end_row();
908 for i in 0..row_count {
909 row(ui, i);
910 ui.end_row();
911 }
912 });
913 });
914}
915
916/// Flex text cell for [`data_table`] rows / grid rows (truncate to available).
917pub fn table_text(ui: &mut Ui, theme: &Theme, text: &str, primary: bool) {
918 table_text_capped(ui, theme, text, primary, ui.available_width());
919}
920
921/// Flex text capped to `max_w` so grid columns stay within the viewport budget.
922pub fn table_text_capped(ui: &mut Ui, theme: &Theme, text: &str, primary: bool, max_w: f32) {
923 let color = if primary {
924 theme.palette.text
925 } else {
926 theme.palette.text_secondary
927 };
928 ui.scope(|ui| {
929 ui.set_max_width(max_w.max(1.0));
930 ui.add(
931 egui::Label::new(
932 RichText::new(text)
933 .size(if primary {
934 theme.type_scale.body
935 } else {
936 theme.type_scale.caption
937 })
938 .color(color),
939 )
940 .truncate(),
941 );
942 });
943}
944
945/// Metric cell for [`data_table`] rows (`text` should be [`metric_bps`] / [`metric_rate`]).
946pub fn table_metric(ui: &mut Ui, theme: &Theme, width: f32, text: &str, secondary: bool) {
947 metric_cell(ui, theme, width, text, secondary);
948}
949
950// ── Tests ───────────────────────────────────────────────────────────────────
951
952#[cfg(test)]
953mod tests {
954 use super::*;
955 use crate::Theme;
956
957 #[test]
958 fn side_by_side_policy_matches_two_col_breakpoint() {
959 let gap = 12.0;
960 let min = 160.0;
961 assert!(!side_by_side(100.0, min, gap));
962 assert!(!side_by_side(min * 2.0 + gap - 1.0, min, gap));
963 assert!(side_by_side(min * 2.0 + gap, min, gap));
964 assert!(side_by_side(800.0, min, gap));
965 assert!(!side_by_side(800.0, 0.0, gap));
966 }
967
968 #[test]
969 fn metric_bps_fixed_char_width() {
970 for bps in [0.0, 100.0, 44.8 * 1024.0, 2.0 * 1024.0 * 1024.0, 999.0] {
971 let s = metric_bps(bps);
972 assert_eq!(
973 s.chars().count(),
974 METRIC_BPS_CHARS,
975 "metric_bps({bps}) = {s:?}"
976 );
977 assert!(
978 s.ends_with(format_bps(bps).as_str()),
979 "padding must preserve value"
980 );
981 }
982 }
983
984 #[test]
985 fn metric_rate_fixed_char_width() {
986 for rate in [0.0, 1.5, 134.0, 2600.0, 1_500_000.0] {
987 let s = metric_rate(rate);
988 assert_eq!(
989 s.chars().count(),
990 METRIC_RATE_CHARS,
991 "metric_rate({rate}) = {s:?}"
992 );
993 assert!(
994 s.contains(format_rate(rate).as_str()) || s.ends_with(format_rate(rate).as_str())
995 );
996 }
997 }
998
999 #[test]
1000 fn pad_metric_is_identity_when_already_wide() {
1001 let long = "123456789012345"; // 15 > 14
1002 assert_eq!(pad_metric(long, METRIC_BPS_CHARS), long);
1003 }
1004
1005 #[test]
1006 fn metric_cell_px_scales_with_theme_caption() {
1007 let th = Theme::dark();
1008 let a = metric_cell_px(&th, METRIC_BPS_CHARS);
1009 let b = metric_cell_px(&th, METRIC_RATE_CHARS);
1010 assert!(a > b);
1011 assert!(a > 40.0);
1012 // Floor must cover full padded glyph run (chars × 0.72em + pad).
1013 let floor = th.type_scale.caption * 0.72 * METRIC_BPS_CHARS as f32;
1014 assert!(
1015 a >= floor,
1016 "metric_cell_px={a} must be >= glyph floor {floor}"
1017 );
1018 }
1019
1020 #[test]
1021 fn metric_bps_string_never_exceeds_char_budget() {
1022 // Ensures the padded formatter and char budget stay in sync so
1023 // metric_cell_px floors remain meaningful.
1024 for bps in [0.0, 1.0, 512.0, 1024.0 * 50.0, 1024.0 * 1024.0 * 9.9] {
1025 let s = metric_bps(bps);
1026 assert!(
1027 s.chars().count() <= METRIC_BPS_CHARS || s.chars().count() == METRIC_BPS_CHARS,
1028 "unexpected width for {s:?}"
1029 );
1030 assert_eq!(s.chars().count(), METRIC_BPS_CHARS);
1031 }
1032 }
1033
1034 #[test]
1035 fn default_min_col_positive() {
1036 let th = Theme::dark();
1037 assert!(default_min_col(&th) > 100.0);
1038 }
1039
1040 #[test]
1041 fn col_spec_resolves_metric_widths() {
1042 let th = Theme::dark();
1043 assert!(ColSpec::Flex.px(&th).is_none());
1044 assert_eq!(ColSpec::Fixed(120.0).px(&th), Some(120.0));
1045 let bps = ColSpec::MetricBps.px(&th).unwrap();
1046 let rate = ColSpec::MetricRate.px(&th).unwrap();
1047 assert!(bps > rate);
1048 assert!((bps - metric_cell_px(&th, METRIC_BPS_CHARS)).abs() < 0.01);
1049 }
1050
1051 #[test]
1052 fn grid_opts_page_is_not_striped_and_uses_lg_row_gap() {
1053 let th = Theme::dark();
1054 let page = GridOpts::page(&th);
1055 let table = GridOpts::table(&th);
1056 assert!(!page.striped);
1057 assert!(table.striped);
1058 assert!((page.spacing.y - th.spacing.lg).abs() < 0.01);
1059 assert!((table.spacing.y - 2.0).abs() < 0.01);
1060 }
1061
1062 #[test]
1063 fn page_shell_api_is_grid_only() {
1064 // Source-level contract: page_body / central_page take GridCtx, not free Ui.
1065 let layout = include_str!("layout.rs");
1066 // Signature block for page_body must include theme + GridCtx (not free Ui only).
1067 let start = layout
1068 .find("pub fn page_body(\n")
1069 .expect("page_body definition");
1070 let sig = &layout[start..start + 280];
1071 assert!(
1072 sig.contains("theme: &Theme"),
1073 "page_body must take theme for grid"
1074 );
1075 assert!(
1076 sig.contains("GridCtx"),
1077 "page_body must take GridCtx callback: {sig}"
1078 );
1079 assert!(
1080 !sig.contains("FnOnce(&mut Ui)"),
1081 "page_body must not accept free-form Ui: {sig}"
1082 );
1083 assert!(
1084 layout.contains("page_body(ui, theme, id, add)"),
1085 "central_page must route through grid page_body"
1086 );
1087 assert!(
1088 layout.contains("/// Full-width page section"),
1089 "GridCtx::section is the preferred page building block"
1090 );
1091 // Escape hatch exists but is not the app path.
1092 assert!(layout.contains("pub fn page_scroll("));
1093 }
1094
1095 #[test]
1096 fn distribute_col_max_never_exceeds_budget() {
1097 let gap = 12.0;
1098 let specs = vec![None, None, Some(100.0), Some(80.0)];
1099 for avail in [200.0_f32, 400.0, 800.0, 100.0] {
1100 let maxes = distribute_col_max(&specs, avail, gap);
1101 let gaps = gap * (specs.len() - 1) as f32;
1102 let sum: f32 = maxes.iter().sum();
1103 assert!(
1104 sum + gaps <= avail + 0.5,
1105 "sum={sum} gaps={gaps} avail={avail} maxes={maxes:?}"
1106 );
1107 assert_eq!(maxes.len(), specs.len());
1108 }
1109 }
1110
1111 #[test]
1112 fn distribute_col_max_scales_fixed_when_tight() {
1113 let specs = vec![Some(200.0), Some(200.0)];
1114 let maxes = distribute_col_max(&specs, 200.0, 0.0);
1115 assert!((maxes[0] + maxes[1] - 200.0).abs() < 0.01);
1116 assert!(maxes[0] < 200.0);
1117 }
1118
1119 #[test]
1120 fn distribute_all_flex_equal() {
1121 let specs = vec![None, None, None, None];
1122 let maxes = distribute_col_max(&specs, 400.0, 0.0);
1123 for w in &maxes {
1124 assert!((*w - 100.0).abs() < 0.01);
1125 }
1126 }
1127
1128 #[test]
1129 fn card_frame_chrome_x_is_margins_plus_stroke() {
1130 let th = Theme::dark();
1131 // md*2 + 1px stroke each side
1132 assert!((card_frame_chrome_x(&th) - (th.spacing.md * 2.0 + 2.0)).abs() < 0.01);
1133 assert!(card_frame_chrome_x(&th) > th.spacing.md);
1134 }
1135
1136 #[test]
1137 fn four_flex_cols_fit_viewport_budget() {
1138 // Same shape as the usage gauge row.
1139 let gap = 12.0;
1140 let avail = 700.0;
1141 let specs = vec![None, None, None, None];
1142 let maxes = distribute_col_max(&specs, avail, gap);
1143 let sum: f32 = maxes.iter().sum::<f32>() + gap * 3.0;
1144 assert!(sum <= avail + 0.01, "sum={sum} avail={avail}");
1145 for w in maxes {
1146 assert!(w > 50.0);
1147 }
1148 }
1149}