| Bring vidya in cfd3e36 nandi 19d ago | 1 | //! 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 | |
| 37 | use std::hash::Hash; |
| 38 | |
| 39 | use egui::{ |
| 40 | Align, FontId, Frame, Grid, InnerResponse, Layout, Margin, RichText, ScrollArea, Sense, Stroke, |
| 41 | Ui, Vec2, |
| 42 | }; |
| 43 | |
| 44 | use 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. |
| 51 | pub 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. |
| 56 | pub 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`]). |
| 62 | pub const METRIC_BPS_CHARS: usize = 14; |
| 63 | |
| 64 | /// Character width of a fixed monospace event-rate cell ([`metric_rate`]). |
| 65 | pub 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. |
| 71 | pub 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). |
| 82 | pub 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. |
| 92 | pub 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 ago | 100 | if i == 0 || v >= 100.0 { |
| Bring vidya in cfd3e36 nandi 19d ago | 101 | 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). |
| 110 | pub 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. |
| 115 | pub 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. |
| 130 | pub 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. |
| 137 | pub 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. |
| 146 | pub 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). |
| 157 | pub 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. |
| 170 | pub 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. |
| 184 | pub 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). |
| 196 | pub 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. |
| 221 | pub 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 ago | 226 | .stroke(Stroke::new(1.0_f32, theme.palette.border_soft)) |
| Bring vidya in cfd3e36 nandi 19d ago | 227 | .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). |
| 243 | pub 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. |
| 258 | pub 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. |
| 274 | pub 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. |
| 300 | pub 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. |
| 333 | pub 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. |
| 354 | pub 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. |
| 364 | pub 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. |
| 381 | pub 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. |
| 395 | pub 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)] |
| 437 | pub 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 | |
| 448 | impl 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. |
| 461 | pub 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. |
| 471 | pub 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`). |
| 522 | pub 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)] |
| 533 | pub 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 | |
| 540 | impl 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`]. |
| 563 | pub 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. |
| 574 | pub 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. |
| 617 | pub fn grid(ui: &mut Ui, theme: &Theme, id: impl Hash, add: impl FnOnce(&mut GridCtx<'_, '_>)) { |
| 618 | grid_cols(ui, theme, id, &[], add); |
| 619 | } |
| 620 | |
| 621 | impl<'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`]. |
| 658 | pub 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 | |
| 666 | impl<'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). |
| 798 | fn 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`. |
| 813 | pub 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. |
| 822 | pub 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)] |
| 842 | pub enum ColKind { |
| 843 | Flex, |
| 844 | Metric { width: f32 }, |
| 845 | } |
| 846 | |
| 847 | /// One column header + kind for [`data_table`]. |
| 848 | #[derive(Debug, Clone, Copy)] |
| 849 | pub 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. |
| 858 | pub 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). |
| 915 | pub 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. |
| 920 | pub 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`]). |
| 944 | pub 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)] |
| 951 | mod 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 | } |