Skip to main content

yog_ui/
layout.rs

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