Skip to main content

newter_compiler/
react.rs

1//! Generate React JSX from the Newt layout tree.
2//!
3//! Maps Newt elements to HTML/React equivalents with inline styles.
4//! The output is a self-contained React component.
5
6use crate::ast::ProgramItem;
7use crate::layout::{LayoutKind, LayoutNode};
8use crate::value::{eval_expr, EvalContext, Value};
9use crate::Program;
10use std::fmt::Write;
11
12fn hex(r: u8, g: u8, b: u8, a: u8) -> String {
13    if a == 255 {
14        format!("#{:02x}{:02x}{:02x}", r, g, b)
15    } else {
16        format!("rgba({}, {}, {}, {:.2})", r, g, b, a as f32 / 255.0)
17    }
18}
19
20fn escape_jsx(s: &str) -> String {
21    s.replace('{', "\\{").replace('}', "\\}")
22}
23
24/// Generate a complete React component from a Newt program.
25pub fn layout_to_react(
26    program: &Program,
27    root: &LayoutNode,
28) -> String {
29    let mut out = String::new();
30
31    // Collect state variables
32    let mut state_vars: Vec<(String, String)> = Vec::new();
33    let ctx = EvalContext::from_program(program);
34    for item in &program.items {
35        if let ProgramItem::StateDecl(sd) = item {
36            let js_val = match eval_expr(&ctx, &sd.initial_value) {
37                Ok(Value::Number(n)) => {
38                    if n == (n as i64) as f64 {
39                        format!("{}", n as i64)
40                    } else {
41                        format!("{}", n)
42                    }
43                }
44                Ok(Value::String(s)) => format!("\"{}\"", s),
45                Ok(Value::Bool(b)) => format!("{}", b),
46                Ok(Value::Color { r, g, b, a }) => format!("\"{}\"", hex(r, g, b, a)),
47                _ => "null".to_string(),
48            };
49            state_vars.push((sd.name.clone(), js_val));
50        }
51    }
52
53    // Imports
54    out.push_str("import React");
55    if !state_vars.is_empty() {
56        out.push_str(", { useState }");
57    }
58    out.push_str(" from 'react';\n\n");
59
60    // Component
61    out.push_str("export default function NewtApp() {\n");
62
63    // State declarations
64    for (name, val) in &state_vars {
65        let capitalized = format!("{}{}", &name[..1].to_uppercase(), &name[1..]);
66        writeln!(out, "  const [{name}, set{capitalized}] = useState({val});").unwrap();
67    }
68    if !state_vars.is_empty() {
69        out.push('\n');
70    }
71
72    out.push_str("  return (\n");
73    emit_jsx(&mut out, root, 2, &state_vars);
74    out.push_str("  );\n");
75    out.push_str("}\n");
76
77    out
78}
79
80fn indent(out: &mut String, depth: usize) {
81    for _ in 0..depth {
82        out.push_str("  ");
83    }
84}
85
86fn emit_jsx(out: &mut String, n: &LayoutNode, depth: usize, state_vars: &[(String, String)]) {
87    let tag = jsx_tag(n);
88    let style = jsx_style(n);
89    let mut extra_props = String::new();
90
91    // Handle onClick for state mutations
92    if let Some(ref onclick) = n.on_click {
93        let handler = convert_onclick(onclick, state_vars);
94        write!(extra_props, " onClick={{() => {{ {} }}}}", handler).unwrap();
95    }
96
97    // Self-closing tags for leaf elements
98    let is_leaf = n.children.is_empty() && n.text.is_none();
99
100    indent(out, depth);
101    if is_leaf {
102        writeln!(out, "<{tag} style={{{{{style}}}}}{extra_props} />").unwrap();
103    } else if let Some(ref text) = n.text {
104        // Text content with possible interpolation
105        let content = convert_interpolation(text, state_vars);
106        writeln!(out, "<{tag} style={{{{{style}}}}}{extra_props}>{content}</{tag}>").unwrap();
107    } else {
108        writeln!(out, "<{tag} style={{{{{style}}}}}{extra_props}>").unwrap();
109        for child in &n.children {
110            emit_jsx(out, child, depth + 1, state_vars);
111        }
112        indent(out, depth);
113        writeln!(out, "</{tag}>").unwrap();
114    }
115}
116
117fn jsx_tag(n: &LayoutNode) -> &'static str {
118    match n.kind {
119        LayoutKind::Button => "button",
120        LayoutKind::Input => "input",
121        LayoutKind::Image => "img",
122        _ => "div",
123    }
124}
125
126fn jsx_style(n: &LayoutNode) -> String {
127    let mut parts: Vec<String> = Vec::new();
128
129    // Layout
130    match n.kind {
131        LayoutKind::Row => {
132            parts.push("display: 'flex'".into());
133            parts.push("flexDirection: 'row'".into());
134        }
135        LayoutKind::Column => {
136            parts.push("display: 'flex'".into());
137            parts.push("flexDirection: 'column'".into());
138        }
139        LayoutKind::Center => {
140            parts.push("display: 'flex'".into());
141            parts.push("alignItems: 'center'".into());
142            parts.push("justifyContent: 'center'".into());
143        }
144        LayoutKind::Grid => {
145            parts.push("display: 'grid'".into());
146        }
147        LayoutKind::Stack => {
148            parts.push("position: 'relative'".into());
149        }
150        _ => {}
151    }
152
153    // Gap (from rect spacing — approximate from children positions)
154    // We encode gap in the layout, but for JSX we use the gap property
155    // This is a simplification; the actual gap is computed during layout
156
157    // Fill
158    if let Some((r, g, b, a)) = n.fill {
159        parts.push(format!("background: '{}'", hex(r, g, b, a)));
160    }
161
162    // Stroke
163    if let Some((r, g, b, a)) = n.stroke {
164        let w = n.stroke_width.unwrap_or(1.0).max(0.0);
165        parts.push(format!("border: '{w}px solid {}'", hex(r, g, b, a)));
166    }
167
168    // Radius
169    if n.radius > 0.0 {
170        parts.push(format!("borderRadius: {}", n.radius as i32));
171    }
172
173    // Typography
174    if n.font_size > 0.0 && n.font_size != 16.0 {
175        parts.push(format!("fontSize: {}", n.font_size as i32));
176    }
177    if let Some(ref w) = n.font_weight {
178        parts.push(format!("fontWeight: '{w}'"));
179    }
180
181    // Shadow
182    if let Some(sh) = n.shadow {
183        if sh > 0.0 {
184            parts.push(format!(
185                "boxShadow: '0 {}px {}px rgba(0,0,0,0.15)'",
186                sh as i32,
187                (sh * 1.5) as i32
188            ));
189        }
190    }
191
192    // Padding (approximated from the rect)
193    parts.push("padding: 8".into());
194
195    parts.join(", ")
196}
197
198/// Convert Newt onClick expressions to React state setters.
199/// e.g. "count = count + 1" -> "setCount(count + 1)"
200fn convert_onclick(expr: &str, state_vars: &[(String, String)]) -> String {
201    let mut result = Vec::new();
202    for stmt in expr.split(';') {
203        let stmt = stmt.trim();
204        if stmt.is_empty() {
205            continue;
206        }
207        if let Some(eq_pos) = stmt.find('=') {
208            let lhs = stmt[..eq_pos].trim();
209            let rhs = stmt[eq_pos + 1..].trim();
210            // Check if lhs is a state variable
211            let is_state = state_vars.iter().any(|(name, _)| name == lhs);
212            if is_state {
213                let capitalized = format!("{}{}", &lhs[..1].to_uppercase(), &lhs[1..]);
214                // Handle negation: !varName
215                if rhs.starts_with('!') {
216                    result.push(format!("set{}(!{})", capitalized, &rhs[1..]));
217                } else {
218                    result.push(format!("set{}({})", capitalized, rhs));
219                }
220            }
221        }
222    }
223    result.join("; ")
224}
225
226/// Convert Newt string interpolation {expr} to JSX template literals.
227/// e.g. "Count: {count}" -> `Count: ${count}`
228fn convert_interpolation(text: &str, _state_vars: &[(String, String)]) -> String {
229    if text.contains('{') {
230        let converted = text
231            .replace('{', "${")
232            .replace("\\${", "{"); // Handle escaped braces
233        format!("`{}`", converted)
234    } else {
235        text.to_string()
236    }
237}