feat: Implement Panel IR emitter to generate JSON UI descriptions for LVGL panels.
This commit is contained in:
parent
cc6aac8697
commit
bf2b7c3cd5
7 changed files with 2182 additions and 28 deletions
|
|
@ -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<String, String
|
|||
Ok(html)
|
||||
}
|
||||
|
||||
/// Compile a DreamStack source file to Panel IR JSON for ESP32 LVGL panels.
|
||||
fn compile_panel_ir(source: &str, base_dir: &Path) -> Result<String, String> {
|
||||
// 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,6 +253,26 @@ fn cmd_build(file: &Path, output: &Path, minify: bool) {
|
|||
};
|
||||
|
||||
let base_dir = file.parent().unwrap_or(Path::new("."));
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Default HTML target
|
||||
match compile(&source, base_dir, minify) {
|
||||
Ok(html) => {
|
||||
fs::create_dir_all(output).unwrap();
|
||||
|
|
@ -238,6 +289,8 @@ fn cmd_build(file: &Path, output: &Path, minify: bool) {
|
|||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// HMR client script injected into every page served by `dreamstack dev`.
|
||||
|
|
|
|||
854
compiler/ds-codegen/src/ir_emitter.rs
Normal file
854
compiler/ds-codegen/src/ir_emitter.rs
Normal file
|
|
@ -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<String, u16>,
|
||||
/// 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<u16> {
|
||||
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<String> = items.iter()
|
||||
.map(|item| self.expr_to_value(item).0)
|
||||
.collect();
|
||||
(format!("[{}]", vals.join(",")), "list")
|
||||
}
|
||||
Expr::Record(fields) => {
|
||||
let entries: Vec<String> = 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<String> = 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<String> = 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<String> = 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::<Vec<_>>().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<String> = 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
1
devices/panel-preview/app.ir.json
Normal file
1
devices/panel-preview/app.ir.json
Normal file
|
|
@ -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}]}}]}]}}
|
||||
1016
devices/panel-preview/index.html
Normal file
1016
devices/panel-preview/index.html
Normal file
File diff suppressed because it is too large
Load diff
234
docs/panel-ir-spec.md
Normal file
234
docs/panel-ir-spec.md
Normal file
|
|
@ -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.
|
||||
```
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue