Skip to main content

crepuscularity_lvgl/
lib.rs

1use std::path::Path;
2
3use crepuscularity_core::ast::*;
4use crepuscularity_core::context::{value_to_str, TemplateContext, TemplateValue};
5use crepuscularity_core::eval::eval_expr;
6use crepuscularity_core::include_paths::resolve_include_path;
7use crepuscularity_core::parser::{parse_component_file, parse_template};
8use crepuscularity_core::virtual_files::lookup_virtual_file;
9pub use crepuscularity_core::CrepusError;
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct LvglOptions {
13    pub name: String,
14    pub root: LvglRoot,
15}
16
17impl Default for LvglOptions {
18    fn default() -> Self {
19        Self {
20            name: "CrepusView".into(),
21            root: LvglRoot::Component,
22        }
23    }
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum LvglRoot {
28    Component,
29    Screen,
30}
31
32pub fn render_template_to_lvgl_xml(
33    template: &str,
34    ctx: &TemplateContext,
35) -> Result<String, CrepusError> {
36    render_template_to_lvgl_xml_with_options(template, ctx, &LvglOptions::default())
37}
38
39pub fn render_template_to_lvgl_xml_with_options(
40    template: &str,
41    ctx: &TemplateContext,
42    options: &LvglOptions,
43) -> Result<String, CrepusError> {
44    let nodes = parse_template(template)?;
45    render_nodes_to_lvgl_xml(&nodes, ctx, options)
46}
47
48pub fn render_component_file_to_lvgl_xml(
49    content: &str,
50    component_name: &str,
51    ctx: &TemplateContext,
52) -> Result<String, CrepusError> {
53    let file = parse_component_file(content)?;
54    let component = file
55        .components
56        .get(component_name)
57        .ok_or_else(|| CrepusError::render(format!("component not found: {component_name}")))?;
58    let mut child_ctx = lvgl_context(ctx);
59    for (key, expr) in &component.meta.defaults {
60        child_ctx
61            .vars
62            .entry(key.clone())
63            .or_insert(eval_expr(expr, &TemplateContext::new())?);
64    }
65    render_nodes_to_lvgl_xml(
66        &component.nodes,
67        &child_ctx,
68        &LvglOptions {
69            name: component_name.into(),
70            root: LvglRoot::Component,
71        },
72    )
73}
74
75pub fn render_nodes_to_lvgl_xml(
76    nodes: &[Node],
77    ctx: &TemplateContext,
78    options: &LvglOptions,
79) -> Result<String, CrepusError> {
80    let mut out = String::new();
81    let tag = match options.root {
82        LvglRoot::Component => "component",
83        LvglRoot::Screen => "screen",
84    };
85    out.push('<');
86    out.push_str(tag);
87    out.push_str(" name=\"");
88    push_xml_attr(&mut out, &options.name);
89    out.push_str("\">\n  <view>\n");
90    let ctx = lvgl_context(ctx);
91    for node in render_nodes_list(nodes, &ctx)? {
92        write_node(&mut out, &node, 4);
93    }
94    out.push_str("  </view>\n</");
95    out.push_str(tag);
96    out.push_str(">\n");
97    Ok(out)
98}
99
100pub fn lvgl_context(ctx: &TemplateContext) -> TemplateContext {
101    let mut c = ctx.clone();
102    c.vars
103        .insert("crepus_target".into(), TemplateValue::Str("lvgl".into()));
104    c.vars.insert("is_lvgl".into(), TemplateValue::Bool(true));
105    c.vars
106        .insert("is_embedded".into(), TemplateValue::Bool(true));
107    c.vars.insert("is_tui".into(), TemplateValue::Bool(false));
108    c.vars.insert("is_web".into(), TemplateValue::Bool(false));
109    c.vars.insert("is_gui".into(), TemplateValue::Bool(false));
110    c
111}
112
113#[derive(Debug, Clone, PartialEq, Eq)]
114struct XmlNode {
115    tag: String,
116    attrs: Vec<(String, String)>,
117    children: Vec<XmlNode>,
118}
119
120fn render_nodes_list(nodes: &[Node], ctx: &TemplateContext) -> Result<Vec<XmlNode>, CrepusError> {
121    let mut ctx = ctx.clone();
122    let mut out = Vec::new();
123    for node in nodes {
124        match node {
125            Node::LetDecl(decl) => {
126                if !(decl.is_default && ctx.vars.contains_key(&decl.name)) {
127                    ctx.vars
128                        .insert(decl.name.clone(), eval_expr(&decl.expr, &ctx)?);
129                }
130            }
131            Node::If(block) => {
132                let body = if ctx.eval_condition(&block.condition)? {
133                    &block.then_children
134                } else {
135                    block.else_children.as_deref().unwrap_or(&[])
136                };
137                out.extend(render_nodes_list(body, &ctx)?);
138            }
139            Node::For(block) => {
140                for item_ctx in ctx.get_list(&block.iterator) {
141                    let mut loop_ctx = ctx.clone();
142                    for (key, value) in &item_ctx.vars {
143                        loop_ctx.vars.insert(key.clone(), value.clone());
144                    }
145                    let pattern = block.pattern.trim();
146                    if !pattern.is_empty() {
147                        let item_value = item_ctx.get_str("value");
148                        if !item_value.is_empty() {
149                            loop_ctx
150                                .vars
151                                .insert(pattern.to_string(), TemplateValue::Str(item_value));
152                        }
153                    }
154                    out.extend(render_nodes_list(&block.body, &loop_ctx)?);
155                }
156            }
157            Node::Match(block) => out.extend(render_match(block, &ctx)?),
158            Node::Element(el) => out.push(render_element(el, &ctx)?),
159            Node::Text(parts) => out.push(label_node(render_text_inline(parts, &ctx)?)),
160            Node::RawText(expr) | Node::RawHtml(expr) => {
161                out.push(label_node(value_to_str(&eval_expr(expr, &ctx)?)));
162            }
163            Node::Include(inc) => {
164                let (inner, inner_ctx) = expand_include(inc, &ctx)?;
165                out.extend(render_nodes_list(&inner, &inner_ctx)?);
166            }
167            Node::Embed(_) => {}
168        }
169    }
170    Ok(out)
171}
172
173fn render_match(block: &MatchBlock, ctx: &TemplateContext) -> Result<Vec<XmlNode>, CrepusError> {
174    let value = value_to_str(&eval_expr(&block.expr, ctx)?);
175    for arm in &block.arms {
176        let pattern = arm.pattern.trim();
177        if pattern == "_"
178            || (pattern.starts_with('"')
179                && pattern.ends_with('"')
180                && value == pattern[1..pattern.len() - 1])
181            || value == pattern
182        {
183            return render_nodes_list(&arm.body, ctx);
184        }
185    }
186    Ok(Vec::new())
187}
188
189fn render_element(el: &Element, ctx: &TemplateContext) -> Result<XmlNode, CrepusError> {
190    if el.tag == "slot" {
191        let children = if let Some((nodes, slot_ctx)) = &ctx.slot {
192            render_nodes_list(nodes, slot_ctx)?
193        } else {
194            render_nodes_list(&el.children, ctx)?
195        };
196        return Ok(XmlNode {
197            tag: "lv_obj".into(),
198            attrs: Vec::new(),
199            children,
200        });
201    }
202
203    let mut classes = el.classes.clone();
204    for class in &el.conditional_classes {
205        if ctx.eval_condition(&class.condition)? {
206            classes.push(class.class.clone());
207        }
208    }
209
210    let mut children = render_nodes_list(&el.children, ctx)?;
211    let text = take_text_child(&mut children);
212    let mut attrs = Vec::new();
213    if let Some(id) = &el.id {
214        attrs.push(("id".into(), id.clone()));
215    }
216    apply_class_attrs(&classes, &mut attrs);
217    for binding in &el.bindings {
218        attrs.push((
219            lvgl_attr_name(&binding.prop),
220            eval_binding(&binding.value, ctx)?,
221        ));
222    }
223    for handler in &el.event_handlers {
224        attrs.push((
225            format!("data_on_{}", handler.event),
226            handler.handler.clone(),
227        ));
228    }
229
230    let tag = map_tag(&el.tag, text.as_deref(), children.is_empty());
231    if let Some(text) = text {
232        if tag == "lv_button" {
233            children.insert(0, label_node(text));
234        } else {
235            attrs.push(("text".into(), text));
236        }
237    }
238    Ok(XmlNode {
239        tag,
240        attrs,
241        children,
242    })
243}
244
245fn render_text_inline(parts: &[TextPart], ctx: &TemplateContext) -> Result<String, CrepusError> {
246    let mut out = String::new();
247    for part in parts {
248        match part {
249            TextPart::Literal(s) => out.push_str(&ctx.interpolate(s)?),
250            TextPart::Expr(expr) => {
251                out.push_str(&value_to_str(&eval_expr(expr, ctx)?));
252            }
253        }
254    }
255    Ok(out)
256}
257
258fn read_file(ctx: &TemplateContext, path: &Path) -> Result<String, CrepusError> {
259    if let Some(content) = lookup_virtual_file(ctx, path) {
260        return Ok(content);
261    }
262    std::fs::read_to_string(path)
263        .map_err(|e| CrepusError::render(format!("include error: {:?}: {}", path, e)))
264}
265
266fn expand_include(
267    inc: &IncludeNode,
268    ctx: &TemplateContext,
269) -> Result<(Vec<Node>, TemplateContext), CrepusError> {
270    if let Some((file_part, comp_name)) = inc.path.split_once('#') {
271        return expand_named_component(inc, ctx, file_part, comp_name);
272    }
273
274    let file_path = resolve_include_path(ctx.base_dir.as_deref(), &inc.path)?;
275    let content = read_file(ctx, &file_path)?;
276    let nodes = parse_template(&content)
277        .map_err(|e| CrepusError::render(format!("include parse error: {e}")))?;
278
279    let mut child_ctx = TemplateContext::new();
280    child_ctx.base_dir = file_path.parent().map(|p| p.to_path_buf());
281    child_ctx.virtual_files = ctx.virtual_files.clone();
282    for (key, expr) in &inc.props {
283        child_ctx.vars.insert(key.clone(), eval_expr(expr, ctx)?);
284    }
285    if !inc.slot.is_empty() {
286        child_ctx.slot = Some((inc.slot.clone(), Box::new(ctx.clone())));
287    }
288
289    Ok((nodes, lvgl_context(&child_ctx)))
290}
291
292fn expand_named_component(
293    inc: &IncludeNode,
294    ctx: &TemplateContext,
295    file_part: &str,
296    comp_name: &str,
297) -> Result<(Vec<Node>, TemplateContext), CrepusError> {
298    let file_path = resolve_include_path(ctx.base_dir.as_deref(), file_part)?;
299    let content = read_file(ctx, &file_path)?;
300    let comp_file = parse_component_file(&content)
301        .map_err(|e| CrepusError::render(format!("component file parse error: {e}")))?;
302    let comp = comp_file.components.get(comp_name).ok_or_else(|| {
303        CrepusError::render(format!("component '{comp_name}' not found in {file_part}"))
304    })?;
305
306    let mut child_ctx = TemplateContext::new();
307    child_ctx.base_dir = file_path.parent().map(|p| p.to_path_buf());
308    child_ctx.virtual_files = ctx.virtual_files.clone();
309    for (key, expr) in &comp.meta.defaults {
310        child_ctx
311            .vars
312            .entry(key.clone())
313            .or_insert(eval_expr(expr, &TemplateContext::new())?);
314    }
315    for (key, expr) in &inc.props {
316        child_ctx.vars.insert(key.clone(), eval_expr(expr, ctx)?);
317    }
318    if !inc.slot.is_empty() {
319        child_ctx.slot = Some((inc.slot.clone(), Box::new(ctx.clone())));
320    }
321
322    Ok((comp.nodes.clone(), lvgl_context(&child_ctx)))
323}
324
325fn map_tag(tag: &str, text: Option<&str>, childless: bool) -> String {
326    match tag {
327        "button" => "lv_button",
328        "input" | "textarea" => "lv_textarea",
329        "img" | "image" => "lv_image",
330        "progress" | "meter" => "lv_bar",
331        "slider" => "lv_slider",
332        "checkbox" => "lv_checkbox",
333        "switch" => "lv_switch",
334        "select" | "dropdown" => "lv_dropdown",
335        "canvas" => "lv_canvas",
336        "span" | "p" | "label" | "h1" | "h2" | "h3" if text.is_some() && childless => "lv_label",
337        _ => "lv_obj",
338    }
339    .into()
340}
341
342fn label_node(text: String) -> XmlNode {
343    XmlNode {
344        tag: "lv_label".into(),
345        attrs: vec![("text".into(), text)],
346        children: Vec::new(),
347    }
348}
349
350fn take_text_child(children: &mut Vec<XmlNode>) -> Option<String> {
351    if children.len() == 1 && children[0].tag == "lv_label" && children[0].children.is_empty() {
352        if let Some(pos) = children[0].attrs.iter().position(|(key, _)| key == "text") {
353            let (_, text) = children[0].attrs.remove(pos);
354            children.clear();
355            return Some(text);
356        }
357    }
358    None
359}
360
361fn eval_binding(value: &str, ctx: &TemplateContext) -> Result<String, CrepusError> {
362    let trimmed = value.trim();
363    if trimmed.starts_with('{') && trimmed.ends_with('}') && trimmed.len() >= 2 {
364        Ok(value_to_str(&eval_expr(
365            &trimmed[1..trimmed.len() - 1],
366            ctx,
367        )?))
368    } else if ctx.get(trimmed).is_some() {
369        Ok(value_to_str(&eval_expr(trimmed, ctx)?))
370    } else {
371        ctx.interpolate(trimmed)
372            .map_err(|e| CrepusError::render(e.to_string()))
373    }
374}
375
376fn lvgl_attr_name(name: &str) -> String {
377    match name {
378        "class" => "class".into(),
379        "src" => "src".into(),
380        "value" => "value".into(),
381        "placeholder" => "placeholder_text".into(),
382        "disabled" => "disabled".into(),
383        other => other.replace('-', "_"),
384    }
385}
386
387fn apply_class_attrs(classes: &[String], attrs: &mut Vec<(String, String)>) {
388    for class in classes {
389        match class.as_str() {
390            "flex" => attrs.push(("layout".into(), "flex".into())),
391            "flex-row" => attrs.push(("flex_flow".into(), "row".into())),
392            "flex-col" => attrs.push(("flex_flow".into(), "column".into())),
393            "items-center" => attrs.push(("flex_cross_place".into(), "center".into())),
394            "items-end" => attrs.push(("flex_cross_place".into(), "end".into())),
395            "justify-center" => attrs.push(("flex_main_place".into(), "center".into())),
396            "justify-end" => attrs.push(("flex_main_place".into(), "end".into())),
397            "justify-between" => attrs.push(("flex_track_place".into(), "space_between".into())),
398            "hidden" => attrs.push(("hidden".into(), "true".into())),
399            "font-bold" => attrs.push(("text_font".into(), "montserrat_16".into())),
400            "text-center" => attrs.push(("text_align".into(), "center".into())),
401            "rounded" | "rounded-md" => attrs.push(("radius".into(), "6".into())),
402            "rounded-lg" => attrs.push(("radius".into(), "8".into())),
403            "rounded-full" => attrs.push(("radius".into(), "100%".into())),
404            "border" => attrs.push(("border_width".into(), "1".into())),
405            "w-full" => attrs.push(("width".into(), "100%".into())),
406            "h-full" => attrs.push(("height".into(), "100%".into())),
407            _ => apply_prefixed_class(class, attrs),
408        }
409    }
410}
411
412fn apply_prefixed_class(class: &str, attrs: &mut Vec<(String, String)>) {
413    if let Some(value) = class.strip_prefix("w-") {
414        if let Some(px) = spacing_px(value) {
415            attrs.push(("width".into(), px));
416        }
417    } else if let Some(value) = class.strip_prefix("h-") {
418        if let Some(px) = spacing_px(value) {
419            attrs.push(("height".into(), px));
420        }
421    } else if let Some(value) = class.strip_prefix("p-") {
422        if let Some(px) = spacing_px(value) {
423            attrs.push(("pad_all".into(), px));
424        }
425    } else if let Some(value) = class.strip_prefix("px-") {
426        if let Some(px) = spacing_px(value) {
427            attrs.push(("pad_left".into(), px.clone()));
428            attrs.push(("pad_right".into(), px));
429        }
430    } else if let Some(value) = class.strip_prefix("py-") {
431        if let Some(px) = spacing_px(value) {
432            attrs.push(("pad_top".into(), px.clone()));
433            attrs.push(("pad_bottom".into(), px));
434        }
435    } else if let Some(value) = class.strip_prefix("gap-") {
436        if let Some(px) = spacing_px(value) {
437            attrs.push(("style_pad_gap".into(), px));
438        }
439    } else if let Some(value) = class.strip_prefix("bg-") {
440        if let Some(color) = color_value(value) {
441            attrs.push(("bg_color".into(), color));
442        }
443    } else if let Some(value) = class.strip_prefix("text-") {
444        if let Some(color) = color_value(value) {
445            attrs.push(("text_color".into(), color));
446        } else if let Some(size) = font_size(value) {
447            attrs.push(("text_font".into(), size));
448        }
449    } else if let Some(value) = class.strip_prefix("border-") {
450        if let Some(color) = color_value(value) {
451            attrs.push(("border_color".into(), color));
452        }
453    }
454}
455
456fn spacing_px(value: &str) -> Option<String> {
457    match value {
458        "0" => Some("0".into()),
459        "1" => Some("4".into()),
460        "2" => Some("8".into()),
461        "3" => Some("12".into()),
462        "4" => Some("16".into()),
463        "5" => Some("20".into()),
464        "6" => Some("24".into()),
465        "8" => Some("32".into()),
466        "10" => Some("40".into()),
467        "12" => Some("48".into()),
468        "16" => Some("64".into()),
469        "20" => Some("80".into()),
470        "24" => Some("96".into()),
471        "32" => Some("128".into()),
472        _ if value.ends_with("px") => Some(value.trim_end_matches("px").into()),
473        _ => None,
474    }
475}
476
477fn font_size(value: &str) -> Option<String> {
478    match value {
479        "xs" | "sm" => Some("montserrat_12".into()),
480        "base" => Some("montserrat_14".into()),
481        "lg" | "xl" | "2xl" => Some("montserrat_16".into()),
482        _ => None,
483    }
484}
485
486fn color_value(value: &str) -> Option<String> {
487    match value {
488        "black" => Some("#000000".into()),
489        "white" => Some("#ffffff".into()),
490        "transparent" => Some("#000000".into()),
491        "red-500" => Some("#ef4444".into()),
492        "green-500" => Some("#22c55e".into()),
493        "blue-500" => Some("#3b82f6".into()),
494        "yellow-500" => Some("#eab308".into()),
495        "zinc-50" => Some("#fafafa".into()),
496        "zinc-100" => Some("#f4f4f5".into()),
497        "zinc-400" => Some("#a1a1aa".into()),
498        "zinc-500" => Some("#71717a".into()),
499        "zinc-800" => Some("#27272a".into()),
500        "zinc-900" => Some("#18181b".into()),
501        "zinc-950" => Some("#09090b".into()),
502        _ if value.starts_with("[#") && value.ends_with(']') => {
503            Some(value[1..value.len() - 1].into())
504        }
505        _ => None,
506    }
507}
508
509fn write_node(out: &mut String, node: &XmlNode, indent: usize) {
510    push_indent(out, indent);
511    out.push('<');
512    out.push_str(&node.tag);
513    for (key, value) in &node.attrs {
514        out.push(' ');
515        out.push_str(key);
516        out.push_str("=\"");
517        push_xml_attr(out, value);
518        out.push('"');
519    }
520    if node.children.is_empty() {
521        out.push_str("/>\n");
522    } else {
523        out.push_str(">\n");
524        for child in &node.children {
525            write_node(out, child, indent + 2);
526        }
527        push_indent(out, indent);
528        out.push_str("</");
529        out.push_str(&node.tag);
530        out.push_str(">\n");
531    }
532}
533
534fn push_indent(out: &mut String, indent: usize) {
535    for _ in 0..indent {
536        out.push(' ');
537    }
538}
539
540fn push_xml_attr(out: &mut String, value: &str) {
541    for ch in value.chars() {
542        match ch {
543            '&' => out.push_str("&amp;"),
544            '<' => out.push_str("&lt;"),
545            '>' => out.push_str("&gt;"),
546            '"' => out.push_str("&quot;"),
547            '\'' => out.push_str("&apos;"),
548            _ => out.push(ch),
549        }
550    }
551}
552
553#[cfg(test)]
554mod tests {
555    use super::*;
556
557    #[test]
558    fn renders_lvgl_component_xml() {
559        let template = r##"
560div #root w-full h-full flex flex-col gap-2 bg-[#101820] p-4
561  h1 text-white text-lg
562    "Temp {temp}"
563  button #refresh bg-blue-500 text-white rounded @click="refresh"
564    "Refresh"
565"##;
566        let mut ctx = TemplateContext::new();
567        ctx.set("temp", 24);
568        let xml = render_template_to_lvgl_xml(template, &ctx).unwrap();
569        assert!(xml.contains(r#"<component name="CrepusView">"#));
570        assert!(xml.contains(r#"<lv_obj id="root" width="100%" height="100%""#));
571        assert!(xml.contains(
572            r##"<lv_label text_color="#ffffff" text_font="montserrat_16" text="Temp 24"/>"##
573        ));
574        assert!(xml.contains(r##"<lv_button id="refresh" bg_color="#3b82f6""##));
575        assert!(xml.contains(r#"<lv_label text="Refresh"/>"#));
576    }
577
578    #[test]
579    fn evaluates_dynamic_binding_values() {
580        let template = r#"
581progress #cpu value={cpu}
582"#;
583        let mut ctx = TemplateContext::new();
584        ctx.set("cpu", 68);
585        let xml = render_template_to_lvgl_xml(template, &ctx).unwrap();
586        assert!(xml.contains(r#"<lv_bar id="cpu" value="68"/>"#));
587    }
588
589    #[test]
590    fn applies_control_flow_before_xml_generation() {
591        let template = r#"
592if {ok}
593  div
594    "Ready"
595else
596  div
597    "Offline"
598"#;
599        let mut ctx = TemplateContext::new();
600        ctx.set("ok", true);
601        let xml = render_template_to_lvgl_xml(template, &ctx).unwrap();
602        assert!(xml.contains(r#"text="Ready""#));
603        assert!(!xml.contains("Offline"));
604    }
605
606    #[test]
607    fn escapes_xml_attribute_values() {
608        let template = r#"
609div
610  "A&B <C>"
611"#;
612        let xml = render_template_to_lvgl_xml(template, &TemplateContext::new()).unwrap();
613        assert!(xml.contains("A&amp;B &lt;C&gt;"));
614    }
615
616    #[test]
617    fn expands_virtual_file_include_with_slot() {
618        let template = r#"
619include card.crepus title="Vitals"
620  span
621    "OK"
622"#;
623        let mut ctx = TemplateContext::new();
624        std::sync::Arc::make_mut(&mut ctx.virtual_files).insert(
625            "card.crepus".into(),
626            r#"
627div #card p-2
628  h2
629    "{title}"
630  slot
631    span
632      "fallback"
633"#
634            .into(),
635        );
636        let xml = render_template_to_lvgl_xml(template, &ctx).unwrap();
637        assert!(xml.contains(r#"<lv_obj id="card" pad_all="8">"#));
638        assert!(xml.contains(r#"<lv_label text="Vitals"/>"#));
639        assert!(xml.contains(r#"<lv_label text="OK"/>"#));
640        assert!(!xml.contains("fallback"));
641    }
642}