Skip to main content

yog_ui/
layout.rs

1use crate::text;
2use crate::widget::{Dock, 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#[derive(Debug, Clone)]
25pub struct LayoutNode {
26    pub rect: Rect,
27    pub id: Option<String>,
28    pub on_click: Option<String>,
29    pub children: Vec<LayoutNode>,
30    pub enabled: bool,
31    pub focused: bool,
32}
33
34impl Default for LayoutNode {
35    fn default() -> Self {
36        Self {
37            rect: Rect::default(),
38            id: None,
39            on_click: None,
40            children: Vec::new(),
41            enabled: true,
42            focused: false,
43        }
44    }
45}
46
47/// Compute layout starting at (0,0) with given available size.
48/// Returns the root LayoutNode with absolute coordinates.
49pub fn compute(widget: &Widget, avail_w: f32, avail_h: f32) -> LayoutNode {
50    let mut node = LayoutNode {
51        id: widget.id.clone(),
52        on_click: widget.on_click.clone(),
53        enabled: widget.enabled,
54        focused: widget.focused,
55        ..Default::default()
56    };
57    layout_widget(widget, &mut node, 0.0, 0.0, avail_w, avail_h);
58    node
59}
60
61fn layout_widget(w: &Widget, node: &mut LayoutNode, x: f32, y: f32, max_w: f32, max_h: f32) {
62    let s = &w.style;
63    let has_children = !w.children.is_empty();
64
65    // Determine own size
66    let mut ww = s.w.resolve(max_w).map(|v| v.min(max_w)).unwrap_or(max_w);
67    let mut hh = s.h.resolve(max_h).map(|v| v.min(max_h)).unwrap_or(max_h);
68
69    if !has_children {
70        // Leaf: size to content (text, item slot, spacer)
71        match &w.kind {
72            WidgetKind::Label(t) | WidgetKind::Button(t) => {
73                let avail_w = (max_w - s.pad[1] - s.pad[3]).max(0.0);
74                // Wrap only when max_w is a real constraint (not "unlimited")
75                // and the widget doesn't opt out of wrapping entirely.
76                if avail_w < 4096.0 && !s.no_wrap {
77                    ww = (avail_w + s.pad[1] + s.pad[3]).max(s.min_w).min(max_w);
78                    hh = (text::text_height(t, avail_w, s.font_scale) + s.pad[0] + s.pad[2])
79                        .max(s.min_h)
80                        .min(max_h);
81                } else {
82                    let tw = text::str_width(t, s.font_scale);
83                    ww = (tw + s.pad[1] + s.pad[3]).max(s.min_w).min(max_w);
84                    hh = (text::LINE_H * s.font_scale + s.pad[0] + s.pad[2])
85                        .max(s.min_h)
86                        .min(max_h);
87                }
88            }
89            WidgetKind::ItemSlot(_) | WidgetKind::InvSlot { .. } => {
90                ww = (18.0 + s.pad[1] + s.pad[3]).max(s.min_w).min(max_w);
91                hh = (18.0 + s.pad[0] + s.pad[2]).max(s.min_h).min(max_h);
92            }
93            WidgetKind::Spacer => {
94                ww = s.min_w.max(1.0).min(max_w);
95                hh = s.min_h.max(1.0).min(max_h);
96            }
97            WidgetKind::Panel(_) => {} // panel with no children → size to min or available
98            WidgetKind::McImage { img_w, img_h, .. } => {
99                ww = (*img_w + s.pad[1] + s.pad[3]).max(s.min_w).min(max_w);
100                hh = (*img_h + s.pad[0] + s.pad[2]).max(s.min_h).min(max_h);
101            }
102        }
103        // Explicit width/height always wins over content sizing.
104        if let Some(v) = s.w.resolve(max_w) {
105            ww = v.min(max_w);
106        }
107        if let Some(v) = s.h.resolve(max_h) {
108            hh = v.min(max_h);
109        }
110    }
111
112    node.rect = Rect { x, y, w: ww, h: hh };
113
114    if !has_children {
115        return;
116    }
117
118    // Flex layout for children
119    let dir = if matches!(w.kind, WidgetKind::Panel(_)) && w.flex_dir == FlexDir::Row {
120        FlexDir::Row
121    } else {
122        FlexDir::Column
123    };
124    let content_w = ww - s.pad[1] - s.pad[3];
125    let content_h = hh - s.pad[0] - s.pad[2];
126
127    // Helpers for Dock
128    // Returns effective flex factor (Dock::Fill implies at least 1.0).
129    let effective_flex = |child: &Widget| -> f32 {
130        if child.style.dock == Dock::Fill {
131            child.style.flex.max(1.0)
132        } else {
133            child.style.flex
134        }
135    };
136    // Returns true if this child consumes main-axis space in the normal forward pass.
137    let in_flow = |child: &Widget| -> bool {
138        child.style.pos.is_none()
139            && !matches!(
140                (dir, child.style.dock),
141                (FlexDir::Row, Dock::Right) | (FlexDir::Column, Dock::Bottom)
142            )
143    };
144
145    // Measure children
146    let mut child_nodes: Vec<LayoutNode> = Vec::new();
147    let mut total_flex: f32 = 0.0;
148    let mut used_main: f32 = 0.0;
149
150    for child in &w.children {
151        let dock = child.style.dock;
152        let mut cn = LayoutNode {
153            id: child.id.clone(),
154            on_click: child.on_click.clone(),
155            enabled: child.enabled,
156            focused: child.focused,
157            ..Default::default()
158        };
159        // Absolutely positioned: measured against what's left of the content
160        // box past its offset, placed after the flow passes.
161        if let Some((px, py)) = child.style.pos {
162            let mw = (content_w - px).max(0.0);
163            let mh = (content_h - py).max(0.0);
164            layout_widget(child, &mut cn, 0.0, 0.0, mw, mh);
165            child_nodes.push(cn);
166            continue;
167        }
168        // Determine measurement constraints based on Dock + direction.
169        let (cmw, cmh) = match (dir, dock) {
170            // Fill: constrain both axes so text can wrap to container dimensions.
171            (FlexDir::Row, Dock::Fill) => (content_w, content_h),
172            (FlexDir::Column, Dock::Fill) => (content_w, content_h),
173            // Cross-axis fill: constrain cross axis, unlimited main axis.
174            (FlexDir::Row, Dock::Left | Dock::Right) => (f32::MAX, content_h),
175            (FlexDir::Column, Dock::Top | Dock::Bottom) => (content_w, f32::MAX),
176            // Default flex behaviour.
177            (FlexDir::Row, _) => (f32::MAX, content_h),
178            (FlexDir::Column, _) => (content_w, f32::MAX),
179        };
180        layout_widget(child, &mut cn, 0.0, 0.0, cmw, cmh);
181        if in_flow(child) {
182            if dir == FlexDir::Row {
183                used_main += cn.rect.w;
184            } else {
185                used_main += cn.rect.h;
186            }
187        }
188        total_flex += effective_flex(child);
189        child_nodes.push(cn);
190    }
191    let flow_count = w.children.iter().filter(|c| in_flow(c)).count();
192    let gaps = s.gap * (flow_count.saturating_sub(1) as f32);
193    used_main += gaps;
194
195    let available = (if dir == FlexDir::Row {
196        content_w
197    } else {
198        content_h
199    }) - used_main;
200    let mut pos = if dir == FlexDir::Row {
201        s.pad[3]
202    } else {
203        s.pad[0]
204    };
205
206    // --- Forward pass: position in-flow children (not Dock::Right / Dock::Bottom) ---
207    for (i, child) in w.children.iter().enumerate() {
208        if !in_flow(child) {
209            continue;
210        }
211        let dock = child.style.dock;
212        let cn = &mut child_nodes[i];
213        if dir == FlexDir::Row {
214            let ef = effective_flex(child);
215            if ef > 0.0 && total_flex > 0.0 && available > 0.0 {
216                cn.rect.w += available * ef / total_flex;
217            }
218            if dock == Dock::Fill || dock == Dock::Left || dock == Dock::Right {
219                cn.rect.h = content_h; // stretch cross axis
220            }
221            cn.rect.x = x + pos;
222            cn.rect.y = y
223                + s.pad[0]
224                + match s.align {
225                    Align::Center => (content_h - cn.rect.h) / 2.0,
226                    Align::End => content_h - cn.rect.h,
227                    _ => 0.0,
228                };
229            pos += cn.rect.w + s.gap;
230        } else {
231            let ef = effective_flex(child);
232            if ef > 0.0 && total_flex > 0.0 && available > 0.0 {
233                cn.rect.h += available * ef / total_flex;
234            }
235            if dock == Dock::Fill || dock == Dock::Top || dock == Dock::Bottom {
236                cn.rect.w = content_w; // stretch cross axis
237            }
238            cn.rect.x = x
239                + s.pad[3]
240                + match s.align {
241                    Align::Center => (content_w - cn.rect.w) / 2.0,
242                    Align::End => content_w - cn.rect.w,
243                    _ => 0.0,
244                };
245            cn.rect.y = y + pos;
246            pos += cn.rect.h + s.gap;
247        }
248        if !child.children.is_empty() {
249            layout_widget(child, cn, cn.rect.x, cn.rect.y, cn.rect.w, cn.rect.h);
250        }
251    }
252
253    // --- Reverse pass: position Dock::Right / Dock::Bottom children from the far edge ---
254    let mut rpos = if dir == FlexDir::Row {
255        x + s.pad[3] + content_w
256    } else {
257        y + s.pad[0] + content_h
258    };
259    for (i, child) in w.children.iter().enumerate() {
260        if in_flow(child) || child.style.pos.is_some() {
261            continue;
262        }
263        let dock = child.style.dock;
264        let cn = &mut child_nodes[i];
265        if dir == FlexDir::Row {
266            if dock == Dock::Fill || dock == Dock::Left || dock == Dock::Right {
267                cn.rect.h = content_h;
268            }
269            rpos -= cn.rect.w;
270            cn.rect.x = rpos;
271            cn.rect.y = y
272                + s.pad[0]
273                + match s.align {
274                    Align::Center => (content_h - cn.rect.h) / 2.0,
275                    Align::End => content_h - cn.rect.h,
276                    _ => 0.0,
277                };
278            rpos -= s.gap;
279        } else {
280            if dock == Dock::Fill || dock == Dock::Top || dock == Dock::Bottom {
281                cn.rect.w = content_w;
282            }
283            rpos -= cn.rect.h;
284            cn.rect.y = rpos;
285            cn.rect.x = x
286                + s.pad[3]
287                + match s.align {
288                    Align::Center => (content_w - cn.rect.w) / 2.0,
289                    Align::End => content_w - cn.rect.w,
290                    _ => 0.0,
291                };
292            rpos -= s.gap;
293        }
294        if !child.children.is_empty() {
295            layout_widget(child, cn, cn.rect.x, cn.rect.y, cn.rect.w, cn.rect.h);
296        }
297    }
298
299    // --- Absolutely positioned children (see `Style::pos`) ---
300    for (i, child) in w.children.iter().enumerate() {
301        let Some((px, py)) = child.style.pos else {
302            continue;
303        };
304        let cn = &mut child_nodes[i];
305        cn.rect.x = x + s.pad[3] + px;
306        cn.rect.y = y + s.pad[0] + py;
307        if !child.children.is_empty() {
308            layout_widget(child, cn, cn.rect.x, cn.rect.y, cn.rect.w, cn.rect.h);
309        }
310    }
311
312    // Auto-size: shrink to content
313    if s.w.is_auto() {
314        let cw: f32 = child_nodes
315            .iter()
316            .map(|c| c.rect.x - x + c.rect.w)
317            .fold(0.0f32, f32::max);
318        node.rect.w = (cw + s.pad[1] + s.pad[3]).max(s.min_w).min(max_w);
319    }
320    if s.h.is_auto() {
321        let ch: f32 = child_nodes
322            .iter()
323            .map(|c| c.rect.y - y + c.rect.h)
324            .fold(0.0f32, f32::max);
325        node.rect.h = (ch + s.pad[0] + s.pad[2]).max(s.min_h).min(max_h);
326    }
327
328    node.children = child_nodes;
329}
330
331/// Hit-test: find deepest clickable, enabled node at (mx, my).
332pub fn hit_test(node: &LayoutNode, mx: f32, my: f32) -> Option<&LayoutNode> {
333    let r = &node.rect;
334    if mx < r.x || my < r.y || mx > r.x + r.w || my > r.y + r.h {
335        return None;
336    }
337    for child in node.children.iter().rev() {
338        if let Some(hit) = hit_test(child, mx, my) {
339            return Some(hit);
340        }
341    }
342    if node.on_click.is_some() && node.enabled {
343        Some(node)
344    } else {
345        None
346    }
347}
348
349/// Find the resolved rect of the (first) node tagged `id` (see `Widget::id`).
350/// Meant for baking a widget tree's computed positions into some other
351/// system at "measure time" (e.g. a mod reporting real-slot pixel offsets to
352/// its host) — a WinForms-style measure-then-use pass, not a per-frame call.
353pub fn find_by_id(node: &LayoutNode, id: &str) -> Option<Rect> {
354    if node.id.as_deref() == Some(id) {
355        return Some(node.rect);
356    }
357    node.children.iter().find_map(|c| find_by_id(c, id))
358}
359
360/// Walk tree and set `focused = true` on the node whose id matches, false on all others.
361pub fn set_focus(node: &mut LayoutNode, focused_id: Option<&str>) {
362    node.focused = focused_id.is_some() && node.id.as_deref() == focused_id;
363    for child in &mut node.children {
364        set_focus(child, focused_id);
365    }
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371    use crate::widget::{self, Units};
372
373    #[test]
374    fn positioned_children_leave_the_flow() {
375        let root = widget::panel(FlexDir::Column)
376            .w(200.0)
377            .h(100.0)
378            .padding(4.0, 4.0, 4.0, 4.0)
379            .child(widget::spacer().min_w(10.0).min_h(10.0))
380            .child(widget::button("Hi").w(40.0).h(20.0).at(50.0, 30.0))
381            .child(widget::spacer().min_w(10.0).min_h(10.0));
382        let node = compute(&root, 400.0, 400.0);
383        let r = node.children[1].rect;
384        assert_eq!((r.x, r.y, r.w, r.h), (54.0, 34.0, 40.0, 20.0));
385        // The flow children stack as if the positioned one weren't there.
386        assert_eq!(node.children[0].rect.y, 4.0);
387        assert_eq!(node.children[2].rect.y, 4.0 + 10.0 + 2.0);
388    }
389
390    #[test]
391    fn positioned_container_lays_out_its_children_in_place() {
392        let root = widget::panel(FlexDir::Column)
393            .w(Units::Percent(100.0))
394            .h(Units::Percent(100.0))
395            .child(
396                widget::panel(FlexDir::Row)
397                    .w(100.0)
398                    .h(20.0)
399                    .gap(0.0)
400                    .at(10.0, 20.0)
401                    .child(widget::spacer().min_w(30.0).min_h(5.0))
402                    .child(widget::spacer().min_w(30.0).min_h(5.0)),
403            );
404        let node = compute(&root, 320.0, 240.0);
405        let panel = &node.children[0];
406        assert_eq!((panel.rect.x, panel.rect.y), (10.0, 20.0));
407        assert_eq!(panel.children[1].rect.x, 40.0);
408        assert_eq!(panel.children[1].rect.y, 20.0);
409    }
410
411    #[test]
412    fn auto_sized_parent_grows_to_fit_positioned_children() {
413        let root = widget::panel(FlexDir::Column)
414            .child(widget::spacer().min_w(10.0).min_h(10.0).at(30.0, 40.0));
415        let node = compute(&root, 300.0, 300.0);
416        assert_eq!((node.rect.w, node.rect.h), (40.0, 50.0));
417    }
418
419    #[test]
420    fn find_mut_and_set_text() {
421        let mut root = widget::panel(FlexDir::Column).child(widget::label("a").id("title"));
422        assert!(root.find_mut("title").unwrap().set_text("b"));
423        match &root.children[0].kind {
424            crate::widget::WidgetKind::Label(t) => assert_eq!(t, "b"),
425            _ => unreachable!(),
426        }
427        assert!(root.find_mut("missing").is_none());
428    }
429}