Skip to main content

newter_compiler/
layout.rs

1//! Flex-like layout engine: computes bounds for each element from the AST.
2
3use crate::ast::*;
4use crate::error::NewtError;
5use crate::value::{eval_expr, EvalContext, Value};
6use serde::Serialize;
7
8#[derive(Debug, Clone, Copy, Serialize)]
9pub struct Rect {
10    pub x: f32,
11    pub y: f32,
12    pub w: f32,
13    pub h: f32,
14}
15
16impl Rect {
17    pub fn new(x: f32, y: f32, w: f32, h: f32) -> Self {
18        Self { x, y, w, h }
19    }
20}
21
22#[derive(Debug, Clone, Serialize)]
23pub struct LayoutNode {
24    pub kind: LayoutKind,
25    pub rect: Rect,
26    pub fill: Option<(u8, u8, u8, u8)>,
27    pub stroke: Option<(u8, u8, u8, u8)>,
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub stroke_width: Option<f32>,
30    pub radius: f32,
31    pub text: Option<String>,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub content_template: Option<String>,
34    pub font_size: f32,
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub font_weight: Option<String>,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub shadow: Option<f32>,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub transition_ms: Option<f32>,
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub role: Option<String>,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub aria_label: Option<String>,
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub focus_order: Option<i32>,
47    #[serde(skip_serializing_if = "Option::is_none", rename = "onClick")]
48    pub on_click: Option<String>,
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub href: Option<String>,
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub name: Option<String>,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub aspect_ratio: Option<f32>,
55    pub children: Vec<LayoutNode>,
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
59pub enum LayoutKind {
60    Box,
61    Text,
62    Row,
63    Column,
64    Grid,
65    Stack,
66    Center,
67    Spacer,
68    Image,
69    Button,
70    Input,
71    Modal,
72}
73
74impl LayoutNode {
75    fn empty(kind: LayoutKind, rect: Rect) -> Self {
76        Self {
77            kind,
78            rect,
79            fill: None,
80            stroke: None,
81            stroke_width: None,
82            radius: 0.0,
83            text: None,
84            content_template: None,
85            font_size: 16.0,
86            font_weight: None,
87            shadow: None,
88            transition_ms: None,
89            role: None,
90            aria_label: None,
91            focus_order: None,
92            on_click: None,
93            href: None,
94            name: None,
95            aspect_ratio: None,
96            children: Vec::new(),
97        }
98    }
99}
100
101fn get_prop_number(ctx: &EvalContext, props: &[Prop], name: &str, default: f32) -> f32 {
102    for p in props {
103        let match_name = match &p.name {
104            PropName::Ident(s) => s == name,
105            PropName::Width => name == "width",
106            PropName::Height => name == "height",
107            PropName::Padding => name == "padding",
108            PropName::Gap => name == "gap",
109            PropName::Radius => name == "radius",
110            PropName::FontSize => name == "fontSize",
111            PropName::MinWidth => name == "minWidth",
112            PropName::MaxWidth => name == "maxWidth",
113            PropName::MinHeight => name == "minHeight",
114            PropName::MaxHeight => name == "maxHeight",
115            PropName::Shadow => name == "shadow",
116            PropName::Transition => name == "transition",
117            _ => false,
118        };
119        if !match_name {
120            continue;
121        }
122        match &p.value {
123            PropValue::Number(n) => return *n as f32,
124            PropValue::Expr(e) => {
125                if let Ok(Value::Number(n)) = eval_expr(ctx, e) {
126                    return n as f32;
127                }
128            }
129            _ => {}
130        }
131    }
132    default
133}
134
135fn get_prop_color(ctx: &EvalContext, props: &[Prop], name: &str) -> Option<(u8, u8, u8, u8)> {
136    for p in props {
137        let match_name = match &p.name {
138            PropName::Ident(s) => s == name,
139            PropName::Fill => name == "fill",
140            PropName::Stroke => name == "stroke",
141            _ => false,
142        };
143        if !match_name {
144            continue;
145        }
146        match &p.value {
147            PropValue::Color { r, g, b, a } => return Some((*r, *g, *b, *a)),
148            PropValue::Expr(e) => {
149                if let Ok(Value::Color { r, g, b, a }) = eval_expr(ctx, e) {
150                    return Some((r, g, b, a));
151                }
152            }
153            _ => {}
154        }
155    }
156    None
157}
158
159/// Parse grid track list like "80 1fr 340" into (value, is_fr) per track.
160fn parse_grid_tracks(s: &str) -> Vec<(f32, bool)> {
161    let mut out = Vec::new();
162    for part in s.split_whitespace() {
163        let part = part.trim();
164        if part.is_empty() {
165            continue;
166        }
167        if part.ends_with("fr") {
168            let v: f32 = part[..part.len() - 2].trim().parse().unwrap_or(1.0);
169            out.push((v.max(0.0), true));
170        } else if let Ok(v) = part.parse::<f32>() {
171            out.push((v.max(0.0), false));
172        }
173    }
174    if out.is_empty() {
175        out.push((1.0, true));
176    }
177    out
178}
179
180fn get_child_width(ctx: &EvalContext, expr: &Expr) -> f32 {
181    match expr {
182        Expr::Element { props, .. } => get_prop_number(ctx, props, "width", 0.0),
183        _ => 0.0,
184    }
185}
186
187fn get_child_height(ctx: &EvalContext, expr: &Expr) -> f32 {
188    match expr {
189        Expr::Element { props, .. } => get_prop_number(ctx, props, "height", 0.0),
190        _ => 0.0,
191    }
192}
193
194/// Constrain rect to fit inside the given space with width/height = ratio (ratio > 0).
195fn constrain_aspect_ratio(rect: Rect, ratio: f32) -> Rect {
196    if ratio <= 0.0 {
197        return rect;
198    }
199    let (w, h) = (rect.w, rect.h);
200    let target_h = w / ratio;
201    if target_h <= h && target_h > 0.0 {
202        Rect::new(rect.x, rect.y, w, target_h)
203    } else {
204        let target_w = h * ratio;
205        Rect::new(rect.x, rect.y, target_w.min(w), h)
206    }
207}
208
209fn get_prop_string(ctx: &EvalContext, props: &[Prop], name: &str) -> Option<String> {
210    for p in props {
211        let match_name = match &p.name {
212            PropName::Ident(s) => s == name,
213            PropName::Content => name == "content",
214            PropName::Role => name == "role",
215            PropName::AriaLabel => name == "ariaLabel",
216            _ => name == "onClick" || name == "href" || name == "name",
217        };
218        if !match_name {
219            continue;
220        }
221        match &p.value {
222            PropValue::String(s) => return Some(s.clone()),
223            PropValue::Expr(e) => {
224                if let Ok(Value::String(s)) = eval_expr(ctx, e) {
225                    return Some(s);
226                }
227            }
228            _ => {}
229        }
230    }
231    None
232}
233
234fn expr_to_handler_string(expr: &Expr) -> String {
235    match expr {
236        Expr::Assignment { name, value, .. } => {
237            format!("{} = {}", name, expr_to_handler_string(value))
238        }
239        Expr::Binary { left, op, right, .. } => {
240            let op_str = match op {
241                BinaryOp::Add => "+",
242                BinaryOp::Sub => "-",
243                BinaryOp::Mul => "*",
244                BinaryOp::Div => "/",
245                BinaryOp::Mod => "%",
246                BinaryOp::Eq => "==",
247                BinaryOp::Ne => "!=",
248                BinaryOp::Lt => "<",
249                BinaryOp::Le => "<=",
250                BinaryOp::Gt => ">",
251                BinaryOp::Ge => ">=",
252                BinaryOp::And => "&&",
253                BinaryOp::Or => "||",
254            };
255            format!(
256                "{} {} {}",
257                expr_to_handler_string(left),
258                op_str,
259                expr_to_handler_string(right)
260            )
261        }
262        Expr::Ident(name, _) => name.clone(),
263        Expr::Literal(Literal::Number(n)) => {
264            if *n == (*n as i64) as f64 {
265                format!("{}", *n as i64)
266            } else {
267                format!("{}", n)
268            }
269        }
270        Expr::Literal(Literal::String(s)) => format!("\"{}\"", s),
271        Expr::Literal(Literal::Bool(b)) => format!("{}", b),
272        Expr::Block { stmts, .. } => {
273            let parts: Vec<String> = stmts
274                .iter()
275                .filter_map(|s| match s {
276                    Stmt::Expr(e) => Some(expr_to_handler_string(e)),
277                    _ => None,
278                })
279                .collect();
280            parts.join("; ")
281        }
282        Expr::Unary { op, inner, .. } => {
283            let op_str = match op {
284                UnaryOp::Not => "!",
285                UnaryOp::Neg => "-",
286            };
287            format!("{}{}", op_str, expr_to_handler_string(inner))
288        }
289        _ => String::new(),
290    }
291}
292
293fn get_prop_handler(props: &[Prop]) -> Option<String> {
294    for p in props {
295        let is_onclick = match &p.name {
296            PropName::Ident(s) => s == "onClick",
297            _ => false,
298        };
299        if !is_onclick {
300            continue;
301        }
302        match &p.value {
303            PropValue::String(s) => return Some(s.clone()),
304            PropValue::Expr(e) => return Some(expr_to_handler_string(e)),
305            _ => {}
306        }
307    }
308    None
309}
310
311fn get_prop_content_template(props: &[Prop]) -> Option<String> {
312    for p in props {
313        let is_content = match &p.name {
314            PropName::Content => true,
315            PropName::Ident(s) => s == "content",
316            _ => false,
317        };
318        if !is_content {
319            continue;
320        }
321        if let PropValue::Expr(Expr::InterpolatedString { parts, .. }) = &p.value {
322            let mut tpl = String::new();
323            for seg in parts {
324                match seg {
325                    InterpSegment::Literal(s) => tpl.push_str(s),
326                    InterpSegment::Expr(e) => {
327                        tpl.push('{');
328                        tpl.push_str(&expr_to_handler_string(e));
329                        tpl.push('}');
330                    }
331                }
332            }
333            return Some(tpl);
334        }
335    }
336    None
337}
338
339fn layout_kind_from_element(k: ElementKind) -> LayoutKind {
340    use ElementKind::*;
341    match k {
342        Header | Footer | Container | Sidebar | Section | Widget => LayoutKind::Column,
343        Accordion | Bento | Breadcrumb | Hamburger | Kebab | Meatballs | Doner
344        | Tabs | Pagination | LinkList | Nav | Form | Feed | Carousel => LayoutKind::Column,
345        Modal | Drawer | Popover => LayoutKind::Modal,
346        Card | Box | ConfirmDialog | Toast | Notification | Alert | MessageBox
347        | Tooltip | Loader | ProgressBar | Badge | Icon | Tag | Comment | Chart => LayoutKind::Box,
348        Text => LayoutKind::Text,
349        Row => LayoutKind::Row,
350        Column => LayoutKind::Column,
351        Grid => LayoutKind::Grid,
352        Stack => LayoutKind::Stack,
353        Center => LayoutKind::Center,
354        Spacer | Separator => LayoutKind::Spacer,
355        Image | Avatar | Skeleton | Rating => LayoutKind::Image,
356        Button => LayoutKind::Button,
357        Input | Password | Search | Select | Textarea | FileUpload | ColorPicker => LayoutKind::Input,
358        Checkbox | Radio | Dropdown | Combobox | Multiselect | DatePicker | Picker
359        | Slider | Stepper | Toggle => LayoutKind::Box,
360        Table | Timeline | TreeView | CommandPalette => LayoutKind::Column,
361        Splitter => LayoutKind::Row,
362    }
363}
364
365pub fn layout_tree(ctx: &EvalContext, expr: &Expr, rect: Rect) -> Result<LayoutNode, NewtError> {
366    layout_tree_with_viewport(ctx, expr, rect, rect)
367}
368
369fn layout_tree_with_viewport(
370    ctx: &EvalContext,
371    expr: &Expr,
372    rect: Rect,
373    viewport: Rect,
374) -> Result<LayoutNode, NewtError> {
375    match expr {
376        Expr::Element { kind, props, children, .. } => {
377            let layout_kind = layout_kind_from_element(*kind);
378            let aspect_ratio = get_prop_number(ctx, props, "aspectRatio", 0.0);
379            let rect = if aspect_ratio > 0.0 {
380                constrain_aspect_ratio(rect, aspect_ratio)
381            } else {
382                rect
383            };
384            let padding = get_prop_number(ctx, props, "padding", 0.0);
385            let gap = get_prop_number(ctx, props, "gap", 0.0);
386            let fill = get_prop_color(ctx, props, "fill");
387            let stroke = get_prop_color(ctx, props, "stroke");
388            let stroke_width = get_prop_number(ctx, props, "strokeWidth", 1.0);
389            let stroke_width = if stroke_width > 0.0 { Some(stroke_width) } else { None };
390            let radius = get_prop_number(ctx, props, "radius", 0.0);
391            let font_size = get_prop_number(ctx, props, "fontSize", 16.0);
392            let text = get_prop_string(ctx, props, "content");
393            let font_weight = get_prop_string(ctx, props, "fontWeight");
394            let shadow = get_prop_number(ctx, props, "shadow", 0.0);
395            let shadow = if shadow > 0.0 { Some(shadow) } else { None };
396            let transition_ms = get_prop_number(ctx, props, "transition", 0.0);
397            let transition_ms = if transition_ms > 0.0 { Some(transition_ms as i32 as f32) } else { None };
398            let role = get_prop_string(ctx, props, "role");
399            let aria_label = get_prop_string(ctx, props, "ariaLabel");
400            let focus_order = get_prop_number(ctx, props, "focusOrder", f32::NAN);
401            let focus_order = if !focus_order.is_nan() { Some(focus_order as i32) } else { None };
402            let on_click = get_prop_handler(props);
403            let content_template = get_prop_content_template(props);
404            let href = get_prop_string(ctx, props, "href");
405            let name = get_prop_string(ctx, props, "name");
406
407            let min_w = get_prop_number(ctx, props, "minWidth", 0.0);
408            let max_w = get_prop_number(ctx, props, "maxWidth", f32::MAX);
409            let min_h = get_prop_number(ctx, props, "minHeight", 0.0);
410            let max_h = get_prop_number(ctx, props, "maxHeight", f32::MAX);
411            let visible = (min_w <= 0.0 || viewport.w >= min_w)
412                && (max_w >= f32::MAX || viewport.w <= max_w)
413                && (min_h <= 0.0 || viewport.h >= min_h)
414                && (max_h >= f32::MAX || viewport.h <= max_h);
415            if !visible {
416                return Ok(LayoutNode::empty(layout_kind_from_element(*kind), rect));
417            }
418
419            let inner = Rect::new(
420                rect.x + padding,
421                rect.y + padding,
422                (rect.w - 2.0 * padding).max(0.0),
423                (rect.h - 2.0 * padding).max(0.0),
424            );
425
426            let child_nodes = match layout_kind {
427                LayoutKind::Row => {
428                    let mut nodes = Vec::new();
429                    let total_children = children.len();
430                    if total_children == 0 {
431                    } else {
432                        let mut fixed_w: f32 = 0.0;
433                        let mut flexible_count = 0usize;
434                        for child in children.iter() {
435                            let cw = get_child_width(ctx, child);
436                            if cw > 0.0 {
437                                fixed_w += cw;
438                            } else {
439                                flexible_count += 1;
440                            }
441                        }
442                        let total_gap = gap * (total_children as f32 - 1.0);
443                        let remaining_w = (inner.w - total_gap - fixed_w).max(0.0);
444                        let flexible_w = if flexible_count > 0 {
445                            (remaining_w - gap * (flexible_count as f32 - 1.0)).max(0.0) / flexible_count as f32
446                        } else {
447                            0.0
448                        };
449                        let mut x = inner.x;
450                        for child in children {
451                            let cw = get_child_width(ctx, child);
452                            let ch = get_child_height(ctx, child);
453                            let child_w = if cw > 0.0 {
454                                cw.min((inner.w - (x - inner.x)).max(0.0))
455                            } else {
456                                flexible_w
457                            };
458                            let child_h = if ch > 0.0 { ch.min(inner.h) } else { inner.h };
459                            let child_rect = Rect::new(x, inner.y, child_w, child_h);
460                            nodes.push(layout_tree_with_viewport(ctx, child, child_rect, viewport)?);
461                            x += child_w + gap;
462                        }
463                    }
464                    nodes
465                }
466                LayoutKind::Column => {
467                    let mut nodes = Vec::new();
468                    let total_children = children.len();
469                    if total_children == 0 {
470                    } else {
471                        let mut fixed_h: f32 = 0.0;
472                        let mut flexible_count = 0usize;
473                        for child in children.iter() {
474                            let ch = get_child_height(ctx, child);
475                            if ch > 0.0 {
476                                fixed_h += ch;
477                            } else {
478                                flexible_count += 1;
479                            }
480                        }
481                        let total_gap = gap * (total_children as f32 - 1.0);
482                        let remaining_h = (inner.h - total_gap - fixed_h).max(0.0);
483                        let flexible_h = if flexible_count > 0 {
484                            (remaining_h - gap * (flexible_count as f32 - 1.0)).max(0.0) / flexible_count as f32
485                        } else {
486                            0.0
487                        };
488                        let mut y = inner.y;
489                        for child in children {
490                            let cw = get_child_width(ctx, child);
491                            let ch = get_child_height(ctx, child);
492                            let child_w = if cw > 0.0 { cw.min(inner.w) } else { inner.w };
493                            let child_h = if ch > 0.0 {
494                                ch.min((inner.h - (y - inner.y)).max(0.0))
495                            } else {
496                                flexible_h
497                            };
498                            let child_rect = Rect::new(inner.x, y, child_w, child_h);
499                            nodes.push(layout_tree_with_viewport(ctx, child, child_rect, viewport)?);
500                            y += child_h + gap;
501                        }
502                    }
503                    nodes
504                }
505                LayoutKind::Grid => {
506                    let mut nodes = Vec::new();
507                    let columns_str = get_prop_string(ctx, props, "columns").unwrap_or_else(|| "1fr".to_string());
508                    let col_specs = parse_grid_tracks(&columns_str);
509                    if col_specs.is_empty() || children.is_empty() {
510                    } else {
511                        let num_cols = col_specs.len();
512                        let fixed_w: f32 = col_specs.iter().filter(|(_, fr)| !fr).map(|(v, _)| *v).sum();
513                        let fr_total: f32 = col_specs.iter().filter(|(_, fr)| *fr).map(|(v, _)| *v).sum();
514                        let remaining_w = (inner.w - fixed_w).max(0.0);
515                        let col_widths: Vec<f32> = col_specs
516                            .iter()
517                            .map(|(v, fr)| if *fr { remaining_w * v / fr_total.max(1.0) } else { *v })
518                            .collect();
519                        let num_rows = (children.len() + num_cols - 1) / num_cols;
520                        let row_h = if num_rows > 0 {
521                            (inner.h / num_rows as f32).max(0.0)
522                        } else {
523                            0.0
524                        };
525                        let col_offsets: Vec<f32> = (0..num_cols)
526                            .map(|i| inner.x + col_widths[..i].iter().sum::<f32>())
527                            .collect();
528                        for (i, child) in children.iter().enumerate() {
529                            let row = i / num_cols;
530                            let col = i % num_cols;
531                            let x = col_offsets[col];
532                            let child_rect = Rect::new(x, inner.y + row as f32 * row_h, col_widths[col], row_h);
533                            nodes.push(layout_tree_with_viewport(ctx, child, child_rect, viewport)?);
534                        }
535                    }
536                    nodes
537                }
538                LayoutKind::Modal => {
539                    let mut nodes = Vec::new();
540                    if children.is_empty() {
541                    } else if children.len() == 1 {
542                        nodes.push(layout_tree_with_viewport(ctx, &children[0], inner, viewport)?);
543                    } else {
544                        nodes.push(layout_tree_with_viewport(ctx, &children[0], inner, viewport)?);
545                        let content_w = (inner.w * 0.8).min(400.0);
546                        let content_h = (inner.h * 0.6).min(300.0);
547                        let cx = inner.x + (inner.w - content_w) / 2.0;
548                        let cy = inner.y + (inner.h - content_h) / 2.0;
549                        let content_rect = Rect::new(cx, cy, content_w, content_h);
550                        nodes.push(layout_tree_with_viewport(ctx, &children[1], content_rect, viewport)?);
551                    }
552                    nodes
553                }
554                LayoutKind::Stack | LayoutKind::Center | LayoutKind::Box | LayoutKind::Button | LayoutKind::Input => {
555                    let mut nodes = Vec::new();
556                    for child in children {
557                        let r = if layout_kind == LayoutKind::Center && children.len() == 1 {
558                            inner
559                        } else {
560                            inner
561                        };
562                        nodes.push(layout_tree_with_viewport(ctx, child, r, viewport)?);
563                    }
564                    nodes
565                }
566                LayoutKind::Text | LayoutKind::Spacer | LayoutKind::Image => Vec::new(),
567            };
568
569            Ok(LayoutNode {
570                kind: layout_kind,
571                rect,
572                fill,
573                stroke,
574                stroke_width,
575                radius,
576                text,
577                content_template,
578                font_size,
579                font_weight,
580                shadow,
581                transition_ms,
582                role,
583                aria_label,
584                focus_order,
585                on_click,
586                href,
587                name,
588                aspect_ratio: if aspect_ratio > 0.0 { Some(aspect_ratio) } else { None },
589                children: child_nodes,
590            })
591        }
592        Expr::Call {
593            callee,
594            args,
595            slot_args,
596            span,
597            ..
598        } => {
599            let comp = ctx
600                .components
601                .get(callee)
602                .ok_or_else(|| NewtError::semantic(*span, format!("unknown component '{}'", callee)))?;
603            let body = if let Some(ref slots) = slot_args {
604                crate::ast::substitute_slots(&comp.body, slots)
605            } else {
606                comp.body.clone()
607            };
608            let mut new_ctx = EvalContext {
609                variables: ctx.variables.clone(),
610                components: ctx.components.clone(),
611            };
612            for (i, param) in comp.params.iter().enumerate() {
613                if let Some(arg) = args.get(i) {
614                    if let Ok(v) = eval_expr(ctx, arg) {
615                        new_ctx.variables.insert(param.clone(), v);
616                    }
617                }
618            }
619            layout_tree_with_viewport(&new_ctx, &body, rect, viewport)
620        }
621        Expr::If {
622            cond,
623            then_branch,
624            else_branch,
625            ..
626        } => {
627            let cond_val = eval_expr(ctx, cond)?;
628            let branch = if cond_val.as_bool().unwrap_or(false) {
629                then_branch.as_ref()
630            } else if let Some(eb) = else_branch {
631                eb.as_ref()
632            } else {
633                return Ok(LayoutNode::empty(LayoutKind::Box, rect));
634            };
635            layout_tree_with_viewport(ctx, branch, rect, viewport)
636        }
637        Expr::For { var, iter, body, span, .. } => {
638            let iter_val = eval_expr(ctx, iter)?;
639            let arr = iter_val.as_array().map_err(|_| {
640                NewtError::semantic(*span, "for loop expects an array or range(n)")
641            })?;
642            let mut child_nodes = Vec::new();
643            let n = arr.len();
644            if n == 0 {
645                return Ok(LayoutNode::empty(LayoutKind::Column, rect));
646            }
647            let inner_h = rect.h.max(0.0) / n as f32;
648            let mut y = rect.y;
649            for val in arr.iter() {
650                let mut new_ctx = EvalContext {
651                    variables: ctx.variables.clone(),
652                    components: ctx.components.clone(),
653                };
654                new_ctx.variables.insert(var.clone(), val.clone());
655                let child_rect = Rect::new(rect.x, y, rect.w, inner_h);
656                child_nodes.push(layout_tree_with_viewport(&new_ctx, body.as_ref(), child_rect, viewport)?);
657                y += inner_h;
658            }
659            let mut node = LayoutNode::empty(LayoutKind::Column, rect);
660            node.children = child_nodes;
661            Ok(node)
662        }
663        Expr::Block { stmts, .. } => {
664            // First pass: evaluate Let and StateDecl into a cloned context
665            let mut block_ctx = EvalContext {
666                variables: ctx.variables.clone(),
667                components: ctx.components.clone(),
668            };
669            for s in stmts {
670                match s {
671                    Stmt::Let { name, value, .. } => {
672                        if let Ok(val) = eval_expr(&block_ctx, value) {
673                            block_ctx.variables.insert(name.clone(), val);
674                        }
675                    }
676                    Stmt::StateDecl(sd) => {
677                        if let Ok(val) = eval_expr(&block_ctx, &sd.initial_value) {
678                            block_ctx.variables.insert(sd.name.clone(), val);
679                        }
680                    }
681                    _ => {}
682                }
683            }
684            // Second pass: layout Expr stmts using enriched context
685            let mut child_nodes = Vec::new();
686            let total = stmts.iter().filter(|s| matches!(s, Stmt::Expr(_))).count();
687            if total == 0 {
688                return Ok(LayoutNode::empty(LayoutKind::Box, rect));
689            }
690            let inner_h = rect.h.max(0.0) / total as f32;
691            let mut y = rect.y;
692            for s in stmts {
693                if let Stmt::Expr(e) = s {
694                    let child_rect = Rect::new(rect.x, y, rect.w, inner_h);
695                    child_nodes.push(layout_tree_with_viewport(&block_ctx, e, child_rect, viewport)?);
696                    y += inner_h;
697                }
698            }
699            let mut node = LayoutNode::empty(LayoutKind::Column, rect);
700            node.children = child_nodes;
701            Ok(node)
702        }
703        _ => Ok(LayoutNode::empty(LayoutKind::Box, rect)),
704    }
705}