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