From bf2b7c3cd534ff1535ae592ea0659ff42bdfbd69 Mon Sep 17 00:00:00 2001 From: enzotar Date: Fri, 6 Mar 2026 20:06:33 -0800 Subject: [PATCH] feat: Implement Panel IR emitter to generate JSON UI descriptions for LVGL panels. --- compiler/ds-cli/src/main.rs | 87 ++- compiler/ds-codegen/src/ir_emitter.rs | 854 +++++++++++++++++++++ compiler/ds-codegen/src/lib.rs | 5 +- devices/panel-preview/app.ir.json | 1 + devices/panel-preview/index.html | 1016 +++++++++++++++++++++++++ docs/panel-ir-spec.md | 234 ++++++ examples/game-snake.ds | 13 +- 7 files changed, 2182 insertions(+), 28 deletions(-) create mode 100644 compiler/ds-codegen/src/ir_emitter.rs create mode 100644 devices/panel-preview/app.ir.json create mode 100644 devices/panel-preview/index.html create mode 100644 docs/panel-ir-spec.md diff --git a/compiler/ds-cli/src/main.rs b/compiler/ds-cli/src/main.rs index 8af0582..f2d4042 100644 --- a/compiler/ds-cli/src/main.rs +++ b/compiler/ds-cli/src/main.rs @@ -21,7 +21,7 @@ struct Cli { #[derive(Subcommand)] enum Commands { - /// Compile a .ds file to HTML+JS + /// Compile a .ds file to HTML+JS or Panel IR Build { /// Input .ds file file: PathBuf, @@ -31,6 +31,9 @@ enum Commands { /// Minify JS and CSS output #[arg(long)] minify: bool, + /// Target: html (default) or panel (ESP32 LVGL IR) + #[arg(long, default_value = "html")] + target: String, }, /// Start a dev server with hot reload Dev { @@ -100,7 +103,7 @@ fn main() { let cli = Cli::parse(); match cli.command { - Commands::Build { file, output, minify } => cmd_build(&file, &output, minify), + Commands::Build { file, output, minify, target } => cmd_build(&file, &output, minify, &target), Commands::Dev { file, port } => cmd_dev(&file, port), Commands::Check { file } => cmd_check(&file), Commands::Stream { file, relay, mode, port } => cmd_stream(&file, &relay, &mode, port), @@ -140,6 +143,34 @@ fn compile(source: &str, base_dir: &Path, minify: bool) -> Result Result { + // 1. Lex + let mut lexer = ds_parser::Lexer::new(source); + let tokens = lexer.tokenize(); + + for tok in &tokens { + if let ds_parser::TokenKind::Error(msg) = &tok.kind { + return Err(format!("Lexer error at line {}: {}", tok.line, msg)); + } + } + + // 2. Parse + let mut parser = ds_parser::Parser::with_source(tokens, source); + let mut program = parser.parse_program().map_err(|e| e.to_string())?; + + // 3. Resolve imports + resolve_imports(&mut program, base_dir)?; + + // 4. Analyze + let graph = ds_analyzer::SignalGraph::from_program(&program); + + // 5. Codegen → Panel IR + let ir = ds_codegen::IrEmitter::emit_ir(&program, &graph); + + Ok(ir) +} + /// Resolve `import { X, Y } from "./file"` by parsing the imported file /// and inlining the matching `export`ed declarations. fn resolve_imports(program: &mut ds_parser::Program, base_dir: &Path) -> Result<(), String> { @@ -209,8 +240,8 @@ fn resolve_imports(program: &mut ds_parser::Program, base_dir: &Path) -> Result< Ok(()) } -fn cmd_build(file: &Path, output: &Path, minify: bool) { - println!("🔨 DreamStack build{}", if minify { " (minified)" } else { "" }); +fn cmd_build(file: &Path, output: &Path, minify: bool, target: &str) { + println!("🔨 DreamStack build (target: {}){}", target, if minify { " (minified)" } else { "" }); println!(" source: {}", file.display()); let source = match fs::read_to_string(file) { @@ -222,20 +253,42 @@ fn cmd_build(file: &Path, output: &Path, minify: bool) { }; let base_dir = file.parent().unwrap_or(Path::new(".")); - match compile(&source, base_dir, minify) { - Ok(html) => { - fs::create_dir_all(output).unwrap(); - let out_path = output.join("index.html"); - fs::write(&out_path, &html).unwrap(); - println!(" output: {}", out_path.display()); - println!("✅ Build complete! ({} bytes)", html.len()); - println!(); - println!(" Open in browser:"); - println!(" file://{}", fs::canonicalize(&out_path).unwrap().display()); + + match target { + "panel" => { + // Panel IR target — emit JSON for ESP32 LVGL runtime + match compile_panel_ir(&source, base_dir) { + Ok(ir) => { + fs::create_dir_all(output).unwrap(); + let out_path = output.join("app.ir.json"); + fs::write(&out_path, &ir).unwrap(); + println!(" output: {}", out_path.display()); + println!("✅ Panel IR built ({} bytes)", ir.len()); + } + Err(e) => { + eprintln!("❌ Compile error: {e}"); + std::process::exit(1); + } + } } - Err(e) => { - eprintln!("❌ Compile error: {e}"); - std::process::exit(1); + _ => { + // Default HTML target + match compile(&source, base_dir, minify) { + Ok(html) => { + fs::create_dir_all(output).unwrap(); + let out_path = output.join("index.html"); + fs::write(&out_path, &html).unwrap(); + println!(" output: {}", out_path.display()); + println!("✅ Build complete! ({} bytes)", html.len()); + println!(); + println!(" Open in browser:"); + println!(" file://{}", fs::canonicalize(&out_path).unwrap().display()); + } + Err(e) => { + eprintln!("❌ Compile error: {e}"); + std::process::exit(1); + } + } } } } diff --git a/compiler/ds-codegen/src/ir_emitter.rs b/compiler/ds-codegen/src/ir_emitter.rs new file mode 100644 index 0000000..3999061 --- /dev/null +++ b/compiler/ds-codegen/src/ir_emitter.rs @@ -0,0 +1,854 @@ +/// DreamStack Panel IR Emitter — generates compact JSON IR for ESP32 LVGL panels. +/// +/// Takes the same Program + SignalGraph inputs as JsEmitter but outputs +/// a JSON IR that the on-device LVGL runtime can parse and render. + +use std::collections::HashMap; +use ds_parser::*; +use ds_analyzer::{SignalGraph, SignalKind}; + +/// Panel IR emitter — produces JSON describing the UI tree, signals, and events. +pub struct IrEmitter { + /// Signal name → integer ID mapping + signal_ids: HashMap, + /// Next signal ID to assign + next_signal_id: u16, + /// Next node ID to assign + next_node_id: u16, +} + +impl IrEmitter { + pub fn new() -> Self { + IrEmitter { + signal_ids: HashMap::new(), + next_signal_id: 0, + next_node_id: 0, + } + } + + /// Generate Panel IR JSON from a DreamStack program + signal graph. + pub fn emit_ir(program: &Program, graph: &SignalGraph) -> String { + let mut emitter = IrEmitter::new(); + + // Phase 1: Assign signal IDs + for node in &graph.nodes { + emitter.assign_signal_id(&node.name); + } + + // Phase 2: Build signal list + let signals = emitter.emit_signals(program, graph); + + // Phase 3: Build derived signals + let derived = emitter.emit_derived(graph); + + // Phase 4: Build UI tree from views + let root = emitter.emit_views(program, graph); + + // Phase 5: Build timers from `every` declarations + let timers = emitter.emit_timers(program); + + // Assemble the IR + format!( + r#"{{"t":"ui","signals":[{}],"derived":[{}],"timers":[{}],"root":{}}}"#, + signals, derived, timers, root + ) + } + + fn assign_signal_id(&mut self, name: &str) -> u16 { + if let Some(&id) = self.signal_ids.get(name) { + id + } else { + let id = self.next_signal_id; + self.signal_ids.insert(name.to_string(), id); + self.next_signal_id += 1; + id + } + } + + fn get_signal_id(&self, name: &str) -> Option { + self.signal_ids.get(name).copied() + } + + fn next_node(&mut self) -> u16 { + let id = self.next_node_id; + self.next_node_id += 1; + id + } + + /// Emit the signals array: [{"id":0,"v":72,"type":"int"}, ...] + fn emit_signals(&self, program: &Program, graph: &SignalGraph) -> String { + let mut entries = Vec::new(); + + for node in &graph.nodes { + if !matches!(node.kind, SignalKind::Source) { + continue; + } + let id = match self.get_signal_id(&node.name) { + Some(id) => id, + None => continue, + }; + + // Find the initial value from the Let declaration + let (value_str, type_str) = self.find_initial_value(program, &node.name); + + entries.push(format!( + r#"{{"id":{},"v":{},"type":"{}"}}"#, + id, value_str, type_str + )); + } + + entries.join(",") + } + + /// Find initial value of a signal from the program's Let declarations. + fn find_initial_value(&self, program: &Program, name: &str) -> (String, &'static str) { + for decl in &program.declarations { + if let Declaration::Let(let_decl) = decl { + if let_decl.name == name { + return self.expr_to_value(&let_decl.value); + } + } + } + ("null".to_string(), "null") + } + + /// Convert an expression to a JSON value + type string. + fn expr_to_value(&self, expr: &Expr) -> (String, &'static str) { + match expr { + Expr::IntLit(n) => (n.to_string(), "int"), + Expr::FloatLit(n) => (n.to_string(), "float"), + Expr::BoolLit(b) => (b.to_string(), "bool"), + Expr::StringLit(s) => { + let text = self.string_lit_to_plain(s); + (format!(r#""{}""#, escape_json(&text)), "str") + } + Expr::List(items) => { + let vals: Vec = items.iter() + .map(|item| self.expr_to_value(item).0) + .collect(); + (format!("[{}]", vals.join(",")), "list") + } + Expr::Record(fields) => { + let entries: Vec = fields.iter() + .map(|(k, v)| format!(r#""{}":{}"#, k, self.expr_to_value(v).0)) + .collect(); + (format!("{{{}}}", entries.join(",")), "map") + } + _ => ("null".to_string(), "null"), + } + } + + /// Emit derived signals: [{"id":3,"expr":"s0 * 2","deps":[0]}, ...] + fn emit_derived(&self, graph: &SignalGraph) -> String { + let mut entries = Vec::new(); + + for node in &graph.nodes { + if !matches!(node.kind, SignalKind::Derived) { + continue; + } + let id = match self.get_signal_id(&node.name) { + Some(id) => id, + None => continue, + }; + + let deps: Vec = node.dependencies.iter() + .filter_map(|dep| self.get_signal_id(&dep.signal_name).map(|id| id.to_string())) + .collect(); + + entries.push(format!( + r#"{{"id":{},"deps":[{}]}}"#, + id, deps.join(",") + )); + } + + entries.join(",") + } + + /// Emit UI tree from views. + fn emit_views(&mut self, program: &Program, graph: &SignalGraph) -> String { + // Find the main view (first view, or one named "main") + for decl in &program.declarations { + if let Declaration::View(view) = decl { + return self.emit_view_expr(&view.body, graph); + } + } + + // No view found — empty container + r#"{"t":"col","c":[]}"#.to_string() + } + + /// Emit timers from `every` declarations. + fn emit_timers(&self, program: &Program) -> String { + let mut timers = Vec::new(); + for decl in &program.declarations { + if let Declaration::Every(every) = decl { + let ms = match &every.interval_ms { + Expr::IntLit(n) => *n as u32, + Expr::FloatLit(f) => *f as u32, + _ => 1000, // default to 1s + }; + let action = self.expr_to_action(&every.body); + timers.push(format!(r#"{{"ms":{},"action":{}}}"#, ms, action)); + } + } + timers.join(",") + } + + /// Emit a view expression as an IR node. + fn emit_view_expr(&mut self, expr: &Expr, graph: &SignalGraph) -> String { + match expr { + Expr::Container(container) => self.emit_container(container, graph), + Expr::Element(element) => self.emit_element(element, graph), + Expr::When(cond, then_expr, else_expr) => { + self.emit_conditional(cond, then_expr, else_expr.as_deref(), graph) + } + Expr::Each(item, list, body) | Expr::ForIn { item, iter: list, body, .. } => { + self.emit_each(item, list, body, graph) + } + Expr::ComponentUse { name, props, children } => { + self.emit_component_use(name, props, children, graph) + } + Expr::StringLit(s) => { + let id = self.next_node(); + let text = self.string_lit_to_ir_text(s); + format!(r#"{{"t":"lbl","id":{},"text":"{}"}}"#, id, escape_json(&text)) + } + _ => { + // Fallback: try to emit as a label with the expression value + let id = self.next_node(); + format!(r#"{{"t":"lbl","id":{},"text":"?"}}"#, id) + } + } + } + + /// Emit a container (column, row, stack, etc.) + fn emit_container(&mut self, container: &Container, graph: &SignalGraph) -> String { + let id = self.next_node(); + let kind = match &container.kind { + ContainerKind::Column => "col", + ContainerKind::Row => "row", + ContainerKind::Stack => "stk", + ContainerKind::List => "lst", + ContainerKind::Panel => "pnl", + ContainerKind::Form => "col", + ContainerKind::Scene => "col", // scene → col fallback for IR + ContainerKind::Custom(_) => "col", + }; + + let children: Vec = container.children.iter() + .map(|child| self.emit_view_expr(child, graph)) + .collect(); + + // Extract style props + let style = self.extract_style_props(&container.props); + + let mut parts = vec![ + format!(r#""t":"{}""#, kind), + format!(r#""id":{}"#, id), + format!(r#""c":[{}]"#, children.join(",")), + ]; + + if !style.is_empty() { + parts.push(format!(r#""style":{{{}}}"#, style)); + } + + // Gap and padding from props + for (key, val) in &container.props { + match key.as_str() { + "gap" => if let Expr::IntLit(n) = val { + parts.push(format!(r#""gap":{}"#, n)); + }, + "pad" | "padding" => if let Expr::IntLit(n) = val { + parts.push(format!(r#""pad":{}"#, n)); + }, + _ => {} + } + } + + format!("{{{}}}", parts.join(",")) + } + + /// Emit an element (text, button, input, slider, etc.) + fn emit_element(&mut self, element: &Element, graph: &SignalGraph) -> String { + let id = self.next_node(); + let tag = element.tag.as_str(); + + match tag { + "text" | "label" | "heading" | "h1" | "h2" | "h3" | "p" | "span" => { + self.emit_label(id, element, graph) + } + "button" | "btn" => { + self.emit_button(id, element, graph) + } + "input" | "textfield" => { + self.emit_input(id, element, graph) + } + "slider" | "range" => { + self.emit_slider(id, element, graph) + } + "toggle" | "switch" | "checkbox" => { + self.emit_switch(id, element, graph) + } + "image" | "img" => { + self.emit_image(id, element, graph) + } + "progress" | "bar" => { + self.emit_progress(id, element, graph) + } + _ => { + // Unknown element → warn and label fallback + eprintln!(" ⚠ Unknown element type '{}' — rendered as label", tag); + let text = self.args_to_text(&element.args); + format!(r#"{{"t":"lbl","id":{},"text":"{}"}}"#, id, escape_json(&text)) + } + } + } + + fn emit_label(&mut self, id: u16, element: &Element, _graph: &SignalGraph) -> String { + let text = self.args_to_text(&element.args); + let style = self.extract_style_props(&element.props); + + let mut parts = vec![ + format!(r#""t":"lbl""#), + format!(r#""id":{}"#, id), + format!(r#""text":"{}""#, escape_json(&text)), + ]; + + // Font size from tag + match element.tag.as_str() { + "h1" | "heading" => parts.push(r#""size":28"#.to_string()), + "h2" => parts.push(r#""size":22"#.to_string()), + "h3" => parts.push(r#""size":18"#.to_string()), + _ => {} + } + + if !style.is_empty() { + parts.push(format!(r#""style":{{{}}}"#, style)); + } + + format!("{{{}}}", parts.join(",")) + } + + fn emit_button(&mut self, id: u16, element: &Element, graph: &SignalGraph) -> String { + let text = self.args_to_text(&element.args); + let on_action = self.extract_event_action(element, graph); + + let mut parts = vec![ + format!(r#""t":"btn""#), + format!(r#""id":{}"#, id), + format!(r#""text":"{}""#, escape_json(&text)), + ]; + + if !on_action.is_empty() { + parts.push(format!(r#""on":{{{}}}"#, on_action)); + } + + let style = self.extract_style_props(&element.props); + if !style.is_empty() { + parts.push(format!(r#""style":{{{}}}"#, style)); + } + + format!("{{{}}}", parts.join(",")) + } + + fn emit_input(&mut self, id: u16, element: &Element, _graph: &SignalGraph) -> String { + let mut parts = vec![ + format!(r#""t":"inp""#), + format!(r#""id":{}"#, id), + ]; + + for (key, val) in &element.props { + match key.as_str() { + "placeholder" => { + let text = self.expr_to_text(val); + parts.push(format!(r#""placeholder":"{}""#, escape_json(&text))); + } + "bind" => { + if let Expr::Ident(name) = val { + if let Some(sig_id) = self.get_signal_id(name) { + parts.push(format!(r#""bind":{}"#, sig_id)); + } + } + } + _ => {} + } + } + + format!("{{{}}}", parts.join(",")) + } + + fn emit_slider(&mut self, id: u16, element: &Element, _graph: &SignalGraph) -> String { + let mut parts = vec![ + format!(r#""t":"sld""#), + format!(r#""id":{}"#, id), + ]; + + for (key, val) in &element.props { + match key.as_str() { + "min" => if let Expr::IntLit(n) = val { + parts.push(format!(r#""min":{}"#, n)); + }, + "max" => if let Expr::IntLit(n) = val { + parts.push(format!(r#""max":{}"#, n)); + }, + "bind" => { + if let Expr::Ident(name) = val { + if let Some(sig_id) = self.get_signal_id(name) { + parts.push(format!(r#""bind":{}"#, sig_id)); + } + } + } + _ => {} + } + } + + format!("{{{}}}", parts.join(",")) + } + + fn emit_switch(&mut self, id: u16, element: &Element, _graph: &SignalGraph) -> String { + let mut parts = vec![ + format!(r#""t":"sw""#), + format!(r#""id":{}"#, id), + ]; + + for (key, val) in &element.props { + if key == "bind" { + if let Expr::Ident(name) = val { + if let Some(sig_id) = self.get_signal_id(name) { + parts.push(format!(r#""bind":{}"#, sig_id)); + } + } + } + } + + format!("{{{}}}", parts.join(",")) + } + + fn emit_image(&mut self, id: u16, element: &Element, _graph: &SignalGraph) -> String { + let src = self.args_to_text(&element.args); + format!(r#"{{"t":"img","id":{},"src":"{}"}}"#, id, escape_json(&src)) + } + + fn emit_progress(&mut self, id: u16, element: &Element, _graph: &SignalGraph) -> String { + let mut parts = vec![ + format!(r#""t":"bar""#), + format!(r#""id":{}"#, id), + ]; + + for (key, val) in &element.props { + match key.as_str() { + "min" => if let Expr::IntLit(n) = val { + parts.push(format!(r#""min":{}"#, n)); + }, + "max" => if let Expr::IntLit(n) = val { + parts.push(format!(r#""max":{}"#, n)); + }, + "bind" => { + if let Expr::Ident(name) = val { + if let Some(sig_id) = self.get_signal_id(name) { + parts.push(format!(r#""bind":{}"#, sig_id)); + } + } + } + _ => {} + } + } + + format!("{{{}}}", parts.join(",")) + } + + /// Emit a conditional (when/if). + fn emit_conditional( + &mut self, cond: &Expr, then_expr: &Expr, + else_expr: Option<&Expr>, graph: &SignalGraph + ) -> String { + let id = self.next_node(); + let cond_str = self.expr_to_condition(cond); + let then_node = self.emit_view_expr(then_expr, graph); + + let mut parts = vec![ + format!(r#""t":"cond""#), + format!(r#""id":{}"#, id), + format!(r#""if":"{}""#, escape_json(&cond_str)), + format!(r#""then":{}"#, then_node), + ]; + + if let Some(else_e) = else_expr { + let else_node = self.emit_view_expr(else_e, graph); + parts.push(format!(r#""else":{}"#, else_node)); + } + + format!("{{{}}}", parts.join(",")) + } + + /// Emit an each loop. + fn emit_each(&mut self, item: &str, list: &Expr, body: &Expr, graph: &SignalGraph) -> String { + let id = self.next_node(); + let list_ref = self.expr_to_signal_ref(list); + let body_node = self.emit_view_expr(body, graph); + + format!( + r#"{{"t":"each","id":{},"item":"{}","list":"{}","body":{}}}"#, + id, item, escape_json(&list_ref), body_node + ) + } + + /// Emit a component use — extract props and render with children. + fn emit_component_use( + &mut self, name: &str, props: &[(String, Expr)], + children: &[Expr], graph: &SignalGraph + ) -> String { + let id = self.next_node(); + + let child_nodes: Vec = children.iter() + .map(|c| self.emit_view_expr(c, graph)) + .collect(); + + let mut parts = vec![ + format!(r#""t":"pnl""#), + format!(r#""id":{}"#, id), + format!(r#""_comp":"{}""#, name), + ]; + + // Extract key component props + for (key, val) in props { + match key.as_str() { + "label" | "title" => { + let text = self.expr_to_text(val); + parts.push(format!(r#""text":"{}""#, escape_json(&text))); + } + "variant" => { + let v = self.expr_to_text(val); + parts.push(format!(r#""variant":"{}""#, escape_json(&v))); + } + _ => {} + } + } + + parts.push(format!(r#""c":[{}]"#, child_nodes.join(","))); + + format!("{{{}}}", parts.join(",")) + } + + // ── Helpers ────────────────────────────────────────── + + /// Convert string lit args to IR text with signal references. + fn args_to_text(&self, args: &[Expr]) -> String { + if args.is_empty() { + return String::new(); + } + args.iter().map(|a| self.expr_to_text(a)).collect::>().join(" ") + } + + /// Convert an expression to display text, using {N} for signal references. + fn expr_to_text(&self, expr: &Expr) -> String { + match expr { + Expr::StringLit(s) => self.string_lit_to_ir_text(s), + Expr::Ident(name) => { + if let Some(id) = self.get_signal_id(name) { + format!("{{{}}}", id) + } else { + name.clone() + } + } + Expr::IntLit(n) => n.to_string(), + Expr::FloatLit(n) => n.to_string(), + Expr::BoolLit(b) => b.to_string(), + _ => "?".to_string(), + } + } + + /// Convert a StringLit to IR text: "Hello {name}" → "Hello {0}" + fn string_lit_to_ir_text(&self, s: &StringLit) -> String { + let mut out = String::new(); + for seg in &s.segments { + match seg { + StringSegment::Literal(text) => out.push_str(text), + StringSegment::Interpolation(expr) => { + if let Expr::Ident(name) = expr.as_ref() { + if let Some(id) = self.get_signal_id(name) { + out.push_str(&format!("{{{}}}", id)); + } else { + out.push_str(&format!("{{{}}}", name)); + } + } else if let Expr::DotAccess(base, field) = expr.as_ref() { + if let Expr::Ident(name) = base.as_ref() { + if let Some(id) = self.get_signal_id(name) { + out.push_str(&format!("{{{}.{}}}", id, field)); + } else { + out.push_str(&format!("{{{}.{}}}", name, field)); + } + } + } else { + out.push_str("{?}"); + } + } + } + } + out + } + + /// Convert a StringLit to plain text without signal references. + fn string_lit_to_plain(&self, s: &StringLit) -> String { + let mut out = String::new(); + for seg in &s.segments { + match seg { + StringSegment::Literal(text) => out.push_str(text), + StringSegment::Interpolation(expr) => { + out.push_str(&self.expr_to_text(expr)); + } + } + } + out + } + + /// Extract event actions from element props (on click, etc.) + fn extract_event_action(&self, element: &Element, _graph: &SignalGraph) -> String { + let mut actions = Vec::new(); + + for (key, val) in &element.props { + // Check for "on" + event patterns: "click", "press", etc. + let event_name = match key.as_str() { + "click" | "on_click" => Some("click"), + "press" | "on_press" => Some("press"), + _ => None, + }; + + if let Some(event) = event_name { + let action = self.expr_to_action(val); + actions.push(format!(r#""{}":{}"#, event, action)); + } + } + + // Also check for explicit on handlers in modifiers or inline props + // Pattern: button "+" { on click { count += 1 } } + // In the AST, this comes through as props with key "click" and value being the handler body + + actions.join(",") + } + + /// Convert an expression to an action opcode. + fn expr_to_action(&self, expr: &Expr) -> String { + match expr { + // count += 1 → inc + Expr::Assign(target, AssignOp::AddAssign, val) => { + if let (Expr::Ident(name), Expr::IntLit(1)) = (target.as_ref(), val.as_ref()) { + if let Some(id) = self.get_signal_id(name) { + return format!(r#"{{"op":"inc","s":{}}}"#, id); + } + } + if let Expr::Ident(name) = target.as_ref() { + if let Some(id) = self.get_signal_id(name) { + let v = self.expr_to_value(val).0; + return format!(r#"{{"op":"add","s":{},"v":{}}}"#, id, v); + } + } + r#"{"op":"noop"}"#.to_string() + } + // count -= 1 → dec + Expr::Assign(target, AssignOp::SubAssign, val) => { + if let (Expr::Ident(name), Expr::IntLit(1)) = (target.as_ref(), val.as_ref()) { + if let Some(id) = self.get_signal_id(name) { + return format!(r#"{{"op":"dec","s":{}}}"#, id); + } + } + if let Expr::Ident(name) = target.as_ref() { + if let Some(id) = self.get_signal_id(name) { + let v = self.expr_to_value(val).0; + return format!(r#"{{"op":"sub","s":{},"v":{}}}"#, id, v); + } + } + r#"{"op":"noop"}"#.to_string() + } + // count = expr → set + Expr::Assign(target, AssignOp::Set, val) => { + if let Expr::Ident(name) = target.as_ref() { + if let Some(id) = self.get_signal_id(name) { + // Check for toggle: x = !x + if let Expr::UnaryOp(UnaryOp::Not, inner) = val.as_ref() { + if let Expr::Ident(inner_name) = inner.as_ref() { + if inner_name == name { + return format!(r#"{{"op":"toggle","s":{}}}"#, id); + } + } + } + let v = self.expr_to_value(val).0; + return format!(r#"{{"op":"set","s":{},"v":{}}}"#, id, v); + } + } + r#"{"op":"noop"}"#.to_string() + } + // Block → multiple actions as an array + Expr::Block(exprs) => { + if exprs.len() == 1 { + self.expr_to_action(&exprs[0]) + } else { + let actions: Vec = exprs.iter() + .map(|e| self.expr_to_action(e)) + .filter(|a| !a.contains("noop")) + .collect(); + if actions.len() == 1 { + actions[0].clone() + } else { + format!("[{}]", actions.join(",")) + } + } + } + // Function call → remote action + Expr::Call(name, _args) => { + format!(r#"{{"op":"remote","name":"{}"}}"#, name) + } + _ => r#"{"op":"noop"}"#.to_string(), + } + } + + /// Convert an expression to a condition string for the runtime. + fn expr_to_condition(&self, expr: &Expr) -> String { + match expr { + Expr::BinOp(left, op, right) => { + let l = self.expr_to_signal_ref(left); + let r = self.expr_to_signal_ref(right); + let op_str = match op { + BinOp::Gt => ">", + BinOp::Lt => "<", + BinOp::Gte => ">=", + BinOp::Lte => "<=", + BinOp::Eq => "==", + BinOp::Neq => "!=", + BinOp::And => "&&", + BinOp::Or => "||", + _ => "?", + }; + format!("{} {} {}", l, op_str, r) + } + Expr::Ident(name) => { + if let Some(id) = self.get_signal_id(name) { + format!("s{}", id) + } else { + name.clone() + } + } + Expr::UnaryOp(UnaryOp::Not, inner) => { + format!("!{}", self.expr_to_condition(inner)) + } + _ => "true".to_string(), + } + } + + /// Convert an expression to a signal reference string. + fn expr_to_signal_ref(&self, expr: &Expr) -> String { + match expr { + Expr::Ident(name) => { + if let Some(id) = self.get_signal_id(name) { + format!("s{}", id) + } else { + name.clone() + } + } + Expr::IntLit(n) => n.to_string(), + Expr::FloatLit(n) => n.to_string(), + Expr::BoolLit(b) => b.to_string(), + Expr::StringLit(s) => format!(r#""{}""#, self.string_lit_to_plain(s)), + _ => "?".to_string(), + } + } + + /// Extract style properties from props list. + fn extract_style_props(&self, props: &[(String, Expr)]) -> String { + let mut style_parts = Vec::new(); + + for (key, val) in props { + match key.as_str() { + "bg" | "background" | "backgroundColor" => { + let v = self.expr_to_text(val); + style_parts.push(format!(r#""bg":"{}""#, escape_json(&v))); + } + "fg" | "color" | "textColor" => { + let v = self.expr_to_text(val); + style_parts.push(format!(r#""fg":"{}""#, escape_json(&v))); + } + "size" | "fontSize" => { + if let Expr::IntLit(n) = val { + style_parts.push(format!(r#""size":{}"#, n)); + } + } + "radius" | "borderRadius" => { + if let Expr::IntLit(n) = val { + style_parts.push(format!(r#""radius":{}"#, n)); + } + } + "align" | "textAlign" => { + let v = self.expr_to_text(val); + style_parts.push(format!(r#""align":"{}""#, escape_json(&v))); + } + "width" => { + if let Expr::IntLit(n) = val { + style_parts.push(format!(r#""w":{}"#, n)); + } + } + "height" => { + if let Expr::IntLit(n) = val { + style_parts.push(format!(r#""h":{}"#, n)); + } + } + _ => {} + } + } + + style_parts.join(",") + } +} + +/// Escape a string for JSON. +fn escape_json(s: &str) -> String { + s.replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\n', "\\n") + .replace('\r', "\\r") + .replace('\t', "\\t") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_escape_json() { + assert_eq!(escape_json(r#"hello "world""#), r#"hello \"world\""#); + assert_eq!(escape_json("line1\nline2"), "line1\\nline2"); + } + + #[test] + fn test_simple_counter() { + let source = r#" +let count = 0 + +view main { + text { "Count: {count}" } + button "+" { + click: count += 1 + } +} +"#; + let mut lexer = ds_parser::Lexer::new(source); + let tokens = lexer.tokenize(); + let mut parser = ds_parser::Parser::new(tokens); + let program = parser.parse_program().unwrap(); + let graph = ds_analyzer::SignalGraph::from_program(&program); + + let ir = IrEmitter::emit_ir(&program, &graph); + + // Should contain signal for count + assert!(ir.contains(r#""id":0"#)); + assert!(ir.contains(r#""v":0"#)); + assert!(ir.contains(r#""type":"int""#)); + + // Should contain a label with signal reference + assert!(ir.contains(r#""t":"lbl""#)); + + // Should contain a button + assert!(ir.contains(r#""t":"btn""#)); + + println!("IR output: {}", ir); + } +} diff --git a/compiler/ds-codegen/src/lib.rs b/compiler/ds-codegen/src/lib.rs index 51ec1da..a0c2b83 100644 --- a/compiler/ds-codegen/src/lib.rs +++ b/compiler/ds-codegen/src/lib.rs @@ -1,7 +1,10 @@ -/// DreamStack Code Generator — emits JavaScript from analyzed AST. +/// DreamStack Code Generator — emits JavaScript or Panel IR from analyzed AST. /// /// Strategy: emit a single JS module that imports the DreamStack runtime /// and creates signals, derived values, DOM bindings, and event handlers. +/// Alternatively, emit Panel IR JSON for ESP32 LVGL thin client panels. pub mod js_emitter; +pub mod ir_emitter; pub use js_emitter::JsEmitter; +pub use ir_emitter::IrEmitter; diff --git a/devices/panel-preview/app.ir.json b/devices/panel-preview/app.ir.json new file mode 100644 index 0000000..95aa85b --- /dev/null +++ b/devices/panel-preview/app.ir.json @@ -0,0 +1 @@ +{"t":"ui","signals":[{"id":0,"v":4,"type":"int"},{"id":1,"v":4,"type":"int"},{"id":2,"v":0,"type":"int"},{"id":3,"v":0,"type":"int"},{"id":4,"v":2,"type":"int"},{"id":5,"v":2,"type":"int"},{"id":6,"v":0,"type":"int"}],"derived":[],"timers":[{"ms":1000,"action":{"op":"inc","s":6}}],"root":{"t":"col","id":0,"c":[{"t":"lbl","id":1,"text":"🐍 Snake Game"},{"t":"lbl","id":2,"text":"Move with arrows â€ĸ Eat the 🍎"},{"t":"row","id":3,"c":[{"t":"pnl","id":4,"_comp":"Badge","text":"Score: {2}","variant":"success","c":[]},{"t":"pnl","id":5,"_comp":"Badge","text":"Moves: {3}","variant":"info","c":[]},{"t":"pnl","id":6,"_comp":"Badge","text":"Time: {6}s","variant":"warning","c":[]},{"t":"pnl","id":7,"_comp":"Badge","text":"🐍 ({0},{1})","variant":"default","c":[]},{"t":"pnl","id":8,"_comp":"Badge","text":"🍎 ({4},{5})","variant":"error","c":[]}]},{"t":"pnl","id":9,"_comp":"Card","text":"Board (12×12)","c":[{"t":"lbl","id":10,"text":"🐍 at ({0},{1}) 🍎 at ({4},{5})"}]},{"t":"pnl","id":11,"_comp":"Card","text":"Controls","c":[{"t":"row","id":12,"c":[{"t":"lbl","id":13,"text":" "},{"t":"btn","id":14,"text":"âŦ†ī¸","on":{"click":[{"op":"dec","s":1},{"op":"inc","s":3}]}},{"t":"lbl","id":15,"text":" "}]},{"t":"row","id":16,"c":[{"t":"btn","id":17,"text":"âŦ…ī¸","on":{"click":[{"op":"dec","s":0},{"op":"inc","s":3}]}},{"t":"btn","id":18,"text":"âšī¸"},{"t":"btn","id":19,"text":"âžĄī¸","on":{"click":[{"op":"inc","s":0},{"op":"inc","s":3}]}}]},{"t":"row","id":20,"c":[{"t":"lbl","id":21,"text":" "},{"t":"btn","id":22,"text":"âŦ‡ī¸","on":{"click":[{"op":"inc","s":1},{"op":"inc","s":3}]}},{"t":"lbl","id":23,"text":" "}]}]},{"t":"row","id":24,"c":[{"t":"btn","id":25,"text":"🍎 Move Food","on":{"click":[{"op":"set","s":4,"v":null},{"op":"set","s":5,"v":null}]}},{"t":"btn","id":26,"text":"🔄 Reset","on":{"click":[{"op":"set","s":0,"v":4},{"op":"set","s":1,"v":4},{"op":"set","s":2,"v":0},{"op":"set","s":3,"v":0}]}}]}]}} \ No newline at end of file diff --git a/devices/panel-preview/index.html b/devices/panel-preview/index.html new file mode 100644 index 0000000..b852f91 --- /dev/null +++ b/devices/panel-preview/index.html @@ -0,0 +1,1016 @@ + + + + + + + DreamStack Panel IR Previewer + + + + + + +
+
+ Panel IR Preview — 800×1280 + No IR loaded +
+
+
+ +
+
DreamStack Panel IR Previewer — drag-drop an .ir.json file or use ?file= URL
+
+ + + + + \ No newline at end of file diff --git a/docs/panel-ir-spec.md b/docs/panel-ir-spec.md new file mode 100644 index 0000000..4be651d --- /dev/null +++ b/docs/panel-ir-spec.md @@ -0,0 +1,234 @@ +# DreamStack Panel IR — Dynamic UI Streaming to ESP32 + +Stream DreamStack apps to ESP32-P4 touchscreen panels as a compact **Panel IR** over WebSocket. The on-device LVGL runtime renders the UI locally. Touch is instant. New UIs arrive dynamically — no reflashing. + +## Architecture + +``` +HUB (Pi / laptop) ESP32-P4 PANEL (Waveshare 10.1") +────────────────── ───────────────────────────────── + +DreamStack compiler Panel Runtime (~500 lines C) + ds compile app.ds --target panel LVGL 9 graphics library + ↓ ↓ +ir_emitter.rs → Panel IR (JSON) ──→ WiFi 6 ──→ Parse IR → create widgets + Bind signal reactivity + Handle touch locally (< 5ms) + +Signal update: { "t":"sig", "s":{"0":75} } ──→ Update bound labels instantly +Touch event: { "t":"evt", "n":3, "e":"click" } ←── LVGL callback fires +New screen: Full IR blob ──→ Destroy old UI, build new +``` + +## Why not pixel streaming? + +| | Pixel streaming | Panel IR (this spec) | +|---|---|---| +| Touch latency | 30-50ms (network round-trip) | **< 5ms (local)** | +| WiFi disconnect | Screen freezes | **App stays interactive** | +| Bandwidth | 20-50 Kbps continuous | ~100 bytes/event | +| Dynamic UIs | ✅ | ✅ | +| Hub CPU load | Renders for all panels | **Near zero** | +| Offline | ❌ | ✅ (for local signals) | + +## Message Protocol + +Three message types over WebSocket (JSON): + +### 1. `ui` — Full UI tree + +Sent when app loads, screen changes, or hub pushes a new app. + +```json +{ + "t": "ui", + "signals": [ + { "id": 0, "v": 72, "type": "int" }, + { "id": 1, "v": true, "type": "bool" }, + { "id": 2, "v": "Kitchen", "type": "str" } + ], + "root": { + "t": "col", "gap": 10, "pad": 20, + "c": [ + { "t": "lbl", "id": 0, "text": "{2}: {0}°F", "size": 24 }, + { "t": "btn", "id": 1, "text": "Lights", + "on": { "click": { "op": "toggle", "s": 1 } } }, + { "t": "sld", "id": 2, "min": 60, "max": 90, "bind": 0 } + ] + }, + "derived": [ + { "id": 3, "expr": "s0 * 2", "deps": [0] } + ] +} +``` + +### 2. `sig` — Signal update (hub → panel) + +```json +{ "t": "sig", "s": { "0": 75 } } +``` + +### 3. `evt` — Event (panel → hub) + +```json +{ "t": "evt", "n": 1, "e": "click" } +``` + +## IR Node Types + +| IR type | DreamStack source | LVGL widget | Key props | +|---|---|---|---| +| `col` | `column [...]` | `lv_obj` + `LV_FLEX_FLOW_COLUMN` | gap, pad, bg | +| `row` | `row [...]` | `lv_obj` + `LV_FLEX_FLOW_ROW` | gap, pad | +| `stk` | `stack [...]` | `lv_obj` (absolute pos) | — | +| `lbl` | `text "..."` | `lv_label` | text, color, size | +| `btn` | `button "..." {}` | `lv_btn` + `lv_label` | text, on.click | +| `inp` | `input` | `lv_textarea` | placeholder, bind | +| `sld` | `slider` | `lv_slider` | min, max, bind | +| `img` | `image` | `lv_img` | src | +| `bar` | `progress` | `lv_bar` | min, max, bind | +| `sw` | `toggle` | `lv_switch` | bind | +| `lst` | `list [...]` | `lv_list` | — | +| `pnl` | `panel [...]` | `lv_obj` (styled card) | title, bg, radius | + +## Signal Binding + +Text fields use `{N}` for signal interpolation: + +``` +"text": "{2}: {0}°F" → "Kitchen: 72°F" + signal 2 signal 0 +``` + +When signal 0 changes to 75, the runtime re-expands the template and calls `lv_label_set_text()`. Only affected labels update. + +Sliders and inputs use `"bind": N` for two-way binding. When the user moves a slider, the runtime updates signal N locally AND sends a `sig` message to the hub. + +## Event Actions + +Simple operations execute locally on the ESP32 (instant). Complex logic forwards to the hub. + +| Action | IR | Runs on | +|---|---|---| +| Set value | `{ "op": "set", "s": 0, "v": 5 }` | ESP32 (local) | +| Toggle bool | `{ "op": "toggle", "s": 1 }` | ESP32 (local) | +| Increment | `{ "op": "inc", "s": 0 }` | ESP32 (local) | +| Decrement | `{ "op": "dec", "s": 0 }` | ESP32 (local) | +| Navigate | `{ "op": "nav", "screen": "settings" }` | Hub → sends new IR | +| Remote call | `{ "op": "remote", "name": "fetch_weather" }` | Hub → result as sig | + +## Styling + +Each node can have optional style props: + +```json +{ + "t": "lbl", "id": 0, "text": "Hello", + "style": { + "bg": "#1a1a2e", + "fg": "#e0e0ff", + "size": 18, + "radius": 8, + "pad": 12, + "align": "center", + "font": "bold" + } +} +``` + +The runtime maps these to `lv_obj_set_style_*()` calls. + +## Implementation Plan + +### Phase 1: Compiler Backend + +**New file:** `compiler/ds-codegen/src/ir_emitter.rs` + +```rust +pub fn emit_panel_ir(program: &Program, graph: &SignalGraph) -> String { + // Walk AST → build IR JSON + // Map signal names → integer IDs + // Convert OnHandler → action opcodes + // Convert string interpolations → {N} references +} +``` + +**Modify:** `compiler/ds-cli/src/main.rs` — add `--target panel` flag + +### Phase 2: ESP32 LVGL Runtime + +**New files in** `devices/waveshare-p4-panel/main/`: + +| File | Lines | Purpose | +|---|---|---| +| `ds_runtime.h` | ~60 | API: build, destroy, signal_update, event_send | +| `ds_runtime.c` | ~400 | JSON parser (cJSON), widget factory, signal table, text formatter, event dispatch | + +Core functions: +```c +void ds_ui_build(const char *ir_json); // Parse IR, create LVGL tree +void ds_ui_destroy(void); // Tear down for screen change +void ds_signal_update(uint16_t id, const char *value); // Update + refresh +void ds_event_send(uint16_t node_id, const char *event); // Send to hub +``` + +### Phase 3: Hub Server + +**New file:** `engine/ds-stream/src/panel_server.rs` + +WebSocket server that: +1. Compiles `.ds` → IR on startup +2. Sends IR to connecting panels +3. Forwards signal diffs bidirectionally +4. Watches `.ds` file → recompile → push new IR (hot reload) + +### Phase 4: Pixel fallback (already built) + +The existing `ds_codec.c` + `ds_protocol.h` remain as a fallback for cases where pixel streaming IS needed (streaming non-DreamStack content, camera feeds, etc). + +## Hardware + +| Component | Role | Price | +|---|---|---| +| Waveshare ESP32-P4-WIFI6 10.1" | Panel display + runtime | ~$85 | +| Raspberry Pi 5 (or any Linux box) | Hub: compile + serve IR | ~$60 | +| **Total POC** | | **~$145** | + +## Example: Complete Flow + +### 1. Write the app +``` +// kitchen.ds +let temperature = 72 +let lights_on = true + +view main { + text { "Kitchen: {temperature}°F" } + button "Lights" { + on click { lights_on = !lights_on } + } + slider { min: 60, max: 90, bind: temperature } +} +``` + +### 2. Compile to IR +```bash +ds compile kitchen.ds --target panel > kitchen.ir.json +``` + +### 3. Hub serves it +```bash +ds serve --panel kitchen.ds --port 9100 +# Panel connects → receives IR → renders UI +# Tap button → lights_on toggles locally +# Move slider → temperature updates locally + syncs to hub +# Hub pushes weather API data → { "t":"sig", "s":{"0": 68} } +# Panel label updates: "Kitchen: 68°F" +``` + +### 4. Push a new app +```bash +ds serve --panel settings.ds --port 9100 +# Hub sends new IR → panel destroys kitchen UI → builds settings UI +# No reflash. Instant. +``` diff --git a/examples/game-snake.ds b/examples/game-snake.ds index 620f0bd..f5de02e 100644 --- a/examples/game-snake.ds +++ b/examples/game-snake.ds @@ -43,16 +43,9 @@ view snake_game = column [ Badge { label: "🍎 ({foodX},{foodY})", variant: "error" } ] - -- Game board: 8 rows rendered with match on headY - Card { title: "Board" } [ - -- Row 0 - row [ - when headY == 0 -> when headX == 0 -> text "🟩" - when headY == 0 -> when headX == 1 -> text "🟩" - when headY == 0 -> when headX == 2 -> text "🟩" - when foodY == 0 -> when foodX == 0 -> text "🍎" - text "Row 0: Snake={headY == 0}" - ] + -- Game board — the previewer renders a visual grid from signals + Card { title: "Board (12×12)" } [ + text "🐍 at ({headX},{headY}) 🍎 at ({foodX},{foodY})" ] -- Directional controls