Skip to main content

blockworx/shell/
flex.rs

1//! Flexbox for the shell: the vocabulary every surface lays its contents out
2//! with, and the inspector that says where the boxes actually went.
3//!
4//! `docs/taffy-layout-playbook.md` §6 and §8. [`glass`](super::glass) places a
5//! box against the frame; this lays out what is inside it. The vocabulary is
6//! shared so no two surfaces can disagree about what "the growing middle"
7//! means — the whole reason the engine is worth having beyond fidelity.
8//!
9//! Every node is created through [`Named`] and carries a `&'static str` name.
10//! The names are what the overlay and [`dump`] print; without them a layout
11//! bug is a wall of anonymous rectangles.
12#![allow(
13    dead_code,
14    reason = "P1 delivers the vocabulary before P2-P4 consume it; the allow \
15              goes when the first surface migrates"
16)]
17
18use egui_taffy::taffy;
19use egui_taffy::taffy::prelude::{auto, length, percent};
20use egui_taffy::{TuiBuilderLogic, TuiWidget, tui};
21
22use crate::bounded::{Bounded, Bounds};
23
24pub use egui_taffy::Tui;
25
26/// Passes egui must be allowed per frame for the engine to work.
27///
28/// `egui_taffy` sizes a node from the *previous* pass and calls
29/// `request_discard` while the tree is dirty (playbook §4.1), so a
30/// single-pass frame would paint the layout it was about to correct.
31pub const MAX_PASSES: core::num::NonZeroUsize = core::num::NonZeroUsize::MIN.saturating_add(2);
32
33pub struct PointBounds;
34
35impl Bounds for PointBounds {
36    const MIN: f32 = 0.0;
37    const MAX: f32 = f32::INFINITY;
38}
39
40/// A length in egui points — a gap, a padding, a fixed side. Non-negative:
41/// flexbox has no such thing as a gap below zero, so the type refuses one
42/// rather than every constructor guarding against it.
43pub type Points = Bounded<PointBounds>;
44
45impl Points {
46    pub const ZERO: Self = Self::new(0.0);
47}
48
49/// How much of the [`egui::Ui`] a [`surface`] root claims. `egui_taffy`
50/// defaults to min-content on both axes, so a root that fills has to say so —
51/// and the same answer decides whether the root is `width: 100%`.
52#[derive(Clone, Copy, PartialEq, Eq, Debug)]
53pub enum Room {
54    Width,
55    Height,
56    Both,
57}
58
59impl Room {
60    fn spans_width(self) -> bool {
61        matches!(self, Room::Width | Room::Both)
62    }
63
64    fn spans_height(self) -> bool {
65        matches!(self, Room::Height | Room::Both)
66    }
67}
68
69/// One box's flex specification, named for what it means in the shell rather
70/// than for the CSS it compiles to. Built by the constructors below and
71/// narrowed with the combinators, so the two sides of every split are named
72/// rather than measured.
73#[derive(Clone, Debug, Default, PartialEq)]
74pub struct Flex(taffy::Style);
75
76/// `display:flex; align-items:center; gap:<gap>` — the prototype's `.row`, and
77/// the default for anything laid out left to right.
78pub fn row(gap: Points) -> Flex {
79    line(taffy::FlexDirection::Row, taffy::AlignItems::Center, gap)
80}
81
82/// `.row.rev`: the same row, aligned to the top, for a body that is taller
83/// than the glyph beside it.
84pub fn row_start(gap: Points) -> Flex {
85    line(taffy::FlexDirection::Row, taffy::AlignItems::FlexStart, gap)
86}
87
88/// `flex-direction:column` with a gap.
89pub fn stack(gap: Points) -> Flex {
90    line(
91        taffy::FlexDirection::Column,
92        taffy::AlignItems::Stretch,
93        gap,
94    )
95}
96
97/// `.tags`: a row that flows onto the next line rather than shrinking its
98/// chips.
99pub fn wrap(gap: Points) -> Flex {
100    let mut flex = line(taffy::FlexDirection::Row, taffy::AlignItems::FlexStart, gap);
101    flex.0.flex_wrap = taffy::FlexWrap::Wrap;
102    flex
103}
104
105/// `flex:1; min-width:0` — the middle that takes what the sides leave, and
106/// shrinks below its content rather than pushing them off the end.
107pub fn grow() -> Flex {
108    Flex::default().grow()
109}
110
111/// `flex:none` — a lead glyph, a trailing readout, a cluster of controls.
112pub fn fixed() -> Flex {
113    Flex::default().fixed()
114}
115
116/// `.topbar .center`: a growing middle whose contents stay centred in it
117/// however much room the sides leave. `egui::containers::Sides` cannot say
118/// this, and it is the clearest single case for the engine.
119pub fn centre(gap: Points) -> Flex {
120    row(gap).grow().centred()
121}
122
123/// `.tool`, `.av`: a fixed square with one thing centred in it.
124pub fn cell(side: Points) -> Flex {
125    row(Points::ZERO).fixed().square(side).centred()
126}
127
128/// `.meta`: a fixed column whose lines are flush right.
129pub fn trailing(gap: Points) -> Flex {
130    let mut flex = stack(gap).fixed();
131    flex.0.align_items = Some(taffy::AlignItems::FlexEnd);
132    flex
133}
134
135/// `.sheetbody`: the growing column that scrolls its own overflow. Scrolling
136/// is a node property here, never an `egui::ScrollArea` wrapped round the
137/// tree (playbook §4.4).
138pub fn scrolling() -> Flex {
139    stack(Points::ZERO).grow().scrolls()
140}
141
142fn line(direction: taffy::FlexDirection, align: taffy::AlignItems, gap: Points) -> Flex {
143    Flex(taffy::Style {
144        display: taffy::Display::Flex,
145        flex_direction: direction,
146        align_items: Some(align),
147        gap: length(f32::from(gap)),
148        ..Default::default()
149    })
150}
151
152impl Flex {
153    /// `flex:1; min-width:0`.
154    pub fn grow(mut self) -> Self {
155        self.0.flex_grow = 1.0;
156        self.0.flex_shrink = 1.0;
157        self.0.flex_basis = length(0.0);
158        self.0.min_size.width = length(0.0);
159        self
160    }
161
162    /// `flex:none`.
163    pub fn fixed(mut self) -> Self {
164        self.0.flex_grow = 0.0;
165        self.0.flex_shrink = 0.0;
166        self.0.flex_basis = auto();
167        self
168    }
169
170    /// `justify-content:center; align-items:center` — the prototype's
171    /// `place-items:center`, which for a single child is the same box.
172    pub fn centred(mut self) -> Self {
173        self.0.justify_content = Some(taffy::JustifyContent::Center);
174        self.0.align_items = Some(taffy::AlignItems::Center);
175        self
176    }
177
178    pub fn min_height(mut self, points: Points) -> Self {
179        self.0.min_size.height = length(f32::from(points));
180        self
181    }
182
183    pub fn width(mut self, points: Points) -> Self {
184        self.0.size.width = length(f32::from(points));
185        self
186    }
187
188    pub fn height(mut self, points: Points) -> Self {
189        self.0.size.height = length(f32::from(points));
190        self
191    }
192
193    pub fn square(self, side: Points) -> Self {
194        self.width(side).height(side)
195    }
196
197    pub fn pad(mut self, points: Points) -> Self {
198        self.0.padding = length(f32::from(points));
199        self
200    }
201
202    /// `overflow-y:auto`, which `egui_taffy` builds the scroll area for. The
203    /// cross axis clips: a column that scrolls sideways as well is a layout
204    /// bug wearing a scrollbar.
205    pub fn scrolls(mut self) -> Self {
206        self.0.overflow = taffy::Point {
207            x: taffy::Overflow::Clip,
208            y: taffy::Overflow::Scroll,
209        };
210        self
211    }
212
213    fn spanning(mut self, room: Room) -> Self {
214        if room.spans_width() {
215            self.0.size.width = percent(1.0);
216        }
217        if room.spans_height() {
218            self.0.size.height = percent(1.0);
219        }
220        self
221    }
222
223    pub fn style(&self) -> &taffy::Style {
224        &self.0
225    }
226}
227
228impl From<Flex> for taffy::Style {
229    fn from(flex: Flex) -> taffy::Style {
230        flex.0
231    }
232}
233
234/// A flexbox root over `ui`, filling as much of it as `room` says.
235///
236/// `name` is both the inspector's label and the root's `egui::Id`, so two live
237/// roots in one frame must not share one — duplicate root ids *panic*
238/// (playbook §4). Every chrome piece is its own `egui::Area`, so one name per
239/// piece is enough.
240pub fn surface<T>(
241    ui: &mut egui::Ui,
242    name: &'static str,
243    room: Room,
244    style: Flex,
245    add: impl FnOnce(&mut Tui) -> T,
246) -> T {
247    let _span = tracing::info_span!("taffy_layout", surface = name).entered();
248    let root = tui(ui, egui::Id::new(name));
249    let root = match room {
250        Room::Width => root.reserve_available_width(),
251        Room::Height => root.reserve_available_height(),
252        Room::Both => root.reserve_available_space(),
253    };
254    root.style(style.spanning(room).into())
255        .show(|tui| recorded(name, tui, add))
256}
257
258/// The shell's node constructors: everything laid out through this module is
259/// named, so the inspector has something to say.
260pub trait Named<'r>: TuiBuilderLogic<'r> {
261    /// A box that holds other boxes.
262    fn node<T>(self, name: &'static str, style: Flex, add: impl FnOnce(&mut Tui) -> T) -> T {
263        self.style(style.into()).add(|tui| recorded(name, tui, add))
264    }
265
266    /// A box that answers a click as a whole — a list row, where the target is
267    /// the row and not the words in it.
268    fn pressable<T>(
269        self,
270        name: &'static str,
271        style: Flex,
272        add: impl FnOnce(&mut Tui) -> T,
273    ) -> egui_taffy::TuiInnerResponse<T> {
274        self.style(style.into())
275            .clickable(|tui| recorded(name, tui, add))
276    }
277
278    /// A leaf drawn with an ordinary egui closure. Its size is what the
279    /// closure allocated, so this is the wrong door for text that wraps —
280    /// [`Named::text`] is that one.
281    ///
282    /// The rect the inspector gets is the box inside the node's padding and
283    /// border, where a container's is the box outside them.
284    fn leaf<T>(self, name: &'static str, style: Flex, draw: impl FnOnce(&mut egui::Ui) -> T) -> T {
285        self.style(style.into()).ui(|ui| {
286            record(ui.ctx(), name, ui.max_rect());
287            draw(ui)
288        })
289    }
290
291    /// A widget the crate measures itself. The recorded rect is the widget's
292    /// own, not the node's box: a leaf gives no closure to read the box from,
293    /// and reaching into the layout tree deadlocks it.
294    fn widget<W>(self, name: &'static str, style: Flex, widget: W) -> egui::Response
295    where
296        W: TuiWidget<Response = egui::Response>,
297    {
298        let response = self.style(style.into()).ui_add(widget);
299        record(&response.ctx, name, response.rect);
300        response
301    }
302
303    /// Text that wraps at the width its node was given.
304    ///
305    /// The only correct door for it: a hand-rolled `ui` closure reports
306    /// `ui.min_size()`, which for wrapping text is the narrowest it can
307    /// possibly be — one letter per line — while the crate's own `Label`
308    /// measurement asks the galley (playbook §12, 2026-09-08).
309    fn text(
310        self,
311        name: &'static str,
312        style: Flex,
313        text: impl Into<egui::WidgetText>,
314    ) -> egui::Response {
315        self.widget(name, style, egui::Label::new(text).wrap())
316    }
317}
318
319impl<'r, B: TuiBuilderLogic<'r>> Named<'r> for B {}
320
321/// Enter `name` at this node's box, run its contents, leave again. One place,
322/// so a root, a container and a click target cannot disagree about what the
323/// inspector is told.
324fn recorded<T>(name: &'static str, tui: &mut Tui, add: impl FnOnce(&mut Tui) -> T) -> T {
325    let ctx = tui.egui_ctx().clone();
326    enter(&ctx, name, tui.taffy_container().full_container());
327    note_overflow(
328        &ctx,
329        name,
330        tui.taffy_container(),
331        tui.current_style().overflow,
332    );
333    let inner = add(tui);
334    leave(&ctx);
335    inner
336}
337
338/// One node as this pass laid it out.
339#[derive(Clone, Copy, Debug, PartialEq)]
340pub struct Node {
341    pub name: &'static str,
342    /// Nesting under the [`surface`] root, which is zero.
343    pub depth: usize,
344    pub rect: egui::Rect,
345}
346
347/// What the last pass laid out, in the order the tree was walked.
348pub fn nodes(ctx: &egui::Context) -> Vec<Node> {
349    read(ctx, |recording| recording.nodes.clone())
350}
351
352/// The computed tree as indented text: name, where it landed, how big it is.
353/// The console half of §8 — an edit-and-re-run loop, and what makes a layout
354/// bug reportable.
355pub fn dump(ctx: &egui::Context) -> String {
356    use std::fmt::Write as _;
357
358    let (nodes, overflowing) = read(ctx, |recording| {
359        (recording.nodes.clone(), recording.overflowing.clone())
360    });
361    let mut out = String::new();
362    for node in nodes {
363        let rect = node.rect;
364        let flag = if overflowing.contains(node.name) {
365            "  OVERFLOWS"
366        } else {
367            ""
368        };
369        let _ = writeln!(
370            out,
371            "{:indent$}{} at ({:.0}, {:.0}) {:.0}x{:.0}{flag}",
372            "",
373            node.name,
374            rect.left(),
375            rect.top(),
376            rect.width(),
377            rect.height(),
378            indent = node.depth * 2,
379        );
380    }
381    out
382}
383
384/// Every node whose content has outgrown its box since the app started.
385pub fn overflowing(ctx: &egui::Context) -> Vec<&'static str> {
386    read(ctx, |recording| {
387        recording.overflowing.iter().copied().collect()
388    })
389}
390
391/// The layout overlay and its console chord (§8.1, §8.4): each node's computed
392/// rect as a one-pixel outline tinted by depth, with the node's name over it.
393#[cfg(feature = "ui_debug")]
394pub fn inspect(ctx: &egui::Context, theme: &crate::theme::Theme) {
395    use crate::theme::Role;
396
397    const BY_DEPTH: [Role; 3] = [Role::LayoutBox0, Role::LayoutBox1, Role::LayoutBox2];
398    let dump_chord = egui::KeyboardShortcut::new(
399        egui::Modifiers::COMMAND | egui::Modifiers::SHIFT,
400        egui::Key::L,
401    );
402    if ctx.input_mut(|input| input.consume_shortcut(&dump_chord)) {
403        println!("{}", dump(ctx));
404    }
405
406    let painter = ctx.layer_painter(egui::LayerId::new(
407        egui::Order::Debug,
408        egui::Id::new("flex_inspector"),
409    ));
410    for node in nodes(ctx) {
411        if !node.rect.is_positive() {
412            continue;
413        }
414        let tint = theme.resolve(BY_DEPTH[node.depth % BY_DEPTH.len()]);
415        painter.rect_stroke(
416            node.rect,
417            egui::CornerRadius::ZERO,
418            egui::Stroke::new(1.0, tint),
419            egui::StrokeKind::Inside,
420        );
421        painter.text(
422            node.rect.left_top(),
423            egui::Align2::LEFT_TOP,
424            node.name,
425            egui::FontId::monospace(7.0),
426            theme.resolve(Role::LayoutName),
427        );
428    }
429}
430
431/// Where the frame's nodes are collected. One buffer per `egui::Context`.
432fn buffer() -> egui::Id {
433    egui::Id::new("blockworx::shell::flex")
434}
435
436#[derive(Clone, Default)]
437struct Recording {
438    /// Cleared whenever this changes, so the buffer holds one pass rather than
439    /// every pass since the app started. Discards perturb the counter, which
440    /// is a hazard for arithmetic on it (playbook §4.2) but not for asking
441    /// whether it moved.
442    pass: u64,
443    depth: usize,
444    nodes: Vec<Node>,
445    /// Named once and remembered, so a node that overflows every frame says so
446    /// once. Survives the per-pass clear for that reason.
447    overflowing: std::collections::BTreeSet<&'static str>,
448}
449
450/// Read what has been recorded. Never rolls the pass: the tree is read back
451/// after the frame that drew it has ended, by which time the counter has
452/// already moved on.
453fn read<T>(ctx: &egui::Context, act: impl FnOnce(&Recording) -> T) -> T {
454    ctx.data_mut(|data| act(data.get_temp_mut_or_default::<Recording>(buffer())))
455}
456
457/// Record into this pass's buffer, emptying it first if the pass has turned
458/// over since the last node was written.
459fn write<T>(ctx: &egui::Context, act: impl FnOnce(&mut Recording) -> T) -> T {
460    let pass = ctx.cumulative_pass_nr();
461    ctx.data_mut(|data| {
462        let recording = data.get_temp_mut_or_default::<Recording>(buffer());
463        if recording.pass != pass {
464            recording.pass = pass;
465            recording.depth = 0;
466            recording.nodes.clear();
467        }
468        act(recording)
469    })
470}
471
472/// A rect a node has not been laid out into yet is NaN, which paints nowhere
473/// and sorts nowhere.
474fn placed(rect: egui::Rect) -> egui::Rect {
475    if rect.any_nan() {
476        egui::Rect::ZERO
477    } else {
478        rect
479    }
480}
481
482fn record(ctx: &egui::Context, name: &'static str, rect: egui::Rect) {
483    write(ctx, |recording| {
484        recording.nodes.push(Node {
485            name,
486            depth: recording.depth,
487            rect: placed(rect),
488        });
489    });
490}
491
492fn enter(ctx: &egui::Context, name: &'static str, rect: egui::Rect) {
493    record(ctx, name, rect);
494    write(ctx, |recording| recording.depth += 1);
495}
496
497fn leave(ctx: &egui::Context) {
498    write(ctx, |recording| {
499        recording.depth = recording.depth.saturating_sub(1);
500    });
501}
502
503/// §8.5: silent overflow is the failure mode hand-rolled layout had, and the
504/// one most likely to survive the migration unnoticed. A node that clips or
505/// scrolls is *meant* to overflow and is not reported.
506#[cfg(debug_assertions)]
507fn note_overflow(
508    ctx: &egui::Context,
509    name: &'static str,
510    container: &egui_taffy::TaffyContainerUi,
511    overflow: taffy::Point<taffy::Overflow>,
512) {
513    /// Sub-pixel content rounding is not an overflow.
514    const SLACK: f32 = 1.0;
515
516    let layout = container.layout();
517    let (box_size, content) = (layout.size, layout.content_size);
518    if box_size.width <= 0.0 || box_size.height <= 0.0 {
519        return;
520    }
521    let over_x = overflow.x == taffy::Overflow::Visible && content.width > box_size.width + SLACK;
522    let over_y = overflow.y == taffy::Overflow::Visible && content.height > box_size.height + SLACK;
523    if !(over_x || over_y) {
524        return;
525    }
526    let first = write(ctx, |recording| recording.overflowing.insert(name));
527    if first {
528        tracing::warn!(
529            node = name,
530            box_width = box_size.width,
531            box_height = box_size.height,
532            content_width = content.width,
533            content_height = content.height,
534            "layout overflow: the content does not fit the box it was given",
535        );
536    }
537}
538
539#[cfg(not(debug_assertions))]
540fn note_overflow(
541    _ctx: &egui::Context,
542    _name: &'static str,
543    _container: &egui_taffy::TaffyContainerUi,
544    _overflow: taffy::Point<taffy::Overflow>,
545) {
546}
547
548#[cfg(test)]
549mod tests {
550    use super::{
551        Flex, MAX_PASSES, Named as _, Node, Points, Room, Tui, cell, centre, dump, fixed, grow,
552        nodes, overflowing, row, row_start, scrolling, stack, surface, trailing, wrap,
553    };
554    use egui_taffy::taffy;
555
556    /// The width every test lays out into, so a reported rect is readable
557    /// against a number rather than against the screen.
558    const ROOM: f32 = 300.0;
559
560    fn points(value: f32) -> Points {
561        Points::new(value)
562    }
563
564    /// Real frames, so the layout under test is the one the app would paint —
565    /// sizes come from the previous pass, so a single frame proves nothing.
566    fn laid_out(mut show: impl FnMut(&mut egui::Ui)) -> crate::tools::painted::Chrome {
567        let mut chrome = crate::tools::painted::Chrome::new(egui::Rect::from_min_size(
568            egui::pos2(0.0, 0.0),
569            egui::vec2(ROOM + 40.0, 600.0),
570        ));
571        chrome.ctx().options_mut(|options| {
572            options.max_passes = MAX_PASSES;
573        });
574        chrome.settle(|ui| {
575            let mut room = ui.new_child(
576                egui::UiBuilder::new()
577                    .id(egui::Id::new("flex_room"))
578                    .max_rect(egui::Rect::from_min_size(
579                        egui::pos2(10.0, 10.0),
580                        egui::vec2(ROOM, 560.0),
581                    ))
582                    .layout(egui::Layout::top_down(egui::Align::Min)),
583            );
584            show(&mut room);
585        });
586        chrome
587    }
588
589    fn named(chrome: &crate::tools::painted::Chrome, name: &str) -> Node {
590        nodes(chrome.ctx())
591            .into_iter()
592            .find(|node| node.name == name)
593            .unwrap_or_else(|| {
594                panic!(
595                    "no node named {name:?}: {:?}",
596                    nodes(chrome.ctx())
597                        .iter()
598                        .map(|node| node.name)
599                        .collect::<Vec<_>>()
600                )
601            })
602    }
603
604    /// The `.row` / `.row .lead` / `.row .body` / `.row .t3` split of §3's
605    /// table, spelled in the vocabulary rather than in taffy.
606    fn a_row(ui: &mut egui::Ui) {
607        surface(
608            ui,
609            "row",
610            Room::Width,
611            row(points(10.0)),
612            |tui: &mut Tui| {
613                tui.leaf("lead", fixed(), |ui| {
614                    ui.label("LEAD");
615                });
616                tui.node("body", grow(), |tui: &mut Tui| {
617                    tui.leaf("body_text", fixed(), |ui| {
618                        ui.label("BODY");
619                    });
620                });
621                tui.leaf("trail", fixed(), |ui| {
622                    ui.label("TRAIL");
623                });
624            },
625        );
626    }
627
628    #[test]
629    fn the_growing_middle_takes_the_room_the_fixed_sides_leave() {
630        let chrome = laid_out(a_row);
631        let (lead, body, trail) = (
632            named(&chrome, "lead"),
633            named(&chrome, "body"),
634            named(&chrome, "trail"),
635        );
636        assert!(
637            lead.rect.is_positive() && trail.rect.is_positive(),
638            "the sides did not lay out at all: {lead:?} {trail:?}",
639        );
640        assert!(
641            body.rect.left() >= lead.rect.right(),
642            "the middle overlaps the lead: {body:?} vs {lead:?}",
643        );
644        assert!(
645            trail.rect.left() >= body.rect.right(),
646            "the trailing cluster did not go to the end: {trail:?} vs {body:?}",
647        );
648        assert!(
649            body.rect.width() > lead.rect.width() + trail.rect.width(),
650            "the middle did not grow: {body:?}",
651        );
652        assert!(
653            trail.rect.right() <= 10.0 + ROOM + 1.0,
654            "the row overran its room: {trail:?}",
655        );
656    }
657
658    #[test]
659    fn every_node_carries_its_name_and_its_nesting_depth() {
660        let chrome = laid_out(a_row);
661        let seen: Vec<(&str, usize)> = nodes(chrome.ctx())
662            .iter()
663            .map(|node| (node.name, node.depth))
664            .collect();
665        assert_eq!(
666            seen,
667            vec![
668                ("row", 0),
669                ("lead", 1),
670                ("body", 1),
671                ("body_text", 2),
672                ("trail", 1),
673            ],
674        );
675    }
676
677    /// The buffer is cleared when the pass moves, so it holds the frame on
678    /// screen rather than every frame since the app started.
679    #[test]
680    fn the_buffer_holds_one_pass_and_not_every_pass() {
681        let chrome = laid_out(a_row);
682        assert_eq!(nodes(chrome.ctx()).len(), 5);
683    }
684
685    /// The one hard rule P0 found: wrapping text has to go through the
686    /// crate's own measurement, which [`Named::text`] is.
687    #[test]
688    fn wrapping_text_uses_the_width_its_node_was_given() {
689        let long = "Base plate seventy eight to eighty four millimetres and then some more";
690        let chrome = laid_out(|ui| {
691            surface(
692                ui,
693                "wrapping",
694                Room::Width,
695                row_start(points(10.0)),
696                |tui: &mut Tui| {
697                    tui.leaf("av", cell(points(26.0)), |ui| {
698                        ui.label("AV");
699                    });
700                    tui.text("description", grow(), long);
701                },
702            );
703        });
704        let text = named(&chrome, "description");
705        assert!(
706            text.rect.width() > ROOM / 2.0,
707            "the label collapsed instead of using its node: {text:?} of {ROOM}",
708        );
709    }
710
711    /// A fixed square is exactly that, however small the thing inside it is.
712    #[test]
713    fn a_cell_keeps_its_side_whatever_it_holds() {
714        let chrome = laid_out(|ui| {
715            surface(
716                ui,
717                "cells",
718                Room::Width,
719                row(points(4.0)),
720                |tui: &mut Tui| {
721                    tui.node("tool", cell(points(52.0)), |tui: &mut Tui| {
722                        tui.leaf("glyph", fixed(), |ui| {
723                            ui.label("T");
724                        });
725                    });
726                },
727            );
728        });
729        let tool = named(&chrome, "tool");
730        assert_eq!((tool.rect.width(), tool.rect.height()), (52.0, 52.0));
731        let glyph = named(&chrome, "glyph");
732        assert!(
733            tool.rect.contains_rect(glyph.rect),
734            "the glyph left its cell: {glyph:?} in {tool:?}",
735        );
736    }
737
738    /// §8.5. A fixed box holding something wider than itself is reported,
739    /// with its name, rather than silently clipping.
740    #[test]
741    fn a_node_whose_content_outgrows_its_box_is_named() {
742        let chrome = laid_out(|ui| {
743            surface(
744                ui,
745                "cramped",
746                Room::Width,
747                stack(points(0.0)),
748                |tui: &mut Tui| {
749                    tui.node("too_small", fixed().width(points(20.0)), |tui: &mut Tui| {
750                        tui.widget(
751                            "wide",
752                            fixed(),
753                            egui::Label::new("a great deal wider than twenty points").extend(),
754                        );
755                    });
756                },
757            );
758        });
759        assert!(
760            named(&chrome, "wide").rect.width() > 20.0,
761            "the test's own premise failed: the content fits the box",
762        );
763        assert!(
764            overflowing(chrome.ctx()).contains(&"too_small"),
765            "the overflow went unreported: {:?}",
766            overflowing(chrome.ctx()),
767        );
768    }
769
770    /// A scrolling node is *meant* to overflow, and must not be reported.
771    #[test]
772    fn a_scrolling_node_is_not_an_overflow() {
773        let chrome = laid_out(|ui| {
774            surface(
775                ui,
776                "sheet",
777                Room::Both,
778                stack(points(0.0)).height(points(120.0)),
779                |tui: &mut Tui| {
780                    tui.node("body", scrolling(), |tui: &mut Tui| {
781                        for _ in 0..20 {
782                            tui.leaf("line", fixed(), |ui| {
783                                ui.label("a row of the list");
784                            });
785                        }
786                    });
787                },
788            );
789        });
790        assert!(
791            !overflowing(chrome.ctx()).contains(&"body"),
792            "a scroll container was reported as overflowing",
793        );
794    }
795
796    /// The whole-row click target of §8.1's history list: the box answers the
797    /// press, not the words inside it, and it is named like any other node.
798    #[test]
799    fn a_pressable_row_answers_a_click_anywhere_in_its_box() {
800        let mut chrome = crate::tools::painted::Chrome::new(egui::Rect::from_min_size(
801            egui::pos2(0.0, 0.0),
802            egui::vec2(ROOM + 40.0, 600.0),
803        ));
804        chrome.ctx().options_mut(|options| {
805            options.max_passes = MAX_PASSES;
806        });
807        let clicked = std::cell::Cell::new(false);
808        let show = |ui: &mut egui::Ui| {
809            surface(
810                ui,
811                "list",
812                Room::Width,
813                stack(points(0.0)),
814                |tui: &mut Tui| {
815                    let pressed = tui.pressable("rev", row(points(11.0)), |tui: &mut Tui| {
816                        tui.leaf("lead", cell(points(28.0)), |ui| {
817                            ui.label("SB");
818                        });
819                        tui.text("what_changed", grow(), "Moved the base plate");
820                    });
821                    if pressed.clicked() {
822                        clicked.set(true);
823                    }
824                },
825            );
826        };
827        chrome.settle(show);
828        let row_rect = named(&chrome, "rev").rect;
829        assert!(
830            row_rect.is_positive(),
831            "the row did not lay out: {row_rect:?}"
832        );
833        let empty = egui::pos2(row_rect.right() - 4.0, row_rect.center().y);
834        assert!(
835            chrome
836                .texts_inside(egui::Rect::from_center_size(empty, egui::vec2(6.0, 6.0)))
837                .is_empty(),
838            "the point pressed is not empty, so the row is not what answered",
839        );
840        chrome.click_at(empty, show);
841        assert!(clicked.get(), "the row ignored a press inside its own box");
842    }
843
844    #[test]
845    fn the_dump_indents_by_depth() {
846        let chrome = laid_out(a_row);
847        let dumped = dump(chrome.ctx());
848        let lines: Vec<&str> = dumped.lines().collect();
849        assert!(lines[0].starts_with("row at "), "{dumped}");
850        assert!(lines[1].starts_with("  lead at "), "{dumped}");
851        assert!(lines[3].starts_with("    body_text at "), "{dumped}");
852    }
853
854    #[test]
855    fn the_dump_says_which_node_overflowed() {
856        let chrome = laid_out(|ui| {
857            surface(
858                ui,
859                "cramped",
860                Room::Width,
861                stack(points(0.0)),
862                |tui: &mut Tui| {
863                    tui.node("too_small", fixed().width(points(20.0)), |tui: &mut Tui| {
864                        tui.widget(
865                            "wide",
866                            fixed(),
867                            egui::Label::new("a great deal wider than twenty points").extend(),
868                        );
869                    });
870                },
871            );
872        });
873        assert!(dump(chrome.ctx()).contains("too_small at "));
874        assert!(dump(chrome.ctx()).contains("OVERFLOWS"));
875    }
876
877    #[test]
878    fn the_growing_middle_may_shrink_below_its_content() {
879        let style: taffy::Style = grow().into();
880        assert_eq!(style.flex_grow, 1.0);
881        assert_eq!(style.flex_shrink, 1.0);
882        assert_eq!(style.min_size.width, taffy::prelude::length(0.0));
883    }
884
885    #[test]
886    fn a_fixed_box_neither_grows_nor_shrinks() {
887        let style: taffy::Style = fixed().into();
888        assert_eq!(style.flex_grow, 0.0);
889        assert_eq!(style.flex_shrink, 0.0);
890    }
891
892    /// §3's table distinguishes `.row` from `.row.rev` by one property, and
893    /// the vocabulary must not blur them.
894    #[test]
895    fn a_reversion_row_starts_where_an_ordinary_row_centres() {
896        let centred: taffy::Style = row(points(11.0)).into();
897        let started: taffy::Style = row_start(points(10.0)).into();
898        assert_eq!(centred.align_items, Some(taffy::AlignItems::Center));
899        assert_eq!(started.align_items, Some(taffy::AlignItems::FlexStart));
900        assert_eq!(centred.gap.width, taffy::prelude::length(11.0));
901    }
902
903    #[test]
904    fn the_centred_middle_grows_and_centres() {
905        let style: taffy::Style = centre(points(8.0)).into();
906        assert_eq!(style.flex_grow, 1.0);
907        assert_eq!(style.justify_content, Some(taffy::JustifyContent::Center));
908        assert_eq!(style.min_size.width, taffy::prelude::length(0.0));
909    }
910
911    #[test]
912    fn chip_flows_wrap_and_meta_columns_do_not() {
913        let chips: taffy::Style = wrap(points(4.0)).into();
914        let meta: taffy::Style = trailing(points(3.0)).into();
915        assert_eq!(chips.flex_wrap, taffy::FlexWrap::Wrap);
916        assert_eq!(meta.flex_direction, taffy::FlexDirection::Column);
917        assert_eq!(meta.align_items, Some(taffy::AlignItems::FlexEnd));
918        assert_eq!(meta.flex_grow, 0.0);
919    }
920
921    #[test]
922    fn a_scrolling_body_scrolls_one_way_and_clips_the_other() {
923        let style: taffy::Style = scrolling().into();
924        assert_eq!(style.overflow.y, taffy::Overflow::Scroll);
925        assert_eq!(style.overflow.x, taffy::Overflow::Clip);
926        assert_eq!(style.flex_grow, 1.0);
927    }
928
929    #[test]
930    fn a_gap_below_zero_is_not_a_gap() {
931        assert_eq!(f32::from(points(-4.0)), 0.0);
932        assert_eq!(f32::from(Points::new(f32::NAN)), 0.0);
933    }
934
935    #[test]
936    fn the_engine_needs_more_than_one_pass() {
937        assert_eq!(MAX_PASSES.get(), 3);
938    }
939
940    /// §8.1: one outline per node, and the names beside them, so "where did
941    /// this box actually go" has an answer on screen.
942    #[cfg(feature = "ui_debug")]
943    #[test]
944    fn the_overlay_draws_one_outline_per_node_with_its_name() {
945        let theme = crate::theme::Theme::from_embedded();
946        let mut chrome = crate::tools::painted::Chrome::new(egui::Rect::from_min_size(
947            egui::pos2(0.0, 0.0),
948            egui::vec2(ROOM + 40.0, 600.0),
949        ));
950        chrome.ctx().options_mut(|options| {
951            options.max_passes = MAX_PASSES;
952        });
953        chrome.settle(|ui| {
954            a_row(ui);
955            super::inspect(ui.ctx(), &theme);
956        });
957        assert_eq!(nodes(chrome.ctx()).len(), 5, "the tree did not lay out");
958        for name in ["row", "lead", "body", "body_text", "trail"] {
959            assert!(chrome.shows(name), "the overlay did not name {name}");
960        }
961        assert!(
962            chrome.outlines().len() >= 5,
963            "fewer outlines than nodes: {:?}",
964            chrome.outlines(),
965        );
966    }
967
968    #[test]
969    fn a_root_that_fills_says_so_in_its_style() {
970        let both: taffy::Style = Flex::default().spanning(Room::Both).into();
971        let wide: taffy::Style = Flex::default().spanning(Room::Width).into();
972        assert_eq!(both.size.width, taffy::prelude::percent(1.0));
973        assert_eq!(both.size.height, taffy::prelude::percent(1.0));
974        assert_eq!(wide.size.height, taffy::prelude::auto());
975    }
976}