Skip to main content

yog_ui/
layout.rs

1use crate::text;
2use crate::widget::{Action, Dock, HAlign, SLOT_SIZE, Units, VAlign, Widget, WidgetKind};
3
4mod grid;
5
6#[derive(Debug, Clone, Copy, PartialEq)]
7pub enum FlexDir {
8    Row,
9    Column,
10}
11#[derive(Debug, Clone, Copy, PartialEq)]
12pub enum Align {
13    Start,
14    Center,
15    End,
16}
17
18#[derive(Debug, Clone, Copy, Default)]
19pub struct Rect {
20    pub x: f32,
21    pub y: f32,
22    pub w: f32,
23    pub h: f32,
24}
25
26/// Width of the strip a [`scrollbar`](crate::widget::Style::scrollbar)
27/// container reserves on its right edge.
28pub const SCROLLBAR_W: f32 = 6.0;
29/// Shortest a scrollbar knob gets.
30pub const MIN_KNOB_H: f32 = 16.0;
31
32impl Rect {
33    /// The rect shrunk by `pad` (top, right, bottom, left), never negative.
34    pub fn inset(&self, pad: [f32; 4]) -> Rect {
35        Rect {
36            x: self.x + pad[3],
37            y: self.y + pad[0],
38            w: (self.w - pad[1] - pad[3]).max(0.0),
39            h: (self.h - pad[0] - pad[2]).max(0.0),
40        }
41    }
42
43    /// Whether `(px, py)` lies inside (edges included).
44    pub fn contains(&self, px: f32, py: f32) -> bool {
45        px >= self.x && py >= self.y && px <= self.x + self.w && py <= self.y + self.h
46    }
47
48    /// The overlap of two rects; `None` when they don't overlap.
49    pub fn intersect(&self, o: &Rect) -> Option<Rect> {
50        let x0 = self.x.max(o.x);
51        let y0 = self.y.max(o.y);
52        let x1 = (self.x + self.w).min(o.x + o.w);
53        let y1 = (self.y + self.h).min(o.y + o.h);
54        (x1 > x0 && y1 > y0).then_some(Rect {
55            x: x0,
56            y: y0,
57            w: x1 - x0,
58            h: y1 - y0,
59        })
60    }
61}
62
63#[derive(Debug, Clone)]
64pub struct LayoutNode {
65    pub rect: Rect,
66    pub id: Option<String>,
67    pub on_click: Option<String>,
68    pub children: Vec<LayoutNode>,
69    pub enabled: bool,
70    pub focused: bool,
71    /// Height of everything inside — children plus padding — before
72    /// scrolling. Larger than `rect.h` when the children overflow.
73    pub content_h: f32,
74    /// Effective vertical scroll offset: the widget's
75    /// [`scroll_y`](crate::widget::Style::scroll_y) clamped to
76    /// `0..=`[`Self::max_scroll`]. Children are already shifted by it.
77    pub scroll_y: f32,
78    /// The widget clips its children (see `Style::clip`).
79    pub clip: bool,
80    /// The widget shows a scrollbar on overflow (see `Style::scrollbar`).
81    pub scrollbar: bool,
82    /// The widget's typed click target (see `Widget::action`).
83    pub action: Option<Action>,
84    /// `false` = collapsed (see `Widget::visible`): a zero rect, no
85    /// children, never hit.
86    pub visible: bool,
87    /// A grid's rows and columns as laid out (`None` for other widgets).
88    pub grid: Option<Box<GridLines>>,
89}
90
91/// Where a grid's rows and columns ended up — for drawing grid lines and
92/// designer adorners.
93#[derive(Debug, Clone, Default, PartialEq)]
94pub struct GridLines {
95    /// Each row's top (absolute, scrolled like the children) and height.
96    pub rows: Vec<(f32, f32)>,
97    /// Each column's left (absolute) and width.
98    pub cols: Vec<(f32, f32)>,
99    /// Track sizes from a measure along an unbounded axis — the content
100    /// size of `*` tracks, kept as their floor when the grid is then
101    /// arranged at (at least) that size. Empty otherwise.
102    pub(crate) floor_rows: Vec<f32>,
103    pub(crate) floor_cols: Vec<f32>,
104}
105
106impl LayoutNode {
107    /// How far the children can scroll: `content_h - rect.h`, at least 0.
108    pub fn max_scroll(&self) -> f32 {
109        (self.content_h - self.rect.h).max(0.0)
110    }
111
112    /// Scrollbar track and knob rects — `None` unless the widget has a
113    /// scrollbar and its content overflows.
114    pub fn scrollbar_rects(&self) -> Option<(Rect, Rect)> {
115        let max = self.max_scroll();
116        if !self.scrollbar || max <= 0.0 {
117            return None;
118        }
119        let r = self.rect;
120        let track = Rect {
121            x: r.x + r.w - SCROLLBAR_W,
122            y: r.y,
123            w: SCROLLBAR_W,
124            h: r.h,
125        };
126        let knob_h = (r.h * r.h / self.content_h).max(MIN_KNOB_H).min(r.h);
127        let knob = Rect {
128            y: r.y + (r.h - knob_h) * (self.scroll_y / max),
129            h: knob_h,
130            ..track
131        };
132        Some((track, knob))
133    }
134
135    fn translate_y(&mut self, dy: f32) {
136        self.rect.y += dy;
137        if let Some(g) = &mut self.grid {
138            for r in &mut g.rows {
139                r.0 += dy;
140            }
141        }
142        for c in &mut self.children {
143            c.translate_y(dy);
144        }
145    }
146}
147
148impl Default for LayoutNode {
149    fn default() -> Self {
150        Self {
151            rect: Rect::default(),
152            id: None,
153            on_click: None,
154            children: Vec::new(),
155            enabled: true,
156            focused: false,
157            content_h: 0.0,
158            scroll_y: 0.0,
159            clip: false,
160            scrollbar: false,
161            action: None,
162            visible: true,
163            grid: None,
164        }
165    }
166}
167
168/// Compute layout starting at (0,0) with given available size.
169/// Returns the root LayoutNode with absolute coordinates.
170///
171/// Sizing rules (CSS-flexbox-like, single pass per container):
172/// - A `Units::Percent` size resolves exactly once, against the parent's
173///   content box (its size minus padding and scrollbar strip) on the same
174///   axis — in both a Row and a Column, at any depth. While a parent is
175///   still being measured for content on an axis (an auto size along an
176///   unbounded axis), a percent along that axis acts as `Auto` until the
177///   parent's final size is known.
178/// - `flex` (and `Dock::Fill`) grows a child into the parent's free main-axis
179///   space; the grown size sticks and the child's own children lay out
180///   against it. A growing child with an auto main size starts from its min
181///   size instead of its content (like WPF star sizing), so it fills exactly
182///   the free space — a `scroll_view` scrolls the rest, and nothing nested
183///   inside can push the child's siblings out of the parent.
184pub fn compute(widget: &Widget, avail_w: f32, avail_h: f32) -> LayoutNode {
185    let mut node = new_node(widget);
186    if widget.visible {
187        layout_widget(
188            widget,
189            &mut node,
190            0.0,
191            0.0,
192            avail_w,
193            avail_h,
194            avail_w,
195            avail_h,
196            Depth::Full,
197        );
198    }
199    node
200}
201
202/// Sizes at or above this mean "unbounded": the axis is being measured for
203/// content (a Row's main axis while its children are measured, ...).
204const UNBOUNDED: f32 = f32::MAX;
205
206/// Labels and buttons at least this wide don't wrap: the width is "no
207/// real limit" rather than a constraint.
208const NO_WRAP_W: f32 = 4096.0;
209/// A text box's width along an unbounded axis.
210const TEXT_BOX_W: f32 = 100.0;
211/// What a text box adds to its line height: border and inner margin.
212const TEXT_BOX_CHROME_H: f32 = 7.0;
213
214fn bounded(v: f32) -> bool {
215    v < UNBOUNDED / 2.0
216}
217
218/// `u` against `basis`; a percent of an unbounded basis is `None` (auto).
219fn resolve_size(u: Units, basis: f32) -> Option<f32> {
220    match u {
221        Units::Percent(_) if !bounded(basis) => None,
222        _ => u.resolve(basis),
223    }
224}
225
226fn new_node(w: &Widget) -> LayoutNode {
227    LayoutNode {
228        id: w.id.clone(),
229        on_click: w.on_click.clone(),
230        enabled: w.enabled,
231        focused: w.focused,
232        action: w.action,
233        visible: w.visible,
234        ..Default::default()
235    }
236}
237
238/// How much of a subtree a layout call settles.
239#[derive(Clone, Copy, PartialEq)]
240enum Depth {
241    /// Only the widget's own size (and its children's rects, which that
242    /// size depends on) — grandchildren are left stale. Every measure is
243    /// followed by an [`arrange`], which lays the subtree out for real;
244    /// recursing here too would redo each level twice (2^depth work).
245    Size,
246    /// The whole subtree, final.
247    Full,
248}
249
250/// Measure `w` (see [`Depth`]) at `(x, y)`. `max_w`/`max_h` cap its size
251/// (`UNBOUNDED` = no cap: size to content); `basis_w`/`basis_h` are the
252/// parent's content box that percent sizes resolve against.
253fn measure(w: &Widget, node: &mut LayoutNode, max_w: f32, max_h: f32, basis_w: f32, basis_h: f32) {
254    layout_widget(
255        w,
256        node,
257        0.0,
258        0.0,
259        max_w,
260        max_h,
261        basis_w,
262        basis_h,
263        Depth::Size,
264    );
265}
266
267#[allow(clippy::too_many_arguments)]
268fn layout_widget(
269    w: &Widget,
270    node: &mut LayoutNode,
271    x: f32,
272    y: f32,
273    max_w: f32,
274    max_h: f32,
275    basis_w: f32,
276    basis_h: f32,
277    depth: Depth,
278) {
279    let s = &w.style;
280    let has_children = is_container(w);
281    let explicit_w = resolve_size(s.w, basis_w).map(|v| v.min(max_w));
282    let explicit_h = resolve_size(s.h, basis_h).map(|v| v.min(max_h));
283
284    // Determine own size
285    let mut ww = explicit_w.unwrap_or(max_w);
286    let mut hh = explicit_h.unwrap_or(max_h);
287
288    if !has_children {
289        // Leaf: size to content (text, item slot, spacer) plus padding,
290        // within the min size and the cap.
291        let fit_w = |content: f32| (content + s.pad[1] + s.pad[3]).max(s.min_w).min(max_w);
292        let fit_h = |content: f32| (content + s.pad[0] + s.pad[2]).max(s.min_h).min(max_h);
293        match &w.kind {
294            WidgetKind::Label(t) | WidgetKind::Button(t) => {
295                // Wrap at the explicit width when there is one.
296                let avail_w = (ww - s.pad[1] - s.pad[3]).max(0.0);
297                // Wrap only when the width is a real constraint (not
298                // "unlimited") and the widget doesn't opt out of wrapping.
299                if avail_w < NO_WRAP_W && !s.no_wrap {
300                    ww = fit_w(avail_w);
301                    hh = fit_h(text::text_height_in(t, avail_w, s.font_scale, &s.font));
302                } else {
303                    ww = fit_w(text::str_width_in(t, s.font_scale, &s.font));
304                    hh = fit_h(text::LINE_H * s.font_scale);
305                }
306            }
307            WidgetKind::ItemSlot(_) | WidgetKind::InvSlot { .. } => {
308                ww = fit_w(SLOT_SIZE);
309                hh = fit_h(SLOT_SIZE);
310            }
311            WidgetKind::Spacer | WidgetKind::Surface(_) => {
312                ww = s.min_w.max(1.0).min(max_w);
313                hh = s.min_h.max(1.0).min(max_h);
314            }
315            // Panel with no children: the available space, or its min size
316            // along an unbounded axis.
317            WidgetKind::Panel(_) | WidgetKind::Grid(_) => {
318                if !bounded(ww) {
319                    ww = s.min_w;
320                }
321                if !bounded(hh) {
322                    hh = s.min_h;
323                }
324            }
325            // Text box: the available width (`TEXT_BOX_W` when unbounded),
326            // one line of text plus its border and padding.
327            WidgetKind::TextBox(_) => {
328                if !bounded(ww) {
329                    ww = TEXT_BOX_W;
330                }
331                ww = ww.max(s.min_w).min(max_w);
332                hh = fit_h(text::LINE_H * s.font_scale + TEXT_BOX_CHROME_H);
333            }
334            WidgetKind::Image(image) => {
335                let (nw, nh) = crate::image::natural_size(image);
336                ww = fit_w(nw);
337                hh = fit_h(nh);
338            }
339        }
340        // Explicit width/height always wins over content sizing.
341        if let Some(v) = explicit_w {
342            ww = v;
343        }
344        if let Some(v) = explicit_h {
345            hh = v;
346        }
347    }
348
349    node.rect = Rect { x, y, w: ww, h: hh };
350    node.grid = None;
351    node.content_h = hh;
352    node.scroll_y = 0.0;
353    node.clip = s.clip;
354    node.scrollbar = s.scrollbar;
355
356    if has_children {
357        let shrink = Shrink {
358            w: explicit_w.is_none(),
359            h: explicit_h.is_none(),
360            max_w,
361            max_h,
362        };
363        layout_children(w, node, Some(shrink), depth);
364    }
365}
366
367/// Give `w` (already measured into `node`) its final rect and lay its
368/// children out against that size — nothing is re-resolved or shrunk.
369fn arrange(w: &Widget, node: &mut LayoutNode, x: f32, y: f32) {
370    node.rect.x = x;
371    node.rect.y = y;
372    node.content_h = node.rect.h;
373    if is_container(w) {
374        layout_children(w, node, None, Depth::Full);
375    }
376}
377
378/// Lays out children (a grid does even with none: its tracks size it).
379fn is_container(w: &Widget) -> bool {
380    !w.children.is_empty() || matches!(w.kind, WidgetKind::Grid(_))
381}
382
383/// Which axes of a container size to its content after its children are
384/// laid out (the measure pass), and the caps on that.
385struct Shrink {
386    w: bool,
387    h: bool,
388    max_w: f32,
389    max_h: f32,
390}
391
392/// Places a measured child at `(x, y)`: [`arrange`]s it, or with
393/// [`Depth::Size`] only moves its rect.
394fn place(child: &Widget, cn: &mut LayoutNode, x: f32, y: f32, depth: Depth) {
395    match depth {
396        Depth::Full => arrange(child, cn, x, y),
397        Depth::Size => {
398            cn.rect.x = x;
399            cn.rect.y = y;
400        }
401    }
402}
403
404/// Flex layout of `w`'s children inside `node.rect` (size and position
405/// already set). With `shrink`, auto axes then fit the content.
406fn layout_children(w: &Widget, node: &mut LayoutNode, shrink: Option<Shrink>, depth: Depth) {
407    if let WidgetKind::Grid(spec) = &w.kind {
408        return grid::layout(w, spec, node, shrink, depth);
409    }
410    let s = &w.style;
411    let Rect { x, y, w: ww, h: hh } = node.rect;
412
413    let dir = if matches!(w.kind, WidgetKind::Panel(_)) && w.flex_dir == FlexDir::Row {
414        FlexDir::Row
415    } else {
416        FlexDir::Column
417    };
418    let bar_w = if s.scrollbar { SCROLLBAR_W } else { 0.0 };
419    let content_w = if bounded(ww) {
420        (ww - s.pad[1] - s.pad[3] - bar_w).max(0.0)
421    } else {
422        UNBOUNDED
423    };
424    let content_h = if bounded(hh) {
425        (hh - s.pad[0] - s.pad[2]).max(0.0)
426    } else {
427        UNBOUNDED
428    };
429    let (main_bounded, cross_bounded) = match dir {
430        FlexDir::Row => (bounded(content_w), bounded(content_h)),
431        FlexDir::Column => (bounded(content_h), bounded(content_w)),
432    };
433    // Measuring an auto-sized cross axis: its size isn't known yet (only a
434    // cap), so nothing is centred/end-aligned until the arrange pass.
435    let cross_shrinks = shrink.as_ref().is_some_and(|f| match dir {
436        FlexDir::Row => f.h,
437        FlexDir::Column => f.w,
438    });
439    // A child's own cross-axis placement (`halign` in a Column, `valign` in
440    // a Row): `Some(None)` = stretch, `Some(Some(a))` = aligned, `None` =
441    // the container's `align`.
442    let self_place = |child: &Widget| -> Option<Option<Align>> {
443        match dir {
444            FlexDir::Column => child.style.halign.map(|a| match a {
445                HAlign::Left => Some(Align::Start),
446                HAlign::Center => Some(Align::Center),
447                HAlign::Right => Some(Align::End),
448                HAlign::Stretch => None,
449            }),
450            FlexDir::Row => child.style.valign.map(|a| match a {
451                VAlign::Top => Some(Align::Start),
452                VAlign::Center => Some(Align::Center),
453                VAlign::Bottom => Some(Align::End),
454                VAlign::Stretch => None,
455            }),
456        }
457    };
458    // Offset of a child `size` long (margins included) across `content`.
459    let cross_offset = |child: &Widget, content: f32, size: f32| -> f32 {
460        if !cross_bounded || cross_shrinks {
461            return 0.0;
462        }
463        match self_place(child).flatten().unwrap_or(s.align) {
464            Align::Center => (content - size) / 2.0,
465            Align::End => content - size,
466            Align::Start => 0.0,
467        }
468    };
469    // Margins along the main axis (leading, total) and across it (leading, total).
470    let main_m = |c: &Widget| -> (f32, f32) {
471        let m = c.style.margin;
472        match dir {
473            FlexDir::Row => (m[3], m[1] + m[3]),
474            FlexDir::Column => (m[0], m[0] + m[2]),
475        }
476    };
477    let cross_m = |c: &Widget| -> (f32, f32) {
478        let m = c.style.margin;
479        match dir {
480            FlexDir::Row => (m[0], m[0] + m[2]),
481            FlexDir::Column => (m[3], m[1] + m[3]),
482        }
483    };
484
485    // Helpers for Dock
486    // Returns effective flex factor (Dock::Fill implies at least 1.0).
487    let effective_flex = |child: &Widget| -> f32 {
488        if child.style.dock == Dock::Fill {
489            child.style.flex.max(1.0)
490        } else {
491            child.style.flex
492        }
493    };
494    // Returns true if this child consumes main-axis space in the normal forward pass.
495    let in_flow = |child: &Widget| -> bool {
496        child.visible
497            && child.style.pos.is_none()
498            && !matches!(
499                (dir, child.style.dock),
500                (FlexDir::Row, Dock::Right) | (FlexDir::Column, Dock::Bottom)
501            )
502    };
503    let stretches = |child: &Widget| -> bool {
504        let dock = child.style.dock;
505        let auto_cross = match dir {
506            FlexDir::Row => child.style.h.is_auto(),
507            FlexDir::Column => child.style.w.is_auto(),
508        };
509        cross_bounded
510            && (match dir {
511                FlexDir::Row => matches!(dock, Dock::Fill | Dock::Left | Dock::Right),
512                FlexDir::Column => matches!(dock, Dock::Fill | Dock::Top | Dock::Bottom),
513            } || (auto_cross && self_place(child) == Some(None)))
514    };
515
516    // Measure children
517    let mut child_nodes: Vec<LayoutNode> = Vec::with_capacity(w.children.len());
518    let mut total_flex: f32 = 0.0;
519    let mut used_main: f32 = 0.0;
520
521    for child in &w.children {
522        let mut cn = new_node(child);
523        // Collapsed: a zero rect at the content box's corner, out of the flow.
524        if !child.visible {
525            cn.rect = Rect {
526                x: x + s.pad[3],
527                y: y + s.pad[0],
528                w: 0.0,
529                h: 0.0,
530            };
531            child_nodes.push(cn);
532            continue;
533        }
534        let m = child.style.margin;
535        // Absolutely positioned: measured against what's left of the content
536        // box past its offset (percent sizes: against the whole content box),
537        // placed after the flow passes. A size in pixels is kept whatever
538        // fits — a screen bigger than the window keeps its proportions and
539        // is cut at the window's edges, as the game's are, not squeezed.
540        if let Some((px, py)) = child.style.pos {
541            let fixed = |u: Units| matches!(u, Units::Px(_));
542            let mw = if fixed(child.style.w) { UNBOUNDED } else { less(content_w, px + m[1] + m[3]) };
543            let mh = if fixed(child.style.h) { UNBOUNDED } else { less(content_h, py + m[0] + m[2]) };
544            measure(child, &mut cn, mw, mh, content_w, content_h);
545            child_nodes.push(cn);
546            continue;
547        }
548        // Main axis unbounded (children size to content along it), except a
549        // Dock::Fill child which may wrap text to the container.
550        let (cmw, cmh) = match (dir, child.style.dock) {
551            (_, Dock::Fill) => (content_w, content_h),
552            (FlexDir::Row, _) => (UNBOUNDED, content_h),
553            (FlexDir::Column, _) => (content_w, UNBOUNDED),
554        };
555        let (cmw, cmh) = (less(cmw, m[1] + m[3]), less(cmh, m[0] + m[2]));
556        if dir == FlexDir::Column
557            && child.style.dock != Dock::Fill
558            && matches!(self_place(child), Some(Some(_)))
559        {
560            measure_aligned(child, &mut cn, cmw, cmh, content_w, content_h);
561        } else {
562            measure(child, &mut cn, cmw, cmh, content_w, content_h);
563        }
564        let ef = effective_flex(child);
565        // A growing child with an auto main size starts from its min size,
566        // not its content (WPF star sizing / CSS `flex: 1 1 0`): it gets
567        // exactly its share of the free space, so a long list nested deep
568        // inside can't push its siblings out of the parent.
569        if ef > 0.0 && main_bounded && in_flow(child) {
570            match dir {
571                FlexDir::Row if child.style.w.is_auto() => cn.rect.w = child.style.min_w,
572                FlexDir::Column if child.style.h.is_auto() => cn.rect.h = child.style.min_h,
573                _ => {}
574            }
575        }
576        // Flow and far-edge (Dock::Right / Dock::Bottom) children alike
577        // take main-axis space.
578        used_main += main_m(child).1
579            + match dir {
580                FlexDir::Row => cn.rect.w,
581                FlexDir::Column => cn.rect.h,
582            };
583        if in_flow(child) {
584            total_flex += ef;
585        }
586        child_nodes.push(cn);
587    }
588    let placed = w
589        .children
590        .iter()
591        .filter(|c| c.visible && c.style.pos.is_none())
592        .count();
593    used_main += s.gap * (placed.saturating_sub(1) as f32);
594
595    // Free main-axis space to grow into — none while measuring for content.
596    let available = if main_bounded {
597        (match dir {
598            FlexDir::Row => content_w,
599            FlexDir::Column => content_h,
600        }) - used_main
601    } else {
602        0.0
603    };
604    let mut pos = match dir {
605        FlexDir::Row => s.pad[3],
606        FlexDir::Column => s.pad[0],
607    };
608
609    // --- Forward pass: position in-flow children (not Dock::Right / Dock::Bottom) ---
610    for (i, child) in w.children.iter().enumerate() {
611        if !in_flow(child) {
612            continue;
613        }
614        let cn = &mut child_nodes[i];
615        let ef = effective_flex(child);
616        let grow = if ef > 0.0 && total_flex > 0.0 && available > 0.0 {
617            available * ef / total_flex
618        } else {
619            0.0
620        };
621        let stretch = stretches(child);
622        let (ml, mt) = main_m(child);
623        let (cl, ct) = cross_m(child);
624        let (cx, cy) = if dir == FlexDir::Row {
625            cn.rect.w += grow;
626            if stretch {
627                cn.rect.h = (content_h - ct).max(0.0);
628            }
629            let p = (
630                x + pos + ml,
631                y + s.pad[0] + cl + cross_offset(child, content_h, cn.rect.h + ct),
632            );
633            pos += cn.rect.w + mt + s.gap;
634            p
635        } else {
636            cn.rect.h += grow;
637            if stretch {
638                cn.rect.w = (content_w - ct).max(0.0);
639            }
640            let p = (
641                x + s.pad[3] + cl + cross_offset(child, content_w, cn.rect.w + ct),
642                y + pos + ml,
643            );
644            pos += cn.rect.h + mt + s.gap;
645            p
646        };
647        place(child, cn, cx, cy, depth);
648    }
649
650    // --- Reverse pass: position Dock::Right / Dock::Bottom children from the far edge ---
651    // While measuring for content there is no far edge: they line up right
652    // after the flow, keeping their right-to-left (bottom-to-top) order.
653    let mut rpos = if main_bounded {
654        match dir {
655            FlexDir::Row => x + s.pad[3] + content_w,
656            FlexDir::Column => y + s.pad[0] + content_h,
657        }
658    } else {
659        let docked: Vec<f32> = w
660            .children
661            .iter()
662            .zip(&child_nodes)
663            .filter(|(c, _)| c.visible && !in_flow(c) && c.style.pos.is_none())
664            .map(|(c, n)| {
665                main_m(c).1
666                    + match dir {
667                        FlexDir::Row => n.rect.w,
668                        FlexDir::Column => n.rect.h,
669                    }
670            })
671            .collect();
672        let span: f32 =
673            docked.iter().sum::<f32>() + s.gap * (docked.len().saturating_sub(1) as f32);
674        (match dir {
675            FlexDir::Row => x,
676            FlexDir::Column => y,
677        }) + pos
678            + span
679    };
680    for (i, child) in w.children.iter().enumerate() {
681        if !child.visible || in_flow(child) || child.style.pos.is_some() {
682            continue;
683        }
684        let cn = &mut child_nodes[i];
685        let stretch = stretches(child);
686        let (ml, mt) = main_m(child);
687        let (cl, ct) = cross_m(child);
688        // The trailing margin first (it's on the far side).
689        rpos -= mt - ml;
690        let (cx, cy) = if dir == FlexDir::Row {
691            if stretch {
692                cn.rect.h = (content_h - ct).max(0.0);
693            }
694            rpos -= cn.rect.w;
695            (
696                rpos,
697                y + s.pad[0] + cl + cross_offset(child, content_h, cn.rect.h + ct),
698            )
699        } else {
700            if stretch {
701                cn.rect.w = (content_w - ct).max(0.0);
702            }
703            rpos -= cn.rect.h;
704            (
705                x + s.pad[3] + cl + cross_offset(child, content_w, cn.rect.w + ct),
706                rpos,
707            )
708        };
709        rpos -= ml + s.gap;
710        place(child, cn, cx, cy, depth);
711    }
712
713    // --- Absolutely positioned children (see `Style::pos`) ---
714    for (i, child) in w.children.iter().enumerate() {
715        let Some((px, py)) = child.style.pos else {
716            continue;
717        };
718        if !child.visible {
719            continue;
720        }
721        let m = child.style.margin;
722        let cn = &mut child_nodes[i];
723        place(
724            child,
725            cn,
726            x + s.pad[3] + px + m[3],
727            y + s.pad[0] + py + m[0],
728            depth,
729        );
730    }
731
732    finish(w, node, child_nodes, shrink, bar_w);
733}
734
735/// Measures `w` aligned (not stretched) across a `max_w` slot: at its
736/// content width — text takes the width it needs instead of wrapping at
737/// (and filling) the slot — unless that's wider than the slot.
738fn measure_aligned(
739    w: &Widget,
740    node: &mut LayoutNode,
741    max_w: f32,
742    max_h: f32,
743    basis_w: f32,
744    basis_h: f32,
745) {
746    if bounded(max_w) && w.style.w.is_auto() {
747        measure(w, node, UNBOUNDED, max_h, basis_w, basis_h);
748        if node.rect.w <= max_w {
749            return;
750        }
751    }
752    measure(w, node, max_w, max_h, basis_w, basis_h);
753}
754
755/// Margin `side` of a visible child (a collapsed one has none).
756fn vis_margin(c: &Widget, side: usize) -> f32 {
757    if c.visible { c.style.margin[side] } else { 0.0 }
758}
759
760/// `v` less `by` (at least 0); unbounded stays unbounded.
761fn less(v: f32, by: f32) -> f32 {
762    if bounded(v) {
763        (v - by).max(0.0)
764    } else {
765        UNBOUNDED
766    }
767}
768
769/// The end of a container's layout: with `shrink`, auto axes fit the
770/// children (margins included); then the content height and scrolling.
771fn finish(
772    w: &Widget,
773    node: &mut LayoutNode,
774    mut child_nodes: Vec<LayoutNode>,
775    shrink: Option<Shrink>,
776    bar_w: f32,
777) {
778    let s = &w.style;
779    let Rect { x, y, .. } = node.rect;
780    let extent = |f: &dyn Fn(&Widget, &LayoutNode) -> f32| -> f32 {
781        w.children
782            .iter()
783            .zip(&child_nodes)
784            .map(|(c, n)| f(c, n))
785            .fold(0.0f32, f32::max)
786    };
787    let ch = extent(&|c, n| n.rect.y - y + n.rect.h + vis_margin(c, 2));
788    if let Some(fit) = shrink {
789        if fit.w {
790            let cw = extent(&|c, n| n.rect.x - x + n.rect.w + vis_margin(c, 1));
791            node.rect.w = (cw + s.pad[1] + bar_w).max(s.min_w).min(fit.max_w);
792        }
793        if fit.h {
794            node.rect.h = (ch + s.pad[2]).max(s.min_h).min(fit.max_h);
795        }
796    }
797
798    // Scrolling: children positioned from the content's top, then shifted up.
799    // (`ch` already includes the top padding: child y is relative to `y`.)
800    node.content_h = ch + s.pad[2];
801    node.scroll_y = s.scroll_y.clamp(0.0, node.max_scroll());
802    if node.scroll_y > 0.0 {
803        for c in &mut child_nodes {
804            c.translate_y(-node.scroll_y);
805        }
806    }
807
808    node.children = child_nodes;
809}
810
811/// Hit-test: find deepest clickable, enabled node at (mx, my).
812/// Children only count where they're inside their parent's rect, so rows
813/// scrolled out of a clipping container can't be clicked.
814pub fn hit_test(node: &LayoutNode, mx: f32, my: f32) -> Option<&LayoutNode> {
815    if !node.visible || !node.rect.contains(mx, my) {
816        return None;
817    }
818    for child in node.children.iter().rev() {
819        if let Some(hit) = hit_test(child, mx, my) {
820            return Some(hit);
821        }
822    }
823    if node.is_clickable() {
824        Some(node)
825    } else {
826        None
827    }
828}
829
830/// The child-index path (as in `Widget::at_path`) of the node
831/// [`hit_test`] finds at `(mx, my)`; `None` on a miss.
832pub fn hit_path(node: &LayoutNode, mx: f32, my: f32) -> Option<Vec<u16>> {
833    fn walk(node: &LayoutNode, mx: f32, my: f32, path: &mut Vec<u16>) -> bool {
834        if !node.visible || !node.rect.contains(mx, my) {
835            return false;
836        }
837        for (i, child) in node.children.iter().enumerate().rev() {
838            path.push(i as u16);
839            if walk(child, mx, my, path) {
840                return true;
841            }
842            path.pop();
843        }
844        node.is_clickable()
845    }
846    let mut path = Vec::new();
847    walk(node, mx, my, &mut path).then_some(path)
848}
849
850/// The path of the deepest visible node at `(mx, my)`, clickable or not
851/// (for hover routing: which scroll view is under the cursor).
852pub fn deepest_path(node: &LayoutNode, mx: f32, my: f32) -> Option<Vec<u16>> {
853    if !node.visible || !node.rect.contains(mx, my) {
854        return None;
855    }
856    let mut path = Vec::new();
857    let mut n = node;
858    'outer: loop {
859        for (i, child) in n.children.iter().enumerate().rev() {
860            if child.visible && child.rect.contains(mx, my) {
861                path.push(i as u16);
862                n = child;
863                continue 'outer;
864            }
865        }
866        return Some(path);
867    }
868}
869
870/// The node at child-index `path` (`&[]` = `node`).
871pub fn node_at<'a>(node: &'a LayoutNode, path: &[u16]) -> Option<&'a LayoutNode> {
872    let mut n = node;
873    for &i in path {
874        n = n.children.get(i as usize)?;
875    }
876    Some(n)
877}
878
879/// [`node_at`], mutably.
880pub fn node_at_mut<'a>(node: &'a mut LayoutNode, path: &[u16]) -> Option<&'a mut LayoutNode> {
881    let mut n = node;
882    for &i in path {
883        n = n.children.get_mut(i as usize)?;
884    }
885    Some(n)
886}
887
888impl LayoutNode {
889    /// Clicking it does something: it has an action or `on_click`, is
890    /// enabled and visible.
891    pub fn is_clickable(&self) -> bool {
892        self.visible && self.enabled && (self.action.is_some() || self.on_click.is_some())
893    }
894}
895
896/// Find the resolved rect of the (first) node tagged `id` (see `Widget::id`).
897/// Meant for baking a widget tree's computed positions into some other
898/// system at "measure time" (e.g. a mod reporting real-slot pixel offsets to
899/// its host) — a WinForms-style measure-then-use pass, not a per-frame call.
900pub fn find_by_id(node: &LayoutNode, id: &str) -> Option<Rect> {
901    if node.id.as_deref() == Some(id) {
902        return Some(node.rect);
903    }
904    node.children.iter().find_map(|c| find_by_id(c, id))
905}
906
907/// The (first) node tagged `id` — its rect plus scrolling state
908/// ([`LayoutNode::scroll_y`], [`LayoutNode::scrollbar_rects`], ...).
909pub fn find_node<'a>(node: &'a LayoutNode, id: &str) -> Option<&'a LayoutNode> {
910    if node.id.as_deref() == Some(id) {
911        return Some(node);
912    }
913    node.children.iter().find_map(|c| find_node(c, id))
914}
915
916/// Walk tree and set `focused = true` on the node whose id matches, false on all others.
917pub fn set_focus(node: &mut LayoutNode, focused_id: Option<&str>) {
918    node.focused = focused_id.is_some() && node.id.as_deref() == focused_id;
919    for child in &mut node.children {
920        set_focus(child, focused_id);
921    }
922}
923
924#[cfg(test)]
925mod tests {
926    use super::*;
927    use crate::widget::{self, Units};
928
929    #[test]
930    fn positioned_children_leave_the_flow() {
931        let root = widget::panel(FlexDir::Column)
932            .w(200.0)
933            .h(100.0)
934            .padding(4.0, 4.0, 4.0, 4.0)
935            .child(widget::spacer().min_w(10.0).min_h(10.0))
936            .child(widget::button("Hi").w(40.0).h(20.0).at(50.0, 30.0))
937            .child(widget::spacer().min_w(10.0).min_h(10.0));
938        let node = compute(&root, 400.0, 400.0);
939        let r = node.children[1].rect;
940        assert_eq!((r.x, r.y, r.w, r.h), (54.0, 34.0, 40.0, 20.0));
941        // The flow children stack as if the positioned one weren't there.
942        assert_eq!(node.children[0].rect.y, 4.0);
943        assert_eq!(node.children[2].rect.y, 4.0 + 10.0 + 2.0);
944    }
945
946    #[test]
947    fn positioned_container_lays_out_its_children_in_place() {
948        let root = widget::panel(FlexDir::Column)
949            .w(Units::Percent(100.0))
950            .h(Units::Percent(100.0))
951            .child(
952                widget::panel(FlexDir::Row)
953                    .w(100.0)
954                    .h(20.0)
955                    .gap(0.0)
956                    .at(10.0, 20.0)
957                    .child(widget::spacer().min_w(30.0).min_h(5.0))
958                    .child(widget::spacer().min_w(30.0).min_h(5.0)),
959            );
960        let node = compute(&root, 320.0, 240.0);
961        let panel = &node.children[0];
962        assert_eq!((panel.rect.x, panel.rect.y), (10.0, 20.0));
963        assert_eq!(panel.children[1].rect.x, 40.0);
964        assert_eq!(panel.children[1].rect.y, 20.0);
965    }
966
967    #[test]
968    fn auto_sized_parent_grows_to_fit_positioned_children() {
969        let root = widget::panel(FlexDir::Column)
970            .child(widget::spacer().min_w(10.0).min_h(10.0).at(30.0, 40.0));
971        let node = compute(&root, 300.0, 300.0);
972        assert_eq!((node.rect.w, node.rect.h), (40.0, 50.0));
973    }
974
975    fn pct(v: f32) -> Units {
976        Units::Percent(v)
977    }
978
979    fn r(n: &LayoutNode) -> (f32, f32, f32, f32) {
980        (n.rect.x, n.rect.y, n.rect.w, n.rect.h)
981    }
982
983    #[test]
984    fn percent_width_in_a_row_resolves_once_against_the_content_box() {
985        let root = widget::panel(FlexDir::Row)
986            .w(400.0)
987            .h(100.0)
988            .padding(5.0, 10.0, 5.0, 10.0)
989            .child(
990                widget::panel(FlexDir::Column)
991                    .w(pct(50.0))
992                    .child(widget::spacer().min_w(10.0).min_h(10.0)),
993            )
994            .child(widget::spacer().w(pct(25.0)).h(pct(50.0)));
995        let node = compute(&root, 800.0, 600.0);
996        // Content box: 380 x 90.
997        assert_eq!(r(&node.children[0]).2, 190.0);
998        assert_eq!(r(&node.children[1]), (10.0 + 190.0 + 2.0, 5.0, 95.0, 45.0));
999    }
1000
1001    #[test]
1002    fn percent_sizes_in_a_column() {
1003        let root = widget::panel(FlexDir::Column)
1004            .w(200.0)
1005            .h(300.0)
1006            .padding(10.0, 0.0, 10.0, 0.0)
1007            .gap(0.0)
1008            .child(
1009                widget::panel(FlexDir::Row)
1010                    .w(pct(50.0))
1011                    .h(pct(50.0))
1012                    .child(widget::spacer()),
1013            )
1014            .child(widget::spacer().w(pct(100.0)).h(pct(10.0)));
1015        let node = compute(&root, 800.0, 600.0);
1016        assert_eq!(r(&node.children[0]), (0.0, 10.0, 100.0, 140.0));
1017        assert_eq!(r(&node.children[1]), (0.0, 150.0, 200.0, 28.0));
1018    }
1019
1020    #[test]
1021    fn nested_percents_compound_per_level_only() {
1022        let root = widget::panel(FlexDir::Row).w(400.0).h(200.0).child(
1023            widget::panel(FlexDir::Column)
1024                .w(pct(50.0))
1025                .h(pct(100.0))
1026                .child(
1027                    widget::panel(FlexDir::Row)
1028                        .w(pct(50.0))
1029                        .h(pct(50.0))
1030                        .child(widget::spacer().w(pct(50.0)).h(pct(50.0))),
1031                ),
1032        );
1033        let node = compute(&root, 400.0, 200.0);
1034        let a = &node.children[0];
1035        let b = &a.children[0];
1036        let c = &b.children[0];
1037        assert_eq!(r(a), (0.0, 0.0, 200.0, 200.0));
1038        assert_eq!(r(b), (0.0, 0.0, 100.0, 100.0));
1039        assert_eq!(r(c), (0.0, 0.0, 50.0, 50.0));
1040    }
1041
1042    #[test]
1043    fn percent_resolves_against_the_scrollbar_free_box() {
1044        let root = widget::scroll_view()
1045            .w(106.0)
1046            .h(50.0)
1047            .child(widget::spacer().w(pct(50.0)).h(10.0));
1048        let node = compute(&root, 400.0, 400.0);
1049        assert_eq!(node.children[0].rect.w, 50.0);
1050    }
1051
1052    #[test]
1053    fn grow_shares_free_space_with_percent_siblings() {
1054        let root = widget::panel(FlexDir::Row)
1055            .w(300.0)
1056            .h(50.0)
1057            .gap(4.0)
1058            .child(widget::spacer().w(pct(50.0)))
1059            .child(
1060                widget::panel(FlexDir::Column)
1061                    .flex(1.0)
1062                    .child(widget::spacer()),
1063            )
1064            .child(widget::spacer().w(pct(10.0)));
1065        let node = compute(&root, 300.0, 50.0);
1066        // 300 - 150 - 30 - 2 * 4 = 112 for the grown panel.
1067        assert_eq!(r(&node.children[0]).2, 150.0);
1068        assert_eq!(r(&node.children[1]), (154.0, 0.0, 112.0, 4.0));
1069        assert_eq!(r(&node.children[2]), (270.0, 0.0, 30.0, 4.0));
1070    }
1071
1072    #[test]
1073    fn grown_panel_keeps_its_size_and_lays_children_out_against_it() {
1074        let root = widget::panel(FlexDir::Column)
1075            .w(200.0)
1076            .h(200.0)
1077            .gap(0.0)
1078            .child(widget::spacer().min_h(20.0))
1079            .child(
1080                widget::panel(FlexDir::Row)
1081                    .flex(1.0)
1082                    .w(pct(100.0))
1083                    .gap(0.0)
1084                    .child(widget::spacer().w(pct(25.0)).h(pct(50.0)))
1085                    .child(
1086                        widget::panel(FlexDir::Column)
1087                            .flex(1.0)
1088                            .child(widget::spacer()),
1089                    )
1090                    .child(widget::spacer().dock(Dock::Right).min_w(10.0)),
1091            );
1092        let node = compute(&root, 400.0, 400.0);
1093        let grown = &node.children[1];
1094        assert_eq!(r(grown), (0.0, 20.0, 200.0, 180.0));
1095        assert_eq!(r(&grown.children[0]), (0.0, 20.0, 50.0, 90.0));
1096        // The inner flex child grows into what's left of the grown width...
1097        assert_eq!(r(&grown.children[1]).2, 200.0 - 50.0 - 10.0);
1098        // ... and the right-docked one hugs the grown panel's far edge.
1099        assert_eq!(r(&grown.children[2]), (190.0, 20.0, 10.0, 180.0));
1100    }
1101
1102    #[test]
1103    fn long_list_deep_inside_a_grown_row_does_not_push_siblings_out() {
1104        // A list screen: header, a flex row holding a 100%-high column
1105        // with a growing scroll view of many rows, and a footer.
1106        let mut list = widget::scroll_view().flex(1.0).w(pct(100.0));
1107        for _ in 0..60 {
1108            list = list.child(widget::spacer().h(25.0));
1109        }
1110        let root = widget::panel(FlexDir::Column)
1111            .w(400.0)
1112            .h(252.0)
1113            .gap(0.0)
1114            .child(widget::spacer().h(20.0))
1115            .child(
1116                widget::panel(FlexDir::Row).flex(1.0).w(pct(100.0)).child(
1117                    widget::panel(FlexDir::Column)
1118                        .w(pct(50.0))
1119                        .h(pct(100.0))
1120                        .child(widget::spacer().h(16.0))
1121                        .child(list),
1122                ),
1123            )
1124            .child(widget::spacer().h(26.0));
1125        let node = compute(&root, 400.0, 252.0);
1126        assert_eq!(r(&node.children[1]), (0.0, 20.0, 400.0, 206.0));
1127        let footer = r(&node.children[2]);
1128        assert_eq!((footer.1, footer.3), (226.0, 26.0));
1129        let column = &node.children[1].children[0];
1130        assert_eq!(column.rect.h, 206.0);
1131        let list = &column.children[1];
1132        assert!(list.rect.y + list.rect.h <= 226.0, "{:?}", list.rect);
1133        assert!(list.content_h > list.rect.h);
1134    }
1135
1136    #[test]
1137    fn grown_column_panel_fills_its_parent_and_children_grow_too() {
1138        let root = widget::panel(FlexDir::Column)
1139            .w(100.0)
1140            .h(100.0)
1141            .gap(0.0)
1142            .child(
1143                widget::panel(FlexDir::Column)
1144                    .flex(1.0)
1145                    .gap(0.0)
1146                    .child(widget::spacer().flex(1.0)),
1147            );
1148        let node = compute(&root, 100.0, 100.0);
1149        assert_eq!(node.children[0].rect.h, 100.0);
1150        assert_eq!(node.children[0].children[0].rect.h, 100.0);
1151    }
1152
1153    #[test]
1154    fn empty_auto_panel_in_a_row_takes_its_min_width() {
1155        let root = widget::panel(FlexDir::Row)
1156            .w(100.0)
1157            .h(20.0)
1158            .child(widget::panel(FlexDir::Column).min_w(7.0))
1159            .child(widget::spacer());
1160        let node = compute(&root, 100.0, 20.0);
1161        assert_eq!(node.children[0].rect.w, 7.0);
1162        assert_eq!(node.children[1].rect.x, 9.0);
1163    }
1164
1165    #[test]
1166    fn padding_counts_once_in_auto_size() {
1167        let root = widget::panel(FlexDir::Column)
1168            .padding(3.0, 4.0, 5.0, 6.0)
1169            .child(widget::spacer().min_w(10.0).min_h(10.0));
1170        let node = compute(&root, 300.0, 300.0);
1171        assert_eq!((node.rect.w, node.rect.h), (20.0, 18.0));
1172        assert_eq!(
1173            (node.children[0].rect.x, node.children[0].rect.y),
1174            (6.0, 3.0)
1175        );
1176    }
1177
1178    #[test]
1179    fn auto_row_in_a_column_measures_to_content() {
1180        // Align::Center on an auto-height row must not centre in "infinity".
1181        let root = widget::panel(FlexDir::Column).w(200.0).h(200.0).child(
1182            widget::panel(FlexDir::Row)
1183                .align(Align::Center)
1184                .gap(0.0)
1185                .child(widget::spacer().min_w(10.0).min_h(10.0))
1186                .child(widget::spacer().min_w(10.0).min_h(20.0))
1187                .child(widget::spacer().min_w(5.0).dock(Dock::Right)),
1188        );
1189        let node = compute(&root, 200.0, 200.0);
1190        let row = &node.children[0];
1191        assert_eq!(row.rect.h, 20.0);
1192        assert_eq!(row.children[0].rect.y, 5.0);
1193        assert_eq!(row.children[1].rect.y, 0.0);
1194    }
1195
1196    #[test]
1197    fn positioned_auto_row_measures_to_content() {
1198        // Measured against the rest of the screen (a bounded cap), a centred
1199        // auto-height row must not centre in that cap.
1200        let pill = widget::panel(FlexDir::Row)
1201            .padding(1.0, 1.0, 1.0, 1.0)
1202            .child(
1203                widget::panel(FlexDir::Row)
1204                    .align(Align::Center)
1205                    .child(widget::spacer().min_w(10.0).min_h(9.0)),
1206            );
1207        let root = widget::panel(FlexDir::Column)
1208            .w(500.0)
1209            .h(500.0)
1210            .child(pill.at(3.0, 4.0));
1211        let node = compute(&root, 500.0, 500.0);
1212        let r = node.children[0].rect;
1213        assert_eq!((r.x, r.y, r.w, r.h), (3.0, 4.0, 12.0, 11.0));
1214        assert_eq!(node.children[0].children[0].children[0].rect.y, 5.0);
1215    }
1216
1217    #[test]
1218    fn row_with_right_dock_measured_on_unbounded_axis() {
1219        // An auto-width row inside a row: its Dock::Right child lines up after
1220        // the flow instead of at "infinity".
1221        let root = widget::panel(FlexDir::Row).w(300.0).h(20.0).child(
1222            widget::panel(FlexDir::Row)
1223                .gap(1.0)
1224                .child(widget::spacer().min_w(10.0))
1225                .child(widget::spacer().min_w(5.0).dock(Dock::Right))
1226                .child(widget::spacer().min_w(7.0).dock(Dock::Right)),
1227        );
1228        let node = compute(&root, 300.0, 20.0);
1229        let inner = &node.children[0];
1230        assert_eq!(inner.rect.w, 10.0 + 1.0 + 7.0 + 1.0 + 5.0);
1231        assert_eq!(inner.children[1].rect.x, 19.0);
1232        assert_eq!(inner.children[2].rect.x, 11.0);
1233    }
1234
1235    #[test]
1236    fn align_center_and_end_in_both_directions() {
1237        let col = widget::panel(FlexDir::Column)
1238            .w(100.0)
1239            .h(50.0)
1240            .align(Align::Center)
1241            .child(widget::spacer().min_w(20.0).min_h(5.0));
1242        assert_eq!(compute(&col, 100.0, 50.0).children[0].rect.x, 40.0);
1243        let row = widget::panel(FlexDir::Row)
1244            .w(100.0)
1245            .h(50.0)
1246            .padding(10.0, 0.0, 0.0, 0.0)
1247            .align(Align::End)
1248            .child(widget::spacer().min_w(20.0).h(pct(20.0)));
1249        // Content height 40, child 8 tall, bottom-aligned.
1250        assert_eq!(compute(&row, 100.0, 50.0).children[0].rect.y, 10.0 + 32.0);
1251    }
1252
1253    #[test]
1254    fn positioned_children_with_percent_sizes() {
1255        let root = widget::panel(FlexDir::Column)
1256            .w(200.0)
1257            .h(100.0)
1258            .padding(10.0, 10.0, 10.0, 10.0)
1259            .child(widget::spacer().w(pct(50.0)).h(pct(50.0)).at(20.0, 10.0))
1260            .child(
1261                widget::panel(FlexDir::Row)
1262                    .w(pct(100.0))
1263                    .h(pct(25.0))
1264                    .at(0.0, 60.0)
1265                    .gap(0.0)
1266                    .child(widget::spacer().w(pct(50.0)))
1267                    .child(widget::spacer().flex(1.0)),
1268            );
1269        let node = compute(&root, 400.0, 400.0);
1270        // Content box 180 x 80.
1271        assert_eq!(r(&node.children[0]), (30.0, 20.0, 90.0, 40.0));
1272        // 100% of 180 but capped to what's left past x=0, i.e. 180; 25% of 80
1273        // = 20 but capped to 80 - 60.
1274        let p = &node.children[1];
1275        assert_eq!(r(p), (10.0, 70.0, 180.0, 20.0));
1276        assert_eq!(r(&p.children[0]), (10.0, 70.0, 90.0, 4.0));
1277        assert_eq!(r(&p.children[1]), (100.0, 70.0, 90.0, 4.0));
1278    }
1279
1280    #[test]
1281    fn growing_scroll_view_fills_free_space_and_scrolls_the_rest() {
1282        let list = widget::scroll_view()
1283            .id("list")
1284            .flex(1.0)
1285            .w(pct(100.0))
1286            .gap(0.0)
1287            .padding(2.0, 2.0, 2.0, 2.0)
1288            .scroll_y(1000.0)
1289            .children((0..10).map(|_| widget::spacer().w(pct(100.0)).min_h(20.0)));
1290        let root = widget::panel(FlexDir::Column)
1291            .w(100.0)
1292            .h(120.0)
1293            .gap(0.0)
1294            .child(widget::spacer().min_h(20.0))
1295            .child(list);
1296        let node = compute(&root, 100.0, 120.0);
1297        let v = find_node(&node, "list").unwrap();
1298        assert_eq!(r(v), (0.0, 20.0, 100.0, 100.0));
1299        assert_eq!(v.content_h, 204.0);
1300        assert_eq!(v.scroll_y, 104.0);
1301        // Rows span the scrollbar-free content width.
1302        assert_eq!(v.children[0].rect.w, 100.0 - 4.0 - SCROLLBAR_W);
1303        // The last row's bottom sits on the view's inner bottom edge.
1304        let last = v.children[9].rect;
1305        assert_eq!(last.y + last.h, 20.0 + 100.0 - 2.0);
1306        assert!(v.scrollbar_rects().is_some());
1307    }
1308
1309    #[test]
1310    fn label_wraps_at_its_explicit_width() {
1311        let root = widget::panel(FlexDir::Column)
1312            .w(300.0)
1313            .child(widget::label("aaaa bbbb cccc").w(40.0));
1314        let node = compute(&root, 300.0, 300.0);
1315        let l = node.children[0].rect;
1316        assert_eq!(l.w, 40.0);
1317        assert!(l.h > text::LINE_H, "wrapped onto several lines: {l:?}");
1318    }
1319
1320    #[test]
1321    fn percent_caps_at_the_cross_axis_and_ignores_unbounded_basis() {
1322        // 150% across a column caps at the content width.
1323        let root = widget::panel(FlexDir::Column)
1324            .w(100.0)
1325            .h(100.0)
1326            .child(widget::spacer().w(pct(150.0)));
1327        assert_eq!(compute(&root, 100.0, 100.0).children[0].rect.w, 100.0);
1328        // An auto-width row measured inside a row resolves its children's
1329        // percents against its final (content) width.
1330        let root = widget::panel(FlexDir::Row).w(200.0).h(20.0).child(
1331            widget::panel(FlexDir::Row)
1332                .gap(0.0)
1333                .child(widget::spacer().min_w(40.0))
1334                .child(widget::spacer().w(pct(50.0))),
1335        );
1336        let node = compute(&root, 200.0, 20.0);
1337        let inner = &node.children[0];
1338        assert_eq!(inner.rect.w, 44.0);
1339        assert_eq!(inner.children[1].rect.w, 22.0);
1340    }
1341
1342    #[test]
1343    fn find_mut_and_set_text() {
1344        let mut root = widget::panel(FlexDir::Column).child(widget::label("a").id("title"));
1345        assert!(root.find_mut("title").unwrap().set_text("b"));
1346        match &root.children[0].kind {
1347            crate::widget::WidgetKind::Label(t) => assert_eq!(t, "b"),
1348            _ => unreachable!(),
1349        }
1350        assert!(root.find_mut("missing").is_none());
1351    }
1352}
1353#[cfg(test)]
1354mod bigger_than_the_window {
1355    use crate::{FlexDir, Units, widget};
1356
1357    #[test]
1358    fn a_screen_bigger_than_the_window_keeps_its_size_and_is_cut() {
1359        let content = widget::panel(FlexDir::Column)
1360            .w(Units::Px(300.0))
1361            .h(Units::Px(200.0))
1362            .child(widget::spacer().w(Units::Percent(100.0)).h(Units::Px(40.0)))
1363            .at(-50.0, -25.0);
1364        let screen = widget::panel(FlexDir::Column).w(Units::Px(200.0)).h(Units::Px(150.0)).child(content);
1365        let l = super::compute(&screen, 200.0, 150.0);
1366        let c = &l.children[0];
1367        assert_eq!((c.rect.x, c.rect.y, c.rect.w, c.rect.h), (-50.0, -25.0, 300.0, 200.0));
1368        assert_eq!(c.children[0].rect.w, 300.0, "percent of its own size, not the window's");
1369    }
1370}