Skip to main content

yog_ui/
layout.rs

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