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