GORBIE 0.11.1

GORBIE! Is a minimalist notebook library for Rust.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
use std::hash::Hash;
use std::ops::{Deref, DerefMut};

use eframe::egui;

use crate::state;

/// Number of columns in the notebook grid.
pub const GRID_COLUMNS: u32 = 12;

/// Gutter width between grid columns, in pixels.
pub const GRID_GUTTER: f32 = 12.0;

/// Vertical module height for the modular grid, in pixels.
///
/// Row tops snap to multiples of this value (relative to the grid origin),
/// producing uniform row gaps and field-aligned content. Matches the
/// horizontal gutter so the grid is truly modular (square gutters).
pub const GRID_ROW_MODULE: f32 = 12.0;

/// Edge padding of the grid, in pixels.
///
/// The grid has one gutter-width of padding on each side, so the content
/// area is `768 - 2 * GRID_EDGE_PAD = 744px`.
pub const GRID_EDGE_PAD: f32 = GRID_GUTTER;

/// Width of a single grid column, in pixels.
///
/// Derived from the notebook column width (768px), edge padding, grid
/// column count, and gutter:
/// `(768 - 2 * 12 - (GRID_COLUMNS - 1) * GRID_GUTTER) / GRID_COLUMNS = 51`.
pub const GRID_COL_WIDTH: f32 = 51.0;

/// Pixel width of a span of `n` grid columns (including internal gutters).
pub const fn span_width(n: u32) -> f32 {
    n as f32 * GRID_COL_WIDTH + n.saturating_sub(1) as f32 * GRID_GUTTER
}

/// Card-scoped context that threads the state store through all nested layouts.
///
/// `CardCtx` wraps an `egui::Ui` and provides layout methods that pass
/// `&mut CardCtx` (rather than raw `&mut egui::Ui`) to closures, so
/// [`StateId::read`](crate::state::StateId::read) and
/// [`StateId::read_mut`](crate::state::StateId::read_mut) work at any nesting
/// depth.
///
/// All layout methods shadow their `egui::Ui` counterparts. Because owned
/// methods take priority over `Deref` methods, calling `ctx.horizontal(…)`
/// always invokes the GORBIE version automatically.
pub struct CardCtx<'a> {
    ui: &'a mut egui::Ui,
    store: &'a state::StateStore,
}

impl<'a> CardCtx<'a> {
    pub(crate) fn new(ui: &'a mut egui::Ui, store: &'a state::StateStore) -> Self {
        Self { ui, store }
    }

    /// Returns a reference to the backing state store.
    pub fn store(&self) -> &state::StateStore {
        self.store
    }

    /// Access the underlying `egui::Ui` directly.
    ///
    /// Prefer the `CardCtx` layout methods — this escape hatch is for
    /// egui APIs that `CardCtx` does not yet wrap.
    pub fn ui_mut(&mut self) -> &mut egui::Ui {
        self.ui
    }

    // ── Grid-aware widgets ──────────────────────────────────────────

    /// Add a GORBIE button.
    ///
    /// This shadows `egui::Ui::button` via method resolution, so
    /// `ctx.button("text")` always produces a GORBIE button.
    pub fn button(&mut self, text: impl Into<egui::WidgetText>) -> egui::Response {
        self.ui.add(crate::widgets::Button::new(text))
    }

    /// GORBIE-styled toggle button (checkbox-like, latching on/off).
    pub fn toggle(&mut self, on: &mut bool, text: impl Into<egui::WidgetText>) -> egui::Response {
        self.ui.add(crate::widgets::Button::new(text).on(on))
    }

    /// GORBIE-styled slider.
    pub fn slider<Num: egui::emath::Numeric>(
        &mut self,
        value: &mut Num,
        range: std::ops::RangeInclusive<Num>,
    ) -> egui::Response {
        self.ui.add(crate::widgets::Slider::new(value, range))
    }

