Skip to main content

yog_ui/
layout.rs

1use crate::text;
2use crate::widget::{Action, SLOT_SIZE, Style, Units, Widget, WidgetKind};
3
4mod flex;
5mod grid;
6
7#[derive(Debug, Clone, Copy, PartialEq)]
8pub enum FlexDir {
9    Row,
10    Column,
11}
12#[derive(Debug, Clone, Copy, PartialEq)]
13pub enum Align {
14    Start,
15    Center,
16    End,
17}
18
19use yog_gfx::Vec2;
20pub use yog_gfx::graphics::Rect;
21
22/// Width of the strip a [`scrollbar`](crate::widget::Style::scrollbar)
23/// container reserves on its right edge.
24pub const SCROLLBAR_W: f32 = 6.0;
25/// Shortest a scrollbar knob gets.
26pub const MIN_KNOB_H: f32 = 16.0;
27
28#[derive(Debug, Clone)]
29pub struct LayoutNode {
30    pub rect: Rect,
31    pub id: Option<String>,
32    pub on_click: Option<String>,
33    pub children: Vec<LayoutNode>,
34    pub enabled: bool,
35    pub focused: bool,
36    /// Height of everything inside — children plus padding — before
37    /// scrolling. Larger than `rect.h` when the children overflow.
38    pub content_h: f32,
39    /// Effective vertical scroll offset: the widget's
40    /// [`scroll_y`](crate::widget::Style::scroll_y) clamped to
41    /// `0..=`[`Self::max_scroll`]. Children are already shifted by it.
42    pub scroll_y: f32,
43    /// The widget clips its children (see `Style::clip`).
44    pub clip: bool,
45    /// The widget shows a scrollbar on overflow (see `Style::scrollbar`).
46    pub scrollbar: bool,
47    /// The widget's typed click target (see `Widget::action`).
48    pub action: Option<Action>,
49    /// `false` = collapsed (see `Widget::visible`): a zero rect, no
50    /// children, never hit.
51    pub visible: bool,
52    /// A grid's rows and columns as laid out (`None` for other widgets).
53    pub grid: Option<Box<GridLines>>,
54}
55
56/// Where a grid's rows and columns ended up — for drawing grid lines and
57/// designer adorners.
58#[derive(Debug, Clone, Default, PartialEq)]
59pub struct GridLines {
60    /// Each row's top (absolute, scrolled like the children) and height.
61    pub rows: Vec<(f32, f32)>,
62    /// Each column's left (absolute) and width.
63    pub cols: Vec<(f32, f32)>,
64    /// Track sizes from a measure along an unbounded axis — the content
65    /// size of `*` tracks, kept as their floor when the grid is then
66    /// arranged at (at least) that size. Empty otherwise.
67    pub(crate) floor_rows: Vec<f32>,
68    pub(crate) floor_cols: Vec<f32>,
69}
70
71impl LayoutNode {
72    /// How far the children can scroll: `content_h - rect.h`, at least 0.
73    pub fn max_scroll(&self) -> f32 {
74        (self.content_h - self.rect.h).max(0.0)
75    }
76
77    /// Scrollbar track and knob rects — `None` unless the widget has a
78    /// scrollbar and its content overflows.
79    pub fn scrollbar_rects(&self) -> Option<(Rect, Rect)> {
80        let max = self.max_scroll();
81        if !self.scrollbar || max <= 0.0 {
82            return None;
83        }
84        let r = self.rect;
85        let track = Rect {
86            x: r.x + r.w - SCROLLBAR_W,
87            y: r.y,
88            w: SCROLLBAR_W,
89            h: r.h,
90        };
91        let knob_h = (r.h * r.h / self.content_h).max(MIN_KNOB_H).min(r.h);
92        let knob = Rect {
93            y: r.y + (r.h - knob_h) * (self.scroll_y / max),
94            h: knob_h,
95            ..track
96        };
97        Some((track, knob))
98    }
99
100    fn translate_y(&mut self, dy: f32) {
101        self.rect.y += dy;
102        if let Some(g) = &mut self.grid {
103            for r in &mut g.rows {
104                r.0 += dy;
105            }
106        }
107        for c in &mut self.children {
108            c.translate_y(dy);
109        }
110    }
111}
112
113impl Default for LayoutNode {
114    fn default() -> Self {
115        Self {
116            rect: Rect::default(),
117            id: None,
118            on_click: None,
119            children: Vec::new(),
120            enabled: true,
121            focused: false,
122            content_h: 0.0,
123            scroll_y: 0.0,
124            clip: false,
125            scrollbar: false,
126            action: None,
127            visible: true,
128            grid: None,
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        let room = Room::new(avail_w, avail_h, avail_w, avail_h);
153        layout_widget(widget, &mut node, Vec2::ZERO, room, Depth::Full);
154    }
155    node
156}
157
158/// Sizes at or above this mean "unbounded": the axis is being measured for
159/// content (a Row's main axis while its children are measured, ...).
160const UNBOUNDED: f32 = f32::MAX;
161
162/// Labels and buttons at least this wide don't wrap: the width is "no
163/// real limit" rather than a constraint.
164const NO_WRAP_W: f32 = 4096.0;
165/// A text box's width along an unbounded axis.
166const TEXT_BOX_W: f32 = 100.0;
167/// What a text box adds to its line height: border and inner margin.
168const TEXT_BOX_CHROME_H: f32 = 7.0;
169
170fn bounded(v: f32) -> bool {
171    v < UNBOUNDED / 2.0
172}
173
174/// `u` against `basis`; a percent of an unbounded basis is `None` (auto).
175fn resolve_size(u: Units, basis: f32) -> Option<f32> {
176    match u {
177        Units::Percent(_) if !bounded(basis) => None,
178        _ => u.resolve(basis),
179    }
180}
181
182fn new_node(w: &Widget) -> LayoutNode {
183    LayoutNode {
184        id: w.id.clone(),
185        on_click: w.on_click.clone(),
186        enabled: w.enabled,
187        focused: w.focused,
188        action: w.action,
189        visible: w.visible,
190        ..Default::default()
191    }
192}
193
194/// How much of a subtree a layout call settles.
195#[derive(Clone, Copy, PartialEq)]
196enum Depth {
197    /// Only the widget's own size (and its children's rects, which that
198    /// size depends on) — grandchildren are left stale. Every measure is
199    /// followed by an [`arrange`], which lays the subtree out for real;
200    /// recursing here too would redo each level twice (2^depth work).
201    Size,
202    /// The whole subtree, final.
203    Full,
204}
205
206/// What a widget is laid out in: the caps on its size (`UNBOUNDED`: no
207/// cap, size to content) and the parent's content box its percent sizes
208/// resolve against.
209#[derive(Clone, Copy)]
210struct Room {
211    max_w: f32,
212    max_h: f32,
213    basis_w: f32,
214    basis_h: f32,
215}
216
217impl Room {
218    fn new(max_w: f32, max_h: f32, basis_w: f32, basis_h: f32) -> Room {
219        Room {
220            max_w,
221            max_h,
222            basis_w,
223            basis_h,
224        }
225    }
226}
227
228/// Measure `w` (see [`Depth`]) in `room`.
229fn measure(w: &Widget, node: &mut LayoutNode, room: Room) {
230    layout_widget(w, node, Vec2::ZERO, room, Depth::Size);
231}
232
233/// Lays `w` out at `at` in `room`: its own size, then its children.
234fn layout_widget(w: &Widget, node: &mut LayoutNode, at: Vec2, room: Room, depth: Depth) {
235    let s = &w.style;
236    let Room { max_w, max_h, .. } = room;
237    let has_children = is_container(w);
238    let explicit_w = resolve_size(s.w, room.basis_w).map(|v| v.min(max_w));
239    let explicit_h = resolve_size(s.h, room.basis_h).map(|v| v.min(max_h));
240    let (ww, hh) = if has_children {
241        (explicit_w.unwrap_or(max_w), explicit_h.unwrap_or(max_h))
242    } else {
243        // Explicit width/height always wins over content sizing.
244        let (cw, ch) = leaf_size(w, explicit_w.unwrap_or(max_w), room);
245        (explicit_w.unwrap_or(cw), explicit_h.unwrap_or(ch))
246    };
247
248    node.rect = Rect {
249        x: at.x,
250        y: at.y,
251        w: ww,
252        h: hh,
253    };
254    node.grid = None;
255    node.content_h = hh;
256    node.scroll_y = 0.0;
257    node.clip = s.clip;
258    node.scrollbar = s.scrollbar;
259
260    if has_children {
261        let shrink = Shrink {
262            w: explicit_w.is_none(),
263            h: explicit_h.is_none(),
264            max_w,
265            max_h,
266        };
267        layout_children(w, node, Some(shrink), depth);
268    }
269}
270
271/// A leaf's size: its content (text, item slot, spacer) plus padding,
272/// within the min size and the caps; `ww`: the width it has, the explicit
273/// one if any (text wraps at it).
274fn leaf_size(w: &Widget, ww: f32, room: Room) -> (f32, f32) {
275    let s = &w.style;
276    let Room { max_w, max_h, .. } = room;
277    let (mut ww, mut hh) = (ww, max_h);
278    let fit_w = |content: f32| (content + s.pad[1] + s.pad[3]).max(s.min_w).min(max_w);
279    let fit_h = |content: f32| (content + s.pad[0] + s.pad[2]).max(s.min_h).min(max_h);
280    match &w.kind {
281        WidgetKind::Label(t) | WidgetKind::Button(t) => {
282            // Wrap at the explicit width when there is one.
283            let avail_w = (ww - s.pad[1] - s.pad[3]).max(0.0);
284            // Wrap only when the width is a real constraint (not
285            // "unlimited") and the widget doesn't opt out of wrapping.
286            if avail_w < NO_WRAP_W && !s.no_wrap {
287                ww = fit_w(avail_w);
288                hh = fit_h(text::text_height_in(t, avail_w, s.font_scale, &s.font));
289            } else {
290                ww = fit_w(text::str_width_in(t, s.font_scale, &s.font));
291                hh = fit_h(text::LINE_H * s.font_scale);
292            }
293        }
294        WidgetKind::ItemSlot(_) | WidgetKind::InvSlot { .. } => {
295            ww = fit_w(SLOT_SIZE);
296            hh = fit_h(SLOT_SIZE);
297        }
298        WidgetKind::Spacer | WidgetKind::Surface(_) => {
299            ww = s.min_w.max(1.0).min(max_w);
300            hh = s.min_h.max(1.0).min(max_h);
301        }
302        // Panel with no children: the available space, or its min size
303        // along an unbounded axis.
304        WidgetKind::Panel(_) | WidgetKind::Grid(_) => {
305            if !bounded(ww) {
306                ww = s.min_w;
307            }
308            if !bounded(hh) {
309                hh = s.min_h;
310            }
311        }
312        // Text box: the available width (`TEXT_BOX_W` when unbounded),
313        // one line of text plus its border and padding.
314        WidgetKind::TextBox(_) => {
315            if !bounded(ww) {
316                ww = TEXT_BOX_W;
317            }
318            ww = ww.max(s.min_w).min(max_w);
319            hh = fit_h(text::LINE_H * s.font_scale + TEXT_BOX_CHROME_H);
320        }
321        WidgetKind::Image(image) => {
322            let Vec2 { x: nw, y: nh } = crate::image::natural_size(image);
323            ww = fit_w(nw);
324            hh = fit_h(nh);
325        }
326    }
327    (ww, hh)
328}
329
330/// Give `w` (already measured into `node`) its final rect and lay its
331/// children out against that size — nothing is re-resolved or shrunk.
332fn arrange(w: &Widget, node: &mut LayoutNode, x: f32, y: f32) {
333    node.rect.x = x;
334    node.rect.y = y;
335    node.content_h = node.rect.h;
336    if is_container(w) {
337        layout_children(w, node, None, Depth::Full);
338    }
339}
340
341/// Lays out children (a grid does even with none: its tracks size it).
342fn is_container(w: &Widget) -> bool {
343    !w.children.is_empty() || matches!(w.kind, WidgetKind::Grid(_))
344}
345
346/// Which axes of a container size to its content after its children are
347/// laid out (the measure pass), and the caps on that.
348struct Shrink {
349    w: bool,
350    h: bool,
351    max_w: f32,
352    max_h: f32,
353}
354
355/// Places a measured child at `(x, y)`: [`arrange`]s it, or with
356/// [`Depth::Size`] only moves its rect.
357fn place(child: &Widget, cn: &mut LayoutNode, x: f32, y: f32, depth: Depth) {
358    match depth {
359        Depth::Full => arrange(child, cn, x, y),
360        Depth::Size => {
361            cn.rect.x = x;
362            cn.rect.y = y;
363        }
364    }
365}
366
367/// `w`'s children inside `node.rect` (size and position already set): a
368/// grid's in its tracks, a panel's flex. With `shrink`, auto axes then fit
369/// the content.
370fn layout_children(w: &Widget, node: &mut LayoutNode, shrink: Option<Shrink>, depth: Depth) {
371    match &w.kind {
372        WidgetKind::Grid(spec) => grid::layout(w, spec, node, shrink, depth),
373        _ => flex::layout(w, node, shrink, depth),
374    }
375}
376
377/// Measures `w` aligned (not stretched) across a `max_w` slot: at its
378/// content width — text takes the width it needs instead of wrapping at
379/// (and filling) the slot — unless that's wider than the slot (`room.max_w`).
380fn measure_aligned(w: &Widget, node: &mut LayoutNode, room: Room) {
381    if bounded(room.max_w) && w.style.w.is_auto() {
382        measure(
383            w,
384            node,
385            Room {
386                max_w: UNBOUNDED,
387                ..room
388            },
389        );
390        if node.rect.w <= room.max_w {
391            return;
392        }
393    }
394    measure(w, node, room);
395}
396
397/// Margin `side` of a visible child (a collapsed one has none).
398fn vis_margin(c: &Widget, side: usize) -> f32 {
399    if c.visible { c.style.margin[side] } else { 0.0 }
400}
401
402/// `v` less `by` (at least 0); unbounded stays unbounded.
403fn less(v: f32, by: f32) -> f32 {
404    if bounded(v) {
405        (v - by).max(0.0)
406    } else {
407        UNBOUNDED
408    }
409}
410
411/// The end of a container's layout: with `shrink`, auto axes fit the
412/// children (margins included); then the content height and scrolling.
413fn finish(
414    w: &Widget,
415    node: &mut LayoutNode,
416    mut child_nodes: Vec<LayoutNode>,
417    shrink: Option<Shrink>,
418    bar_w: f32,
419) {
420    let s = &w.style;
421    let Rect { x, y, .. } = node.rect;
422    let extent = |f: &dyn Fn(&Widget, &LayoutNode) -> f32| -> f32 {
423        w.children
424            .iter()
425            .zip(&child_nodes)
426            .map(|(c, n)| f(c, n))
427            .fold(0.0f32, f32::max)
428    };
429    let ch = extent(&|c, n| n.rect.y - y + n.rect.h + vis_margin(c, 2));
430    if let Some(fit) = shrink {
431        if fit.w {
432            let cw = extent(&|c, n| n.rect.x - x + n.rect.w + vis_margin(c, 1));
433            node.rect.w = (cw + s.pad[1] + bar_w).max(s.min_w).min(fit.max_w);
434        }
435        if fit.h {
436            node.rect.h = (ch + s.pad[2]).max(s.min_h).min(fit.max_h);
437        }
438    }
439
440    // (`ch` already includes the top padding: child y is relative to `y`.)
441    scroll(node, s, ch + s.pad[2], &mut child_nodes);
442    node.children = child_nodes;
443}
444
445/// Scrolling: `content_h` tall, the children (positioned from the
446/// content's top) shifted up by the style's `scroll_y`, clamped.
447fn scroll(node: &mut LayoutNode, s: &Style, content_h: f32, children: &mut [LayoutNode]) {
448    node.content_h = content_h;
449    node.scroll_y = s.scroll_y.clamp(0.0, node.max_scroll());
450    if node.scroll_y > 0.0 {
451        for c in children {
452            c.translate_y(-node.scroll_y);
453        }
454    }
455}
456
457/// A container's content box inside `rect` (less padding and the
458/// scrollbar's strip; an unbounded axis stays unbounded): its width,
459/// height, and the strip's width.
460fn content_box(s: &Style, rect: Rect) -> (f32, f32, f32) {
461    let bar_w = if s.scrollbar { SCROLLBAR_W } else { 0.0 };
462    let content_w = if bounded(rect.w) {
463        (rect.w - s.pad[1] - s.pad[3] - bar_w).max(0.0)
464    } else {
465        UNBOUNDED
466    };
467    let content_h = if bounded(rect.h) {
468        (rect.h - s.pad[0] - s.pad[2]).max(0.0)
469    } else {
470        UNBOUNDED
471    };
472    (content_w, content_h, bar_w)
473}
474
475/// Hit-test: find deepest clickable, enabled node at (mx, my).
476/// Children only count where they're inside their parent's rect, so rows
477/// scrolled out of a clipping container can't be clicked.
478pub fn hit_test(node: &LayoutNode, mx: f32, my: f32) -> Option<&LayoutNode> {
479    if !node.visible || !node.rect.contains(Vec2::new(mx, my)) {
480        return None;
481    }
482    for child in node.children.iter().rev() {
483        if let Some(hit) = hit_test(child, mx, my) {
484            return Some(hit);
485        }
486    }
487    if node.is_clickable() {
488        Some(node)
489    } else {
490        None
491    }
492}
493
494/// The child-index path (as in `Widget::at_path`) of the node
495/// [`hit_test`] finds at `(mx, my)`; `None` on a miss.
496pub fn hit_path(node: &LayoutNode, mx: f32, my: f32) -> Option<Vec<u16>> {
497    fn walk(node: &LayoutNode, mx: f32, my: f32, path: &mut Vec<u16>) -> bool {
498        if !node.visible || !node.rect.contains(Vec2::new(mx, my)) {
499            return false;
500        }
501        for (i, child) in node.children.iter().enumerate().rev() {
502            path.push(i as u16);
503            if walk(child, mx, my, path) {
504                return true;
505            }
506            path.pop();
507        }
508        node.is_clickable()
509    }
510    let mut path = Vec::new();
511    walk(node, mx, my, &mut path).then_some(path)
512}
513
514/// The path of the deepest visible node at `(mx, my)`, clickable or not
515/// (for hover routing: which scroll view is under the cursor).
516pub fn deepest_path(node: &LayoutNode, mx: f32, my: f32) -> Option<Vec<u16>> {
517    if !node.visible || !node.rect.contains(Vec2::new(mx, my)) {
518        return None;
519    }
520    let mut path = Vec::new();
521    let mut n = node;
522    'outer: loop {
523        for (i, child) in n.children.iter().enumerate().rev() {
524            if child.visible && child.rect.contains(Vec2::new(mx, my)) {
525                path.push(i as u16);
526                n = child;
527                continue 'outer;
528            }
529        }
530        return Some(path);
531    }
532}
533
534/// The node at child-index `path` (`&[]` = `node`).
535pub fn node_at<'a>(node: &'a LayoutNode, path: &[u16]) -> Option<&'a LayoutNode> {
536    let mut n = node;
537    for &i in path {
538        n = n.children.get(i as usize)?;
539    }
540    Some(n)
541}
542
543/// [`node_at`], mutably.
544pub fn node_at_mut<'a>(node: &'a mut LayoutNode, path: &[u16]) -> Option<&'a mut LayoutNode> {
545    let mut n = node;
546    for &i in path {
547        n = n.children.get_mut(i as usize)?;
548    }
549    Some(n)
550}
551
552impl LayoutNode {
553    /// Clicking it does something: it has an action or `on_click`, is
554    /// enabled and visible.
555    pub fn is_clickable(&self) -> bool {
556        self.visible && self.enabled && (self.action.is_some() || self.on_click.is_some())
557    }
558}
559
560/// Find the resolved rect of the (first) node tagged `id` (see `Widget::id`).
561/// Meant for baking a widget tree's computed positions into some other
562/// system at "measure time" (e.g. a mod reporting real-slot pixel offsets to
563/// its host) — a WinForms-style measure-then-use pass, not a per-frame call.
564pub fn find_by_id(node: &LayoutNode, id: &str) -> Option<Rect> {
565    if node.id.as_deref() == Some(id) {
566        return Some(node.rect);
567    }
568    node.children.iter().find_map(|c| find_by_id(c, id))
569}
570
571/// The (first) node tagged `id` — its rect plus scrolling state
572/// ([`LayoutNode::scroll_y`], [`LayoutNode::scrollbar_rects`], ...).
573pub fn find_node<'a>(node: &'a LayoutNode, id: &str) -> Option<&'a LayoutNode> {
574    if node.id.as_deref() == Some(id) {
575        return Some(node);
576    }
577    node.children.iter().find_map(|c| find_node(c, id))
578}
579
580/// Walk tree and set `focused = true` on the node whose id matches, false on all others.
581pub fn set_focus(node: &mut LayoutNode, focused_id: Option<&str>) {
582    node.focused = focused_id.is_some() && node.id.as_deref() == focused_id;
583    for child in &mut node.children {
584        set_focus(child, focused_id);
585    }
586}
587
588#[cfg(test)]
589mod tests;