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 { Row, Column }
6#[derive(Debug, Clone, Copy, PartialEq)]
7pub enum Align { Start, Center, End }
8
9#[derive(Debug, Clone, Copy, Default)]
10pub struct Rect { pub x: f32, pub y: f32, pub w: f32, pub h: f32 }
11
12#[derive(Debug, Clone)]
13pub struct LayoutNode {
14    pub rect: Rect,
15    pub id: Option<String>,
16    pub on_click: Option<String>,
17    pub children: Vec<LayoutNode>,
18    pub enabled: bool,
19    pub focused: bool,
20}
21
22impl Default for LayoutNode {
23    fn default() -> Self {
24        Self { rect: Rect::default(), id: None, on_click: None,
25               children: Vec::new(), enabled: true, focused: false }
26    }
27}
28
29/// Compute layout starting at (0,0) with given available size.
30/// Returns the root LayoutNode with absolute coordinates.
31pub fn compute(widget: &Widget, avail_w: f32, avail_h: f32) -> LayoutNode {
32    let mut node = LayoutNode {
33        id: widget.id.clone(), on_click: widget.on_click.clone(),
34        enabled: widget.enabled, focused: widget.focused,
35        ..Default::default()
36    };
37    layout_widget(widget, &mut node, 0.0, 0.0, avail_w, avail_h);
38    node
39}
40
41fn layout_widget(w: &Widget, node: &mut LayoutNode, x: f32, y: f32, max_w: f32, max_h: f32) {
42    let s = &w.style;
43    let has_children = !w.children.is_empty();
44
45    // Determine own size
46    let mut ww = if s.w > 0.0 { s.w.min(max_w) } else { max_w };
47    let mut hh = if s.h > 0.0 { s.h.min(max_h) } else { max_h };
48
49    if !has_children {
50        // Leaf: size to content (text, item slot, spacer)
51        match &w.kind {
52            WidgetKind::Label(t) | WidgetKind::Button(t) => {
53                let avail_w = (max_w - s.pad[1] - s.pad[3]).max(0.0);
54                // Wrap only when max_w is a real constraint (not "unlimited")
55                // and the widget doesn't opt out of wrapping entirely.
56                if avail_w < 4096.0 && !s.no_wrap {
57                    ww = (avail_w + s.pad[1] + s.pad[3]).max(s.min_w).min(max_w);
58                    hh = (text::text_height(t, avail_w, s.font_scale) + s.pad[0] + s.pad[2])
59                        .max(s.min_h).min(max_h);
60                } else {
61                    let tw = text::str_width(t, s.font_scale);
62                    ww = (tw + s.pad[1] + s.pad[3]).max(s.min_w).min(max_w);
63                    hh = (text::LINE_H * s.font_scale + s.pad[0] + s.pad[2]).max(s.min_h).min(max_h);
64                }
65            }
66            WidgetKind::ItemSlot(_) => {
67                ww = (18.0 + s.pad[1] + s.pad[3]).max(s.min_w).min(max_w);
68                hh = (18.0 + s.pad[0] + s.pad[2]).max(s.min_h).min(max_h);
69            }
70            WidgetKind::Spacer => {
71                ww = s.min_w.max(1.0).min(max_w);
72                hh = s.min_h.max(1.0).min(max_h);
73            }
74            WidgetKind::Panel(_) => {} // panel with no children → size to min or available
75            WidgetKind::McImage { img_w, img_h, .. } => {
76                ww = (*img_w + s.pad[1] + s.pad[3]).max(s.min_w).min(max_w);
77                hh = (*img_h + s.pad[0] + s.pad[2]).max(s.min_h).min(max_h);
78            }
79        }
80        // Explicit width/height always wins over content sizing.
81        if s.w > 0.0 { ww = s.w.min(max_w); }
82        if s.h > 0.0 { hh = s.h.min(max_h); }
83    }
84
85    node.rect = Rect { x, y, w: ww, h: hh };
86
87    if !has_children { return; }
88
89    // Flex layout for children
90    let dir = if matches!(w.kind, WidgetKind::Panel(_)) && w.flex_dir == FlexDir::Row { FlexDir::Row } else { FlexDir::Column };
91    let content_w = ww - s.pad[1] - s.pad[3];
92    let content_h = hh - s.pad[0] - s.pad[2];
93
94    // Helpers for Dock
95    // Returns effective flex factor (Dock::Fill implies at least 1.0).
96    let effective_flex = |child: &Widget| -> f32 {
97        if child.style.dock == Dock::Fill { child.style.flex.max(1.0) } else { child.style.flex }
98    };
99    // Returns true if this child consumes main-axis space in the normal forward pass.
100    let in_flow = |child: &Widget| -> bool {
101        match (dir, child.style.dock) {
102            (FlexDir::Row,    Dock::Right)  => false,
103            (FlexDir::Column, Dock::Bottom) => false,
104            _ => true,
105        }
106    };
107
108    // Measure children
109    let mut child_nodes: Vec<LayoutNode> = Vec::new();
110    let mut total_flex: f32 = 0.0;
111    let mut used_main: f32  = 0.0;
112
113    for child in &w.children {
114        let dock = child.style.dock;
115        let mut cn = LayoutNode {
116            id: child.id.clone(), on_click: child.on_click.clone(),
117            enabled: child.enabled, focused: child.focused,
118            ..Default::default()
119        };
120        // Determine measurement constraints based on Dock + direction.
121        let (cmw, cmh) = match (dir, dock) {
122            // Fill: constrain both axes so text can wrap to container dimensions.
123            (FlexDir::Row,    Dock::Fill) => (content_w, content_h),
124            (FlexDir::Column, Dock::Fill) => (content_w, content_h),
125            // Cross-axis fill: constrain cross axis, unlimited main axis.
126            (FlexDir::Row,    Dock::Left | Dock::Right) => (f32::MAX, content_h),
127            (FlexDir::Column, Dock::Top  | Dock::Bottom) => (content_w, f32::MAX),
128            // Default flex behaviour.
129            (FlexDir::Row,    _) => (f32::MAX, content_h),
130            (FlexDir::Column, _) => (content_w, f32::MAX),
131        };
132        layout_widget(child, &mut cn, 0.0, 0.0, cmw, cmh);
133        if in_flow(child) {
134            if dir == FlexDir::Row { used_main += cn.rect.w; }
135            else                   { used_main += cn.rect.h; }
136        }
137        total_flex += effective_flex(child);
138        child_nodes.push(cn);
139    }
140    let flow_count = w.children.iter().filter(|c| in_flow(c)).count();
141    let gaps = s.gap * (flow_count.saturating_sub(1) as f32);
142    used_main += gaps;
143
144    let available = (if dir == FlexDir::Row { content_w } else { content_h }) - used_main;
145    let mut pos = if dir == FlexDir::Row { s.pad[3] } else { s.pad[0] };
146
147    // --- Forward pass: position in-flow children (not Dock::Right / Dock::Bottom) ---
148    for (i, child) in w.children.iter().enumerate() {
149        if !in_flow(child) { continue; }
150        let dock = child.style.dock;
151        let cn = &mut child_nodes[i];
152        if dir == FlexDir::Row {
153            let ef = effective_flex(child);
154            if ef > 0.0 && total_flex > 0.0 && available > 0.0 {
155                cn.rect.w += available * ef / total_flex;
156            }
157            if dock == Dock::Fill || dock == Dock::Left || dock == Dock::Right {
158                cn.rect.h = content_h; // stretch cross axis
159            }
160            cn.rect.x = x + pos;
161            cn.rect.y = y + s.pad[0] + match s.align {
162                Align::Center => (content_h - cn.rect.h) / 2.0,
163                Align::End    => content_h - cn.rect.h,
164                _             => 0.0,
165            };
166            pos += cn.rect.w + s.gap;
167        } else {
168            let ef = effective_flex(child);
169            if ef > 0.0 && total_flex > 0.0 && available > 0.0 {
170                cn.rect.h += available * ef / total_flex;
171            }
172            if dock == Dock::Fill || dock == Dock::Top || dock == Dock::Bottom {
173                cn.rect.w = content_w; // stretch cross axis
174            }
175            cn.rect.x = x + s.pad[3] + match s.align {
176                Align::Center => (content_w - cn.rect.w) / 2.0,
177                Align::End    => content_w - cn.rect.w,
178                _             => 0.0,
179            };
180            cn.rect.y = y + pos;
181            pos += cn.rect.h + s.gap;
182        }
183        if !child.children.is_empty() {
184            layout_widget(child, cn, cn.rect.x, cn.rect.y, cn.rect.w, cn.rect.h);
185        }
186    }
187
188    // --- Reverse pass: position Dock::Right / Dock::Bottom children from the far edge ---
189    let mut rpos = if dir == FlexDir::Row {
190        x + s.pad[3] + content_w
191    } else {
192        y + s.pad[0] + content_h
193    };
194    for (i, child) in w.children.iter().enumerate() {
195        if in_flow(child) { continue; }
196        let dock = child.style.dock;
197        let cn = &mut child_nodes[i];
198        if dir == FlexDir::Row {
199            if dock == Dock::Fill || dock == Dock::Left || dock == Dock::Right {
200                cn.rect.h = content_h;
201            }
202            rpos -= cn.rect.w;
203            cn.rect.x = rpos;
204            cn.rect.y = y + s.pad[0] + match s.align {
205                Align::Center => (content_h - cn.rect.h) / 2.0,
206                Align::End    => content_h - cn.rect.h,
207                _             => 0.0,
208            };
209            rpos -= s.gap;
210        } else {
211            if dock == Dock::Fill || dock == Dock::Top || dock == Dock::Bottom {
212                cn.rect.w = content_w;
213            }
214            rpos -= cn.rect.h;
215            cn.rect.y = rpos;
216            cn.rect.x = x + s.pad[3] + match s.align {
217                Align::Center => (content_w - cn.rect.w) / 2.0,
218                Align::End    => content_w - cn.rect.w,
219                _             => 0.0,
220            };
221            rpos -= s.gap;
222        }
223        if !child.children.is_empty() {
224            layout_widget(child, cn, cn.rect.x, cn.rect.y, cn.rect.w, cn.rect.h);
225        }
226    }
227
228    // Auto-size: shrink to content
229    if s.w <= 0.0 {
230        let cw: f32 = child_nodes.iter().map(|c| c.rect.x - x + c.rect.w).fold(0.0f32, f32::max);
231        node.rect.w = (cw + s.pad[1] + s.pad[3]).max(s.min_w).min(max_w);
232    }
233    if s.h <= 0.0 {
234        let ch: f32 = child_nodes.iter().map(|c| c.rect.y - y + c.rect.h).fold(0.0f32, f32::max);
235        node.rect.h = (ch + s.pad[0] + s.pad[2]).max(s.min_h).min(max_h);
236    }
237
238    node.children = child_nodes;
239}
240
241/// Hit-test: find deepest clickable, enabled node at (mx, my).
242pub fn hit_test(node: &LayoutNode, mx: f32, my: f32) -> Option<&LayoutNode> {
243    let r = &node.rect;
244    if mx < r.x || my < r.y || mx > r.x + r.w || my > r.y + r.h { return None; }
245    for child in node.children.iter().rev() {
246        if let Some(hit) = hit_test(child, mx, my) { return Some(hit); }
247    }
248    if node.on_click.is_some() && node.enabled { Some(node) } else { None }
249}
250
251/// Walk tree and set `focused = true` on the node whose id matches, false on all others.
252pub fn set_focus(node: &mut LayoutNode, focused_id: Option<&str>) {
253    node.focused = focused_id.is_some() && node.id.as_deref() == focused_id;
254    for child in &mut node.children { set_focus(child, focused_id); }
255}