    /// GORBIE-styled number field (LCD-style drag/edit).
    pub fn number<Num: egui::emath::Numeric>(
        &mut self,
        value: &mut Num,
    ) -> egui::Response {
        self.ui.add(crate::widgets::NumberField::new(value))
    }

    /// GORBIE-styled single-line text field (LCD-style).
    pub fn text_field(&mut self, text: &mut dyn egui::TextBuffer) -> egui::Response {
        self.ui.add(crate::widgets::TextField::singleline(text))
    }

    /// GORBIE-styled progress bar.
    pub fn progress(&mut self, fraction: f32) -> egui::Response {
        self.ui.add(crate::widgets::ProgressBar::new(fraction))
    }

    /// Render markdown with GORBIE styling and syntax themes.
    pub fn markdown(&mut self, text: &str) {
        crate::widgets::markdown(self.ui, text);
    }

    /// Render a full Typst document string with GORBIE styling (RAL colors,
    /// IosevkaGorbie font, page width matching the card, blue links).
    #[cfg(feature = "typst")]
    pub fn typst(&mut self, source: &str) {
        crate::widgets::typst_widget::typst_with_preamble(self.ui, source);
    }

    /// Render an inline math expression via Typst: `$<expr>$`.
    #[cfg(feature = "typst")]
    pub fn typst_math_inline(&mut self, expr: &str) {
        crate::widgets::typst_widget::typst_math_inline(self.ui, expr);
    }

    /// Render a display-mode math expression via Typst.
    #[cfg(feature = "typst")]
    pub fn typst_math_display(&mut self, expr: &str) {
        crate::widgets::typst_widget::typst_math_display(self.ui, expr);
    }

    // ── Section ──────────────────────────────────────────────────────

    /// Collapsible section with a bold colored header (open by default).
    ///
    /// The header color is deterministically assigned from the title via
    /// [`colorhash::ral_categorical`], like colored divider tabs in stationery.
    /// Click to expand/collapse.
    ///
    /// ```ignore
    /// ctx.section("Parameters", |ctx| {
    ///     ctx.text_field(&mut name);
    ///     ctx.number(&mut value);
    /// });
    /// ```
    pub fn section(
        &mut self,
        title: &str,
        add_contents: impl FnOnce(&mut CardCtx<'_>),
    ) {
        self.section_inner(title, true, add_contents);
    }

    /// Collapsible section that starts collapsed (closed by default).
    ///
    /// Identical to [`section`](Self::section) except the initial state is
    /// collapsed. Once the user clicks to expand, their preference is
    /// persisted just like a regular section.
    ///
    /// ```ignore
    /// ctx.section_collapsed("Advanced", |ctx| {
    ///     ctx.label("Hidden until clicked.");
    /// });
    /// ```
    pub fn section_collapsed(
        &mut self,
        title: &str,
        add_contents: impl FnOnce(&mut CardCtx<'_>),
    ) {
        self.section_inner(title, false, add_contents);
    }

    /// Shared implementation for [`section`](Self::section) and
    /// [`section_collapsed`](Self::section_collapsed).
    fn section_inner(
        &mut self,
        title: &str,
        default_open: bool,
        add_contents: impl FnOnce(&mut CardCtx<'_>),
    ) {
        use crate::themes::colorhash;

        let id = self.ui.make_persistent_id(title);
        let mut open = self.ui.ctx().data_mut(|d| {
            *d.get_persisted_mut_or(id, default_open)
        });

        let color = colorhash::ral_categorical(title.as_bytes());
        let text_color = colorhash::text_color_on(color);

        // Zero spacing so header and folds sit flush.
        let prev_spacing = self.ui.spacing().item_spacing.y;
        self.ui.spacing_mut().item_spacing.y = 0.0;

        // Header bar: full width, colored background, clickable.
        let header_height = GRID_ROW_MODULE * 5.0;
        let available_width = self.ui.available_width();
        let (header_rect, header_response) = self.ui.allocate_exact_size(
            egui::vec2(available_width, header_height),
            egui::Sense::click(),
        );

        if header_response.clicked() {
            open = !open;
            self.ui.ctx().data_mut(|d| d.insert_persisted(id, open));
        }

        // Paint the header.
        let painter = self.ui.painter();
        painter.rect_filled(header_rect, 0.0, color);

        // Title text — large, centered vertically, left-aligned with padding.
        let text_pos = egui::pos2(
            header_rect.left() + GRID_EDGE_PAD,
            header_rect.bottom() - GRID_ROW_MODULE,
        );
        painter.text(
            text_pos,
            egui::Align2::LEFT_BOTTOM,
            title,
            egui::FontId::proportional(header_height * 0.45),
            text_color,
        );

        if header_response.hovered() {
            self.ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
        }

        self.ui.spacing_mut().item_spacing.y = prev_spacing;
        if open {
            add_contents(self);
        }
    }

    // ── Layout wrappers ──────────────────────────────────────────────

    /// Apply padding around the contents while preserving `CardCtx`.
    ///
    /// This is the `CardCtx`-aware equivalent of [`cards::with_padding`].
    pub fn with_padding<R>(
        &mut self,
        padding: impl Into<egui::Margin>,
        add_contents: impl FnOnce(&mut CardCtx<'_>) -> R,
    ) -> egui::InnerResponse<R> {
        let store = self.store;
        egui::Frame::new()
            .inner_margin(padding)
            .show(self.ui, |ui| {
                ui.set_width(ui.available_width());
                let mut ctx = CardCtx::new(ui, store);
                add_contents(&mut ctx)
            })
    }

    /// Horizontal layout, passing `CardCtx` instead of raw `Ui`.
    pub fn horizontal<R>(
        &mut self,
        add_contents: impl FnOnce(&mut CardCtx<'_>) -> R,
    ) -> egui::InnerResponse<R> {
        let store = self.store;
        self.ui.horizontal(|ui| {
            let mut ctx = CardCtx::new(ui, store);
            add_contents(&mut ctx)
        })
    }

    /// Horizontal-wrapped layout, passing `CardCtx` instead of raw `Ui`.
    pub fn horizontal_wrapped<R>(
        &mut self,
        add_contents: impl FnOnce(&mut CardCtx<'_>) -> R,
    ) -> egui::InnerResponse<R> {
        let store = self.store;
        self.ui.horizontal_wrapped(|ui| {
            let mut ctx = CardCtx::new(ui, store);
            add_contents(&mut ctx)
        })
    }

    /// Vertical layout, passing `CardCtx` instead of raw `Ui`.
    pub fn vertical<R>(
        &mut self,
        add_contents: impl FnOnce(&mut CardCtx<'_>) -> R,
    ) -> egui::InnerResponse<R> {
        let store = self.store;
        self.ui.vertical(|ui| {
            let mut ctx = CardCtx::new(ui, store);
            add_contents(&mut ctx)
        })
    }

    /// Custom `egui::Layout`, passing `CardCtx` instead of raw `Ui`.
    pub fn with_layout<R>(
        &mut self,
        layout: egui::Layout,
        add_contents: impl FnOnce(&mut CardCtx<'_>) -> R,
    ) -> egui::InnerResponse<R> {
        let store = self.store;
        self.ui.with_layout(layout, |ui| {
            let mut ctx = CardCtx::new(ui, store);
            add_contents(&mut ctx)
        })
    }

    /// Push a unique id salt, passing `CardCtx` instead of raw `Ui`.
    pub fn push_id<R>(
        &mut self,
        id_salt: impl Hash,
        add_contents: impl FnOnce(&mut CardCtx<'_>) -> R,
    ) -> egui::InnerResponse<R> {
        let store = self.store;
        self.ui.push_id(id_salt, |ui| {
            let mut ctx = CardCtx::new(ui, store);
            add_contents(&mut ctx)
        })
    }

    /// Collapsing header, passing `CardCtx` instead of raw `Ui`.
    pub fn collapsing<R>(
        &mut self,
        heading: impl Into<egui::WidgetText>,
        add_contents: impl FnOnce(&mut CardCtx<'_>) -> R,
    ) -> egui::CollapsingResponse<R> {
        let store = self.store;
        self.ui.collapsing(heading, |ui| {
            let mut ctx = CardCtx::new(ui, store);
            add_contents(&mut ctx)
        })
    }

    /// New visual scope, passing `CardCtx` instead of raw `Ui`.
    pub fn scope<R>(
        &mut self,
        add_contents: impl FnOnce(&mut CardCtx<'_>) -> R,
    ) -> egui::InnerResponse<R> {
        let store = self.store;
        self.ui.scope(|ui| {
            let mut ctx = CardCtx::new(ui, store);
            add_contents(&mut ctx)
        })
    }

    /// Indented region, passing `CardCtx` instead of raw `Ui`.
    pub fn indent<R>(
        &mut self,
        id_salt: impl Hash,
        add_contents: impl FnOnce(&mut CardCtx<'_>) -> R,
    ) -> egui::InnerResponse<R> {
        let store = self.store;
        self.ui.indent(id_salt, |ui| {
            let mut ctx = CardCtx::new(ui, store);
            add_contents(&mut ctx)
        })
    }

    /// Visual group with frame, passing `CardCtx` instead of raw `Ui`.
    pub fn group<R>(
        &mut self,
        add_contents: impl FnOnce(&mut CardCtx<'_>) -> R,
    ) -> egui::InnerResponse<R> {
        let store = self.store;
        self.ui.group(|ui| {
            let mut ctx = CardCtx::new(ui, store);
            add_contents(&mut ctx)
        })
    }

    // ── Grid layout ──────────────────────────────────────────────────

    /// Place elements on the 12-column grid.
    ///
    /// The closure receives a [`Grid`] with [`place`](Grid::place) and
    /// [`skip`](Grid::skip) methods. Elements flow left-to-right and
    /// wrap to the next row automatically when spans would exceed 12.
    ///
    /// Pixel widths are derived entirely from constants — same spans
    /// produce identical widths in every card (coordination-free).
    ///
    /// ```ignore
    /// ctx.grid(|g| {
    ///     g.place(12, |ctx| { ctx.heading("Title"); });     // full width
    ///     g.place(3, |ctx| { ctx.paragraph("text"); });     // 3 cols
    ///     g.place(9, |ctx| { ctx.image(path); });           // fills row
    ///     g.place(6, |ctx| { chart(ctx); });                // next row
    ///     g.skip(6);                                        // furniture
    /// });
    /// ```
    pub fn grid(&mut self, build: impl FnOnce(&mut Grid<'_, '_>)) {
        let store = self.store;
        let left = self.ui.cursor().min.x + GRID_EDGE_PAD;
        let top = self.ui.cursor().min.y + GRID_EDGE_PAD;
        let mut g = Grid {
            ui: self.ui,
            store,
            left,
            cursor: 0,
            row_top: top,
            row_max_bottom: top,
        };
        build(&mut g);
        // Advance the parent Ui past all rows we painted.
        g.finish();
    }
}

/// Response from [`CardCtx::float`].
pub struct FloatResponse {
    /// `true` if the user clicked the drag handle to dismiss the float.
    pub closed: bool,
}

impl<'a> CardCtx<'a> {
    /// Spawn a floating card at the current mouse position (on first show).
    ///
    /// The float renders as a GORBIE card with drag handle and shadow, hovering
    /// above the notebook. Clicking the drag handle dismisses it (returns
    /// `closed = true`). Dragging the handle repositions it.
    ///
    /// Use [`push_id`](Self::push_id) to give each float a unique identity
    /// when creating multiple floats in a loop.
    ///
    /// ```ignore
    /// for page in &state.pages {
    ///     ctx.push_id(page.id, |ctx| {
    ///         let resp = ctx.float(|ctx| {
    ///             ctx.markdown(&page.content);
    ///         });
    ///         if resp.closed {
    ///             close_page(page.id);
    ///         }
    ///     });
    /// }
    /// ```
    #[track_caller]
    pub fn float(
        &mut self,
        add_contents: impl FnOnce(&mut CardCtx<'_>),
    ) -> FloatResponse {
        let float_id = self.ui.id().with("gorbie_float");
        let initial_pos = self.ui.ctx().input(|i| {
            i.pointer.hover_pos().unwrap_or(egui::pos2(100.0, 100.0))
        });

        let card_width = crate::NOTEBOOK_COLUMN_WIDTH;
        let store = self.store;
        let mut add_contents = Some(add_contents);

        let resp = crate::floating::show_floating_card(
            self.ui.ctx(),
            float_id,
            initial_pos,
            card_width,
            0.0,
            store,
            "Close",
            &mut |ctx| {
                if let Some(f) = add_contents.take() {
                    f(ctx);
                }
            },
        );

        FloatResponse { closed: resp.handle_clicked }
    }
}

impl<'a> state::StateAccess for CardCtx<'a> {
    fn store(&self) -> &state::StateStore {
        self.store
    }
}

impl<'a> Deref for CardCtx<'a> {
    type Target = egui::Ui;

    fn deref(&self) -> &Self::Target {
        self.ui
    }
}

impl<'a> DerefMut for CardCtx<'a> {
    fn deref_mut(&mut self) -> &mut Self::Target {
        self.ui
    }
}

// ── Grid ─────────────────────────────────────────────────────────────

/// Flat grid layout for a card.
///
/// Created by [`CardCtx::grid`]. Call [`place`](Self::place) to add
/// content spanning grid columns, [`skip`](Self::skip) to add furniture.
///
/// Elements flow left-to-right. When a placement would exceed 12
/// columns, the current row is finalized and a new one begins.
///
/// Each cell is rendered immediately into a precisely positioned rect —
/// no closure buffering, no lifetime constraints beyond the current call.
pub struct Grid<'ui, 'store> {
    ui: &'ui mut egui::Ui,
    store: &'store state::StateStore,
    /// Left edge of the grid (pixel x).
    left: f32,
    /// Current column within the row (0..GRID_COLUMNS).
    cursor: u32,
    /// Top of the current row (pixel y).
    row_top: f32,
    /// Tallest cell bottom in the current row.
    row_max_bottom: f32,
}

impl<'ui, 'store> Grid<'ui, 'store> {
    // ── Named spans ────────────────────────────────────────────────
    //
    // These are the clean divisions of the 12-column grid.
    // Use these instead of raw column counts to stay on the grid.

    /// Full-width cell (12 columns, 744px).
    pub fn full(&mut self, f: impl FnOnce(&mut CardCtx<'_>)) { self.place(12, f); }

    /// Three-quarter cell (9 columns, 555px).
    pub fn three_quarters(&mut self, f: impl FnOnce(&mut CardCtx<'_>)) { self.place(9, f); }

    /// Two-thirds cell (8 columns, 492px).
    pub fn two_thirds(&mut self, f: impl FnOnce(&mut CardCtx<'_>)) { self.place(8, f); }

    /// Half-width cell (6 columns, 366px).
    pub fn half(&mut self, f: impl FnOnce(&mut CardCtx<'_>)) { self.place(6, f); }

    /// One-third cell (4 columns, 240px).
    pub fn third(&mut self, f: impl FnOnce(&mut CardCtx<'_>)) { self.place(4, f); }

    /// Quarter-width cell (3 columns, 177px).
    pub fn quarter(&mut self, f: impl FnOnce(&mut CardCtx<'_>)) { self.place(3, f); }

    // ── Named skips ─────────────────────────────────────────────────

    /// Skip a half-width gap (6 columns).
    pub fn skip_half(&mut self) { self.skip(6); }

    /// Skip a third-width gap (4 columns).
    pub fn skip_third(&mut self) { self.skip(4); }

    /// Skip a quarter-width gap (3 columns).
    pub fn skip_quarter(&mut self) { self.skip(3); }

    // ── Low-level ──────────────────────────────────────────────────

    /// Place content spanning an arbitrary number of grid columns.
    ///
    /// Prefer the named methods ([`full`](Self::full), [`half`](Self::half),
    /// [`third`](Self::third), [`quarter`](Self::quarter),
    /// [`two_thirds`](Self::two_thirds), [`three_quarters`](Self::three_quarters))
    /// to stay on the grid. This escape hatch exists for unusual layouts.
    pub fn place(
        &mut self,
        span: u32,
        add_contents: impl FnOnce(&mut CardCtx<'_>),
    ) {
        assert!(
            span > 0 && span <= GRID_COLUMNS,
            "span must be 1..={GRID_COLUMNS}, got {span}"
        );

        // Advance to new row if the previous row was completed or
        // this span doesn't fit.
        if self.needs_advance(span) {
            self.new_row();
        }

        let x = self.left + col_x(self.cursor);
        let width = span_width(span);

        let cell_rect = egui::Rect::from_min_size(
            egui::pos2(x, self.row_top),
            egui::vec2(width, f32::MAX),
        );

        let store = self.store;
        // Use new_child (not scope_builder) so cells don't advance the
        // parent cursor — finish() handles that in one shot.
        let mut child = self.ui.new_child(
            egui::UiBuilder::new().max_rect(cell_rect),
        );
        child.set_width(width);
        let mut ctx = CardCtx::new(&mut child, store);
        add_contents(&mut ctx);

        let used_bottom = child.min_rect().bottom();
        if used_bottom > self.row_max_bottom {
            self.row_max_bottom = used_bottom;
        }

        self.cursor += span;
        if self.cursor >= GRID_COLUMNS {
            self.cursor = 0;
        }
    }

    /// Skip columns (typographic furniture / blank space).
    ///
    /// Common patterns: `skip_quarter()`, `skip_third()`, `skip_half()`.
    pub fn skip(&mut self, span: u32) {
        assert!(
            span > 0 && span <= GRID_COLUMNS,
            "skip must be 1..={GRID_COLUMNS}, got {span}"
        );

        if self.needs_advance(span) {
            self.new_row();
        }

        self.cursor += span;
        if self.cursor >= GRID_COLUMNS {
            self.cursor = 0;
        }
    }

    /// Check if we need to advance to a new row before placing `span` columns.
    fn needs_advance(&self, span: u32) -> bool {
        // Previous row completed (cursor reset to 0) with content above.
        let pending_complete = self.cursor == 0 && self.row_max_bottom > self.row_top;
        // Span doesn't fit current row.
        let overflow = self.cursor > 0 && self.cursor + span > GRID_COLUMNS;
        pending_complete || overflow
    }

    /// Start a new row with one module (12px) of vertical gutter.
    fn new_row(&mut self) {
        self.row_top = self.row_max_bottom + GRID_ROW_MODULE;
        self.row_max_bottom = self.row_top;
        self.cursor = 0;
    }

    /// Advance the parent Ui's cursor past all grid content.
    fn finish(&mut self) {
        // Don't snap the last row — just add bottom padding.
        // Inter-row snapping is handled by new_row().
        let total_height = (self.row_max_bottom + GRID_EDGE_PAD - self.ui.cursor().min.y).max(0.0);
        if total_height > 0.0 {
            self.ui.allocate_space(egui::vec2(
                2.0 * GRID_EDGE_PAD + span_width(GRID_COLUMNS),
                total_height,
            ));
        }
    }
}

/// Pixel x-offset of grid column `col` from the left edge.
const fn col_x(col: u32) -> f32 {
    col as f32 * (GRID_COL_WIDTH + GRID_GUTTER)
}