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("&"),
544 '<' => out.push_str("<"),
545 '>' => out.push_str(">"),
546 '"' => out.push_str("""),
547 '\'' => out.push_str("'"),
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&B <C>"));
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}