Skip to main content

yog_ui/
layout.rs

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