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/// 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.
128pub fn compute(widget: &Widget, avail_w: f32, avail_h: f32) -> LayoutNode {
129    let mut node = LayoutNode {
130        id: widget.id.clone(),
131        on_click: widget.on_click.clone(),
132        enabled: widget.enabled,
133        focused: widget.focused,
134        ..Default::default()
135    };
136    layout_widget(widget, &mut node, 0.0, 0.0, avail_w, avail_h);
137    node
138}
139
140fn layout_widget(w: &Widget, node: &mut LayoutNode, x: f32, y: f32, max_w: f32, max_h: f32) {
141    let s = &w.style;
142    let has_children = !w.children.is_empty();
143
144    // Determine own size
145    let mut ww = s.w.resolve(max_w).map(|v| v.min(max_w)).unwrap_or(max_w);
146    let mut hh = s.h.resolve(max_h).map(|v| v.min(max_h)).unwrap_or(max_h);
147
148    if !has_children {
149        // Leaf: size to content (text, item slot, spacer)
150        match &w.kind {
151            WidgetKind::Label(t) | WidgetKind::Button(t) => {
152                let avail_w = (max_w - s.pad[1] - s.pad[3]).max(0.0);
153                // Wrap only when max_w is a real constraint (not "unlimited")
154                // and the widget doesn't opt out of wrapping entirely.
155                if avail_w < 4096.0 && !s.no_wrap {
156                    ww = (avail_w + s.pad[1] + s.pad[3]).max(s.min_w).min(max_w);
157                    hh = (text::text_height(t, avail_w, s.font_scale) + s.pad[0] + s.pad[2])
158                        .max(s.min_h)
159                        .min(max_h);
160                } else {
161                    let tw = text::str_width(t, s.font_scale);
162                    ww = (tw + s.pad[1] + s.pad[3]).max(s.min_w).min(max_w);
163                    hh = (text::LINE_H * s.font_scale + s.pad[0] + s.pad[2])
164                        .max(s.min_h)
165                        .min(max_h);
166                }
167            }
168            WidgetKind::ItemSlot(_) | WidgetKind::InvSlot { .. } => {
169                ww = (18.0 + s.pad[1] + s.pad[3]).max(s.min_w).min(max_w);
170                hh = (18.0 + s.pad[0] + s.pad[2]).max(s.min_h).min(max_h);
171            }
172            WidgetKind::Spacer => {
173                ww = s.min_w.max(1.0).min(max_w);
174                hh = s.min_h.max(1.0).min(max_h);
175            }
176            WidgetKind::Panel(_) => {} // panel with no children → size to min or available
177            WidgetKind::McImage { img_w, img_h, .. } => {
178                ww = (*img_w + s.pad[1] + s.pad[3]).max(s.min_w).min(max_w);
179                hh = (*img_h + s.pad[0] + s.pad[2]).max(s.min_h).min(max_h);
180            }
181        }
182        // Explicit width/height always wins over content sizing.
183        if let Some(v) = s.w.resolve(max_w) {
184            ww = v.min(max_w);
185        }
186        if let Some(v) = s.h.resolve(max_h) {
187            hh = v.min(max_h);
188        }
189    }
190
191    node.rect = Rect { x, y, w: ww, h: hh };
192    node.content_h = hh;
193    node.scroll_y = 0.0;
194    node.clip = s.clip;
195    node.scrollbar = s.scrollbar;
196
197    if !has_children {
198        return;
199    }
200
201    // Flex layout for children
202    let dir = if matches!(w.kind, WidgetKind::Panel(_)) && w.flex_dir == FlexDir::Row {
203        FlexDir::Row
204    } else {
205        FlexDir::Column
206    };
207    let bar_w = if s.scrollbar { SCROLLBAR_W } else { 0.0 };
208    let content_w = (ww - s.pad[1] - s.pad[3] - bar_w).max(0.0);
209    let content_h = hh - s.pad[0] - s.pad[2];
210
211    // Helpers for Dock
212    // Returns effective flex factor (Dock::Fill implies at least 1.0).
213    let effective_flex = |child: &Widget| -> f32 {
214        if child.style.dock == Dock::Fill {
215            child.style.flex.max(1.0)
216        } else {
217            child.style.flex
218        }
219    };
220    // Returns true if this child consumes main-axis space in the normal forward pass.
221    let in_flow = |child: &Widget| -> bool {
222        child.style.pos.is_none()
223            && !matches!(
224                (dir, child.style.dock),
225                (FlexDir::Row, Dock::Right) | (FlexDir::Column, Dock::Bottom)
226            )
227    };
228
229    // Measure children
230    let mut child_nodes: Vec<LayoutNode> = Vec::new();
231    let mut total_flex: f32 = 0.0;
232    let mut used_main: f32 = 0.0;
233
234    for child in &w.children {
235        let dock = child.style.dock;
236        let mut cn = LayoutNode {
237            id: child.id.clone(),
238            on_click: child.on_click.clone(),
239            enabled: child.enabled,
240            focused: child.focused,
241            ..Default::default()
242        };
243        // Absolutely positioned: measured against what's left of the content
244        // box past its offset, placed after the flow passes.
245        if let Some((px, py)) = child.style.pos {
246            let mw = (content_w - px).max(0.0);
247            let mh = (content_h - py).max(0.0);
248            layout_widget(child, &mut cn, 0.0, 0.0, mw, mh);
249            child_nodes.push(cn);
250            continue;
251        }
252        // Determine measurement constraints based on Dock + direction.
253        let (cmw, cmh) = match (dir, dock) {
254            // Fill: constrain both axes so text can wrap to container dimensions.
255            (FlexDir::Row, Dock::Fill) => (content_w, content_h),
256            (FlexDir::Column, Dock::Fill) => (content_w, content_h),
257            // Cross-axis fill: constrain cross axis, unlimited main axis.
258            (FlexDir::Row, Dock::Left | Dock::Right) => (f32::MAX, content_h),
259            (FlexDir::Column, Dock::Top | Dock::Bottom) => (content_w, f32::MAX),
260            // Default flex behaviour.
261            (FlexDir::Row, _) => (f32::MAX, content_h),
262            (FlexDir::Column, _) => (content_w, f32::MAX),
263        };
264        layout_widget(child, &mut cn, 0.0, 0.0, cmw, cmh);
265        if in_flow(child) {
266            if dir == FlexDir::Row {
267                used_main += cn.rect.w;
268            } else {
269                used_main += cn.rect.h;
270            }
271        }
272        total_flex += effective_flex(child);
273        child_nodes.push(cn);
274    }
275    let flow_count = w.children.iter().filter(|c| in_flow(c)).count();
276    let gaps = s.gap * (flow_count.saturating_sub(1) as f32);
277    used_main += gaps;
278
279    let available = (if dir == FlexDir::Row {
280        content_w
281    } else {
282        content_h
283    }) - used_main;
284    let mut pos = if dir == FlexDir::Row {
285        s.pad[3]
286    } else {
287        s.pad[0]
288    };
289
290    // --- Forward pass: position in-flow children (not Dock::Right / Dock::Bottom) ---
291    for (i, child) in w.children.iter().enumerate() {
292        if !in_flow(child) {
293            continue;
294        }
295        let dock = child.style.dock;
296        let cn = &mut child_nodes[i];
297        if dir == FlexDir::Row {
298            let ef = effective_flex(child);
299            if ef > 0.0 && total_flex > 0.0 && available > 0.0 {
300                cn.rect.w += available * ef / total_flex;
301            }
302            if dock == Dock::Fill || dock == Dock::Left || dock == Dock::Right {
303                cn.rect.h = content_h; // stretch cross axis
304            }
305            cn.rect.x = x + pos;
306            cn.rect.y = y
307                + s.pad[0]
308                + match s.align {
309                    Align::Center => (content_h - cn.rect.h) / 2.0,
310                    Align::End => content_h - cn.rect.h,
311                    _ => 0.0,
312                };
313            pos += cn.rect.w + s.gap;
314        } else {
315            let ef = effective_flex(child);
316            if ef > 0.0 && total_flex > 0.0 && available > 0.0 {
317                cn.rect.h += available * ef / total_flex;
318            }
319            if dock == Dock::Fill || dock == Dock::Top || dock == Dock::Bottom {
320                cn.rect.w = content_w; // stretch cross axis
321            }
322            cn.rect.x = x
323                + s.pad[3]
324                + match s.align {
325                    Align::Center => (content_w - cn.rect.w) / 2.0,
326                    Align::End => content_w - cn.rect.w,
327                    _ => 0.0,
328                };
329            cn.rect.y = y + pos;
330            pos += cn.rect.h + s.gap;
331        }
332        if !child.children.is_empty() {
333            layout_widget(child, cn, cn.rect.x, cn.rect.y, cn.rect.w, cn.rect.h);
334        }
335    }
336
337    // --- Reverse pass: position Dock::Right / Dock::Bottom children from the far edge ---
338    let mut rpos = if dir == FlexDir::Row {
339        x + s.pad[3] + content_w
340    } else {
341        y + s.pad[0] + content_h
342    };
343    for (i, child) in w.children.iter().enumerate() {
344        if in_flow(child) || child.style.pos.is_some() {
345            continue;
346        }
347        let dock = child.style.dock;
348        let cn = &mut child_nodes[i];
349        if dir == FlexDir::Row {
350            if dock == Dock::Fill || dock == Dock::Left || dock == Dock::Right {
351                cn.rect.h = content_h;
352            }
353            rpos -= cn.rect.w;
354            cn.rect.x = rpos;
355            cn.rect.y = y
356                + s.pad[0]
357                + match s.align {
358                    Align::Center => (content_h - cn.rect.h) / 2.0,
359                    Align::End => content_h - cn.rect.h,
360                    _ => 0.0,
361                };
362            rpos -= s.gap;
363        } else {
364            if dock == Dock::Fill || dock == Dock::Top || dock == Dock::Bottom {
365                cn.rect.w = content_w;
366            }
367            rpos -= cn.rect.h;
368            cn.rect.y = rpos;
369            cn.rect.x = x
370                + s.pad[3]
371                + match s.align {
372                    Align::Center => (content_w - cn.rect.w) / 2.0,
373                    Align::End => content_w - cn.rect.w,
374                    _ => 0.0,
375                };
376            rpos -= s.gap;
377        }
378        if !child.children.is_empty() {
379            layout_widget(child, cn, cn.rect.x, cn.rect.y, cn.rect.w, cn.rect.h);
380        }
381    }
382
383    // --- Absolutely positioned children (see `Style::pos`) ---
384    for (i, child) in w.children.iter().enumerate() {
385        let Some((px, py)) = child.style.pos else {
386            continue;
387        };
388        let cn = &mut child_nodes[i];
389        cn.rect.x = x + s.pad[3] + px;
390        cn.rect.y = y + s.pad[0] + py;
391        if !child.children.is_empty() {
392            layout_widget(child, cn, cn.rect.x, cn.rect.y, cn.rect.w, cn.rect.h);
393        }
394    }
395
396    // Auto-size: shrink to content
397    if s.w.is_auto() {
398        let cw: f32 = child_nodes
399            .iter()
400            .map(|c| c.rect.x - x + c.rect.w)
401            .fold(0.0f32, f32::max);
402        node.rect.w = (cw + s.pad[1] + s.pad[3] + bar_w).max(s.min_w).min(max_w);
403    }
404    let ch: f32 = child_nodes
405        .iter()
406        .map(|c| c.rect.y - y + c.rect.h)
407        .fold(0.0f32, f32::max);
408    if s.h.is_auto() {
409        node.rect.h = (ch + s.pad[0] + s.pad[2]).max(s.min_h).min(max_h);
410    }
411
412    // Scrolling: children positioned from the content's top, then shifted up.
413    // (`ch` already includes the top padding: child y is relative to `y`.)
414    node.content_h = ch + s.pad[2];
415    node.scroll_y = s.scroll_y.clamp(0.0, node.max_scroll());
416    if node.scroll_y > 0.0 {
417        for c in &mut child_nodes {
418            c.translate_y(-node.scroll_y);
419        }
420    }
421
422    node.children = child_nodes;
423}
424
425/// Hit-test: find deepest clickable, enabled node at (mx, my).
426/// Children only count where they're inside their parent's rect, so rows
427/// scrolled out of a clipping container can't be clicked.
428pub fn hit_test(node: &LayoutNode, mx: f32, my: f32) -> Option<&LayoutNode> {
429    if !node.rect.contains(mx, my) {
430        return None;
431    }
432    for child in node.children.iter().rev() {
433        if let Some(hit) = hit_test(child, mx, my) {
434            return Some(hit);
435        }
436    }
437    if node.on_click.is_some() && node.enabled {
438        Some(node)
439    } else {
440        None
441    }
442}
443
444/// Find the resolved rect of the (first) node tagged `id` (see `Widget::id`).
445/// Meant for baking a widget tree's computed positions into some other
446/// system at "measure time" (e.g. a mod reporting real-slot pixel offsets to
447/// its host) — a WinForms-style measure-then-use pass, not a per-frame call.
448pub fn find_by_id(node: &LayoutNode, id: &str) -> Option<Rect> {
449    if node.id.as_deref() == Some(id) {
450        return Some(node.rect);
451    }
452    node.children.iter().find_map(|c| find_by_id(c, id))
453}
454
455/// The (first) node tagged `id` — its rect plus scrolling state
456/// ([`LayoutNode::scroll_y`], [`LayoutNode::scrollbar_rects`], ...).
457pub fn find_node<'a>(node: &'a LayoutNode, id: &str) -> Option<&'a LayoutNode> {
458    if node.id.as_deref() == Some(id) {
459        return Some(node);
460    }
461    node.children.iter().find_map(|c| find_node(c, id))
462}
463
464/// Walk tree and set `focused = true` on the node whose id matches, false on all others.
465pub fn set_focus(node: &mut LayoutNode, focused_id: Option<&str>) {
466    node.focused = focused_id.is_some() && node.id.as_deref() == focused_id;
467    for child in &mut node.children {
468        set_focus(child, focused_id);
469    }
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475    use crate::widget::{self, Units};
476
477    #[test]
478    fn positioned_children_leave_the_flow() {
479        let root = widget::panel(FlexDir::Column)
480            .w(200.0)
481            .h(100.0)
482            .padding(4.0, 4.0, 4.0, 4.0)
483            .child(widget::spacer().min_w(10.0).min_h(10.0))
484            .child(widget::button("Hi").w(40.0).h(20.0).at(50.0, 30.0))
485            .child(widget::spacer().min_w(10.0).min_h(10.0));
486        let node = compute(&root, 400.0, 400.0);
487        let r = node.children[1].rect;
488        assert_eq!((r.x, r.y, r.w, r.h), (54.0, 34.0, 40.0, 20.0));
489        // The flow children stack as if the positioned one weren't there.
490        assert_eq!(node.children[0].rect.y, 4.0);
491        assert_eq!(node.children[2].rect.y, 4.0 + 10.0 + 2.0);
492    }
493
494    #[test]
495    fn positioned_container_lays_out_its_children_in_place() {
496        let root = widget::panel(FlexDir::Column)
497            .w(Units::Percent(100.0))
498            .h(Units::Percent(100.0))
499            .child(
500                widget::panel(FlexDir::Row)
501                    .w(100.0)
502                    .h(20.0)
503                    .gap(0.0)
504                    .at(10.0, 20.0)
505                    .child(widget::spacer().min_w(30.0).min_h(5.0))
506                    .child(widget::spacer().min_w(30.0).min_h(5.0)),
507            );
508        let node = compute(&root, 320.0, 240.0);
509        let panel = &node.children[0];
510        assert_eq!((panel.rect.x, panel.rect.y), (10.0, 20.0));
511        assert_eq!(panel.children[1].rect.x, 40.0);
512        assert_eq!(panel.children[1].rect.y, 20.0);
513    }
514
515    #[test]
516    fn auto_sized_parent_grows_to_fit_positioned_children() {
517        let root = widget::panel(FlexDir::Column)
518            .child(widget::spacer().min_w(10.0).min_h(10.0).at(30.0, 40.0));
519        let node = compute(&root, 300.0, 300.0);
520        assert_eq!((node.rect.w, node.rect.h), (40.0, 50.0));
521    }
522
523    #[test]
524    fn find_mut_and_set_text() {
525        let mut root = widget::panel(FlexDir::Column).child(widget::label("a").id("title"));
526        assert!(root.find_mut("title").unwrap().set_text("b"));
527        match &root.children[0].kind {
528            crate::widget::WidgetKind::Label(t) => assert_eq!(t, "b"),
529            _ => unreachable!(),
530        }
531        assert!(root.find_mut("missing").is_none());
532    }
533}