Runtime _connectStream improvements: - Connection status as reactive signals: _connected, _latency, _frames, _reconnects injected on stream proxy so UIs can show connection health - Fixed RLE decoder: 2-byte LE count (was 1-byte, mismatched relay encoder) - Schema caching: 0x32 SchemaAnnounce frames now parsed and cached - RTT tracking: receivers send periodic pings (5s), measure round-trip latency - Better reconnect logging: includes URL and attempt count Relay tests (57 total): - catchup_merges_multiple_diffs: sync + 3 diffs → 1 merged frame - catchup_diffs_only_no_sync: diffs without sync → merged frame - catchup_preserves_version_counters: conflict resolution versions kept New example: - timer-multi-action.ds: every timer + multi-action buttons verified Documentation: - STREAM_COMPOSITION.md: 4 new sections (Diff Batching, Connection Status, RTT, Relay Merging) - Updated example table with streaming-dashboard.ds and timer-multi-action.ds All 9 examples pass regression (44-70KB each)
3175 lines
126 KiB
Rust
3175 lines
126 KiB
Rust
/// JavaScript emitter — generates executable JS from DreamStack AST + signal graph.
|
|
|
|
use std::collections::HashSet;
|
|
use ds_parser::*;
|
|
use ds_analyzer::{SignalGraph, SignalKind, AnalyzedView, InitialValue};
|
|
|
|
pub struct JsEmitter {
|
|
output: String,
|
|
indent: usize,
|
|
node_id_counter: usize,
|
|
/// Scoped local variables (e.g., for-in loop vars). Stack of scopes for nesting.
|
|
local_var_scopes: Vec<HashSet<String>>,
|
|
}
|
|
|
|
impl JsEmitter {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
output: String::new(),
|
|
indent: 0,
|
|
node_id_counter: 0,
|
|
local_var_scopes: Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// Push a new variable scope (e.g., entering a for-in loop)
|
|
fn push_scope(&mut self, vars: &[&str]) {
|
|
let set: HashSet<String> = vars.iter().map(|s| s.to_string()).collect();
|
|
self.local_var_scopes.push(set);
|
|
}
|
|
|
|
/// Pop the top variable scope (e.g., leaving a for-in loop)
|
|
fn pop_scope(&mut self) {
|
|
self.local_var_scopes.pop();
|
|
}
|
|
|
|
/// Check if a name is a local variable in any active scope
|
|
fn is_local_var(&self, name: &str) -> bool {
|
|
self.local_var_scopes.iter().any(|scope| scope.contains(name))
|
|
}
|
|
|
|
/// Generate a complete HTML page with embedded runtime and compiled app.
|
|
pub fn emit_html(program: &Program, graph: &SignalGraph, views: &[AnalyzedView]) -> String {
|
|
let mut emitter = Self::new();
|
|
let app_js = emitter.emit_program(program, graph, views);
|
|
|
|
format!(
|
|
r#"<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>DreamStack App</title>
|
|
<style>
|
|
{CSS_RESET}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="ds-root"></div>
|
|
<script>
|
|
{RUNTIME_JS}
|
|
</script>
|
|
<script>
|
|
{app_js}
|
|
</script>
|
|
</body>
|
|
</html>"#
|
|
)
|
|
}
|
|
|
|
/// Generate the application JS module.
|
|
pub fn emit_program(&mut self, program: &Program, graph: &SignalGraph, views: &[AnalyzedView]) -> String {
|
|
self.output.clear();
|
|
|
|
self.emit_line("// DreamStack compiled output");
|
|
self.emit_line("// Generated by dreamstack compiler v0.1.0");
|
|
self.emit_line("");
|
|
self.emit_line("(function() {");
|
|
self.indent += 1;
|
|
|
|
// Phase 1: Create all signals
|
|
// Collect explicit output list from stream declaration (if any)
|
|
let stream_outputs: Vec<String> = program.declarations.iter()
|
|
.filter_map(|d| if let Declaration::Stream(s) = d { Some(s) } else { None })
|
|
.flat_map(|s| s.output.iter().cloned())
|
|
.collect();
|
|
let has_explicit_output = !stream_outputs.is_empty();
|
|
|
|
self.emit_line("// ── Signals ──");
|
|
for node in &graph.nodes {
|
|
match &node.kind {
|
|
SignalKind::Source => {
|
|
let init = match &node.initial_value {
|
|
Some(InitialValue::Int(n)) => format!("{n}"),
|
|
Some(InitialValue::Float(n)) => format!("{n}"),
|
|
Some(InitialValue::Bool(b)) => format!("{b}"),
|
|
Some(InitialValue::String(s)) => format!("\"{s}\""),
|
|
None => {
|
|
// Fall back to the let declaration expression
|
|
// (handles List, Record, etc.)
|
|
if let Some(expr) = self.find_let_expr(program, &node.name) {
|
|
// Check if it's a spring() call — Spring is already signal-compatible
|
|
if matches!(expr, Expr::Call(name, _) if name == "spring") {
|
|
let js_expr = self.emit_expr(expr);
|
|
self.emit_line(&format!("const {} = {};", node.name, js_expr));
|
|
continue;
|
|
}
|
|
// Check if it's a stream from — _connectStream returns a signal proxy
|
|
if matches!(expr, Expr::StreamFrom { .. }) {
|
|
let js_expr = self.emit_expr(expr);
|
|
self.emit_line(&format!("const {} = {};", node.name, js_expr));
|
|
continue;
|
|
}
|
|
self.emit_expr(expr)
|
|
} else {
|
|
"null".to_string()
|
|
}
|
|
}
|
|
};
|
|
self.emit_line(&format!("const {} = DS.signal({});", node.name, init));
|
|
// Register for streaming only if in output list (or no explicit output = all)
|
|
if !has_explicit_output || stream_outputs.contains(&node.name) {
|
|
self.emit_line(&format!("DS._registerSignal(\"{}\", {});", node.name, node.name));
|
|
}
|
|
}
|
|
SignalKind::Derived => {
|
|
// Find the let declaration to get the expression
|
|
let expr = self.find_let_expr(program, &node.name);
|
|
if let Some(expr) = expr {
|
|
let js_expr = self.emit_expr(expr);
|
|
self.emit_line(&format!(
|
|
"const {} = DS.derived(() => {});",
|
|
node.name, js_expr
|
|
));
|
|
// Register for streaming only if in output list (or no explicit output = all)
|
|
if !has_explicit_output || stream_outputs.contains(&node.name) {
|
|
self.emit_line(&format!("DS._registerSignal(\"{}\", {});", node.name, node.name));
|
|
}
|
|
}
|
|
}
|
|
SignalKind::Handler { .. } => {} // Handled later
|
|
}
|
|
}
|
|
|
|
// Phase 1b: Emit runtime refinement guards
|
|
// Collect type aliases from program
|
|
let mut type_aliases: std::collections::HashMap<String, &TypeExpr> = std::collections::HashMap::new();
|
|
for decl in &program.declarations {
|
|
if let Declaration::TypeAlias(alias) = decl {
|
|
type_aliases.insert(alias.name.clone(), &alias.definition);
|
|
}
|
|
}
|
|
|
|
let mut guards_emitted = false;
|
|
for decl in &program.declarations {
|
|
if let Declaration::Let(let_decl) = decl {
|
|
// Skip literals — they're statically checked by the type checker
|
|
if matches!(let_decl.value,
|
|
Expr::IntLit(_) | Expr::FloatLit(_) | Expr::StringLit(_) | Expr::BoolLit(_)
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
if let Some(ref type_ann) = let_decl.type_annotation {
|
|
// Resolve type annotation to find refinement predicate
|
|
let resolved = match type_ann {
|
|
TypeExpr::Named(name) => type_aliases.get(name).copied(),
|
|
TypeExpr::Refined { .. } => Some(type_ann),
|
|
_ => None,
|
|
};
|
|
|
|
if let Some(TypeExpr::Refined { predicate, .. }) = resolved {
|
|
if !guards_emitted {
|
|
self.emit_line("");
|
|
self.emit_line("// ── Refinement Guards ──");
|
|
guards_emitted = true;
|
|
}
|
|
let type_name = match type_ann {
|
|
TypeExpr::Named(n) => n.clone(),
|
|
_ => "refined type".to_string(),
|
|
};
|
|
let js_pred = Self::predicate_to_js(predicate, &let_decl.name);
|
|
self.emit_line(&format!(
|
|
"if (!({js_pred})) throw new Error(\"Refinement violated: `{name}` must satisfy {type_name}\");",
|
|
js_pred = js_pred,
|
|
name = let_decl.name,
|
|
type_name = type_name,
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
self.emit_line("");
|
|
|
|
// Phase 2a: Component functions
|
|
self.emit_line("// ── Components ──");
|
|
for decl in &program.declarations {
|
|
if let Declaration::Component(comp) = decl {
|
|
let params = comp.props.iter()
|
|
.map(|p| p.name.clone())
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
self.emit_line(&format!("function component_{}({}) {{", comp.name, params));
|
|
self.indent += 1;
|
|
let child_var = self.emit_view_expr(&comp.body, graph);
|
|
self.emit_line(&format!("return {};", child_var));
|
|
self.indent -= 1;
|
|
self.emit_line("}");
|
|
self.emit_line("");
|
|
}
|
|
}
|
|
|
|
// Phase 2b: Component functions
|
|
let has_components = program.declarations.iter().any(|d| matches!(d, Declaration::Component(_)));
|
|
if has_components {
|
|
self.emit_line("// ── Components ──");
|
|
for decl in &program.declarations {
|
|
if let Declaration::Component(comp) = decl {
|
|
self.emit_component_decl(comp, graph);
|
|
}
|
|
// Also handle exported components
|
|
if let Declaration::Export(_, inner) = decl {
|
|
if let Declaration::Component(comp) = inner.as_ref() {
|
|
self.emit_component_decl(comp, graph);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Phase 2c: Build views
|
|
self.emit_line("// ── Views ──");
|
|
for decl in &program.declarations {
|
|
if let Declaration::View(view) = decl {
|
|
self.emit_view(view, graph);
|
|
}
|
|
}
|
|
|
|
// Phase 3: Event handlers
|
|
self.emit_line("");
|
|
self.emit_line("// ── Handlers ──");
|
|
for decl in &program.declarations {
|
|
if let Declaration::OnHandler(handler) = decl {
|
|
self.emit_handler(handler);
|
|
}
|
|
}
|
|
|
|
// Phase 4: Route view functions
|
|
let routes: Vec<_> = program.declarations.iter()
|
|
.filter_map(|d| if let Declaration::Route(r) = d { Some(r) } else { None })
|
|
.collect();
|
|
|
|
if !routes.is_empty() {
|
|
self.emit_line("");
|
|
self.emit_line("// ── Routes ──");
|
|
for (i, route) in routes.iter().enumerate() {
|
|
self.emit_line(&format!("function route_view_{}() {{", i));
|
|
self.indent += 1;
|
|
let child = self.emit_view_expr(&route.body, graph);
|
|
self.emit_line(&format!("return {};", child));
|
|
self.indent -= 1;
|
|
self.emit_line("}");
|
|
}
|
|
}
|
|
|
|
// Phase 5: Mount to DOM
|
|
self.emit_line("");
|
|
self.emit_line("// ── Mount ──");
|
|
|
|
if !routes.is_empty() {
|
|
// Router-based mounting
|
|
self.emit_line("const __root = document.getElementById('ds-root');");
|
|
self.emit_line("let __currentView = null;");
|
|
|
|
// Emit any non-route views as layout (e.g., nav bar)
|
|
for view in views {
|
|
self.emit_line(&format!("__root.appendChild(view_{}());", view.name));
|
|
}
|
|
|
|
self.emit_line("const __routeContainer = document.createElement('div');");
|
|
self.emit_line("__routeContainer.className = 'ds-route-container';");
|
|
self.emit_line("__root.appendChild(__routeContainer);");
|
|
self.emit_line("");
|
|
self.emit_line("DS.effect(() => {");
|
|
self.indent += 1;
|
|
self.emit_line("const path = DS.route.value;");
|
|
self.emit_line("__routeContainer.innerHTML = '';");
|
|
|
|
for (i, route) in routes.iter().enumerate() {
|
|
let branch = if i == 0 { "if" } else { "} else if" };
|
|
self.emit_line(&format!(
|
|
"{} (DS.matchRoute(\"{}\", path)) {{",
|
|
branch, route.path
|
|
));
|
|
self.indent += 1;
|
|
self.emit_line(&format!("__routeContainer.appendChild(route_view_{}());", i));
|
|
self.indent -= 1;
|
|
}
|
|
self.emit_line("}");
|
|
|
|
self.indent -= 1;
|
|
self.emit_line("});");
|
|
} else if let Some(view) = views.first() {
|
|
self.emit_line(&format!(
|
|
"document.getElementById('ds-root').appendChild(view_{}());",
|
|
view.name
|
|
));
|
|
}
|
|
|
|
// Phase 6: Constraints
|
|
let constraints: Vec<_> = program.declarations.iter()
|
|
.filter_map(|d| if let Declaration::Constrain(c) = d { Some(c) } else { None })
|
|
.collect();
|
|
|
|
if !constraints.is_empty() {
|
|
self.emit_line("");
|
|
self.emit_line("// ── Constraints ──");
|
|
for c in &constraints {
|
|
let expr_js = self.emit_expr(&c.expr);
|
|
// Use view element name or fall back to querySelector
|
|
self.emit_line(&format!(
|
|
"DS.constrain(document.querySelector('[data-ds=\"{}\"]:not(.x)') || document.getElementById('ds-root'), '{}', () => {});",
|
|
c.element, c.prop, expr_js
|
|
));
|
|
}
|
|
}
|
|
|
|
// Phase 6b: Layout blocks
|
|
let layouts: Vec<_> = program.declarations.iter()
|
|
.filter_map(|d| if let Declaration::Layout(l) = d { Some(l) } else { None })
|
|
.collect();
|
|
|
|
for layout in &layouts {
|
|
self.emit_line("");
|
|
self.emit_line(&format!("// ── Layout: {} ──", layout.name));
|
|
self.emit_line(&format!("(function setupLayout_{}() {{", layout.name));
|
|
self.indent += 1;
|
|
self.emit_line("function solve() {");
|
|
self.indent += 1;
|
|
self.emit_line("const vp = { width: window.innerWidth, height: window.innerHeight };");
|
|
|
|
// Collect all element names referenced
|
|
let mut elements = std::collections::HashSet::new();
|
|
for c in &layout.constraints {
|
|
Self::collect_layout_elements(&c.left, &mut elements);
|
|
Self::collect_layout_elements(&c.right, &mut elements);
|
|
}
|
|
|
|
// Create element variable objects: { x, y, width, height }
|
|
for el in &elements {
|
|
if el == "parent" {
|
|
self.emit_line(&format!(
|
|
"const {el} = {{ x: 0, y: 0, width: vp.width, height: vp.height }};"
|
|
));
|
|
} else {
|
|
self.emit_line(&format!(
|
|
"const {el} = {{ x: 0, y: 0, width: 0, height: 0 }};"
|
|
));
|
|
}
|
|
}
|
|
|
|
// Emit constraint assignments (simple direct assignment for == constraints)
|
|
for c in &layout.constraints {
|
|
let left_js = Self::layout_expr_to_js(&c.left);
|
|
let right_js = Self::layout_expr_to_js(&c.right);
|
|
match c.op {
|
|
ds_parser::ConstraintOp::Eq => {
|
|
self.emit_line(&format!("{left_js} = {right_js};"));
|
|
}
|
|
ds_parser::ConstraintOp::Gte => {
|
|
self.emit_line(&format!("{left_js} = Math.max({left_js}, {right_js});"));
|
|
}
|
|
ds_parser::ConstraintOp::Lte => {
|
|
self.emit_line(&format!("{left_js} = Math.min({left_js}, {right_js});"));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Apply solved values to DOM elements
|
|
for el in &elements {
|
|
if el == "parent" { continue; }
|
|
self.emit_line(&format!(
|
|
"const {el}El = document.querySelector('[data-ds=\"{el}\"]');"
|
|
));
|
|
self.emit_line(&format!(
|
|
"if ({el}El) {{ {el}El.style.position = 'absolute'; {el}El.style.left = {el}.x + 'px'; {el}El.style.top = {el}.y + 'px'; {el}El.style.width = {el}.width + 'px'; {el}El.style.height = {el}.height + 'px'; }}"
|
|
));
|
|
}
|
|
|
|
self.indent -= 1;
|
|
self.emit_line("}");
|
|
self.emit_line("solve();");
|
|
self.emit_line("window.addEventListener('resize', solve);");
|
|
self.indent -= 1;
|
|
self.emit_line("})();");
|
|
}
|
|
|
|
// Phase 7: Stream initialization
|
|
let streams: Vec<_> = program.declarations.iter()
|
|
.filter_map(|d| if let Declaration::Stream(s) = d { Some(s) } else { None })
|
|
.collect();
|
|
|
|
if !streams.is_empty() {
|
|
self.emit_line("");
|
|
self.emit_line("// ── Bitstream Streaming ──");
|
|
for stream in &streams {
|
|
let mode = match stream.mode {
|
|
StreamMode::Pixel => "pixel",
|
|
StreamMode::Delta => "delta",
|
|
StreamMode::Signal => "signal",
|
|
};
|
|
let url = self.emit_expr(&stream.relay_url);
|
|
|
|
match stream.transport {
|
|
StreamTransport::WebRTC => {
|
|
// Derive signaling URL: replace /source/ with /signal/
|
|
self.emit_line(&format!(
|
|
"DS._initWebRTC({url}.replace('/source/', '/signal/'), {url}, '{mode}');"
|
|
));
|
|
}
|
|
StreamTransport::WebSocket => {
|
|
self.emit_line(&format!(
|
|
"DS._initStream({url}, '{mode}');"
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Phase 8: Timer / interval declarations
|
|
for decl in &program.declarations {
|
|
if let Declaration::Every(every) = decl {
|
|
let interval_js = self.emit_expr(&every.interval_ms);
|
|
let body_js = self.emit_event_handler_expr(&every.body);
|
|
self.emit_line("");
|
|
self.emit_line("// ── Timer ──");
|
|
self.emit_line(&format!(
|
|
"setInterval(() => {{ {}; DS.flush(); }}, {});",
|
|
body_js, interval_js
|
|
));
|
|
}
|
|
}
|
|
|
|
// Phase 9: Top-level expression statements (log, push, etc.)
|
|
for decl in &program.declarations {
|
|
if let Declaration::ExprStatement(expr) = decl {
|
|
let js = self.emit_expr(expr);
|
|
self.emit_line(&format!("{};", js));
|
|
}
|
|
}
|
|
|
|
self.indent -= 1;
|
|
self.emit_line("})();");
|
|
|
|
self.output.clone()
|
|
}
|
|
|
|
fn emit_view(&mut self, view: &ViewDecl, graph: &SignalGraph) {
|
|
self.emit_line(&format!("function view_{}() {{", view.name));
|
|
self.indent += 1;
|
|
let root_id = self.emit_view_expr(&view.body, graph);
|
|
self.emit_line(&format!("return {};", root_id));
|
|
self.indent -= 1;
|
|
self.emit_line("}");
|
|
}
|
|
|
|
fn emit_component_decl(&mut self, comp: &ComponentDecl, graph: &SignalGraph) {
|
|
self.emit_line(&format!("function DS_{}(props, __children) {{", comp.name));
|
|
self.indent += 1;
|
|
// Destructure props into local signal-compatible variables
|
|
// Props may be raw values, signals, or callback functions
|
|
for p in &comp.props {
|
|
// Create a signal-like wrapper: if prop is a function, keep as-is; if already a signal, use it; otherwise wrap
|
|
self.emit_line(&format!(
|
|
"const {} = (typeof props.{} === 'function') ? props.{} : (props.{} !== undefined && props.{} !== null) ? (typeof props.{} === 'object' && 'value' in props.{} ? props.{} : {{ get value() {{ return props.{}; }} }}) : {{ get value() {{ return \"\"; }} }};",
|
|
p.name, p.name, p.name, p.name, p.name, p.name, p.name, p.name, p.name
|
|
));
|
|
}
|
|
let root_id = self.emit_view_expr(&comp.body, graph);
|
|
self.emit_line(&format!("return {};", root_id));
|
|
self.indent -= 1;
|
|
self.emit_line("}");
|
|
}
|
|
|
|
/// Emit a view expression and return the variable name of the created DOM node.
|
|
fn emit_view_expr(&mut self, expr: &Expr, graph: &SignalGraph) -> String {
|
|
match expr {
|
|
Expr::Container(container) => {
|
|
// ─── PHYSICS SCENE ───
|
|
if matches!(container.kind, ContainerKind::Scene) {
|
|
return self.emit_scene(container, graph);
|
|
}
|
|
|
|
let node_var = self.next_node_id();
|
|
let tag = match &container.kind {
|
|
ContainerKind::Column => "div",
|
|
ContainerKind::Row => "div",
|
|
ContainerKind::Stack => "div",
|
|
ContainerKind::Panel => "div",
|
|
ContainerKind::Form => "form",
|
|
ContainerKind::List => "ul",
|
|
ContainerKind::Scene => "canvas", // handled above
|
|
ContainerKind::Custom(s) => s,
|
|
};
|
|
|
|
let class = match &container.kind {
|
|
ContainerKind::Column => "ds-column",
|
|
ContainerKind::Row => "ds-row",
|
|
ContainerKind::Stack => "ds-stack",
|
|
ContainerKind::Panel => "ds-panel",
|
|
_ => "",
|
|
};
|
|
|
|
self.emit_line(&format!("const {} = document.createElement('{}');", node_var, tag));
|
|
if !class.is_empty() {
|
|
self.emit_line(&format!("{}.className = '{}';", node_var, class));
|
|
}
|
|
|
|
// Handle container props (variant, class, events, style, layout)
|
|
let container_tag = match &container.kind {
|
|
ContainerKind::Column => "column",
|
|
ContainerKind::Row => "row",
|
|
ContainerKind::Stack => "stack",
|
|
ContainerKind::Panel => "panel",
|
|
_ => "column",
|
|
};
|
|
for (key, val) in &container.props {
|
|
match key.as_str() {
|
|
"variant" => {
|
|
match val {
|
|
Expr::StringLit(s) if s.segments.len() == 1 => {
|
|
if let Some(ds_parser::StringSegment::Literal(v)) = s.segments.first() {
|
|
let css_class = variant_to_css(container_tag, v);
|
|
self.emit_line(&format!(
|
|
"{}.className += ' {}';",
|
|
node_var, css_class
|
|
));
|
|
}
|
|
}
|
|
_ => {
|
|
let js = self.emit_expr(val);
|
|
let map_entries = variant_map_js(container_tag);
|
|
self.emit_line(&format!(
|
|
"DS.effect(() => {{ const v = {js}; const cls = ({map_entries})[typeof v === 'object' ? v.value : v] || ''; {node_var}.className = 'ds-{container_tag} ' + cls; }});"
|
|
));
|
|
}
|
|
}
|
|
}
|
|
"class" => {
|
|
let js = self.emit_expr(val);
|
|
self.emit_line(&format!("{}.className += ' ' + {};", node_var, js));
|
|
}
|
|
"click" | "submit" => {
|
|
let handler_js = self.emit_event_handler_expr(val);
|
|
self.emit_line(&format!(
|
|
"{}.addEventListener('{}', (e) => {{ {}; DS.flush(); }});",
|
|
node_var, key, handler_js
|
|
));
|
|
}
|
|
"style" => {
|
|
let js = self.emit_expr(val);
|
|
self.emit_line(&format!("{}.style.cssText = {};", node_var, js));
|
|
}
|
|
_ => {
|
|
// Layout props (x, y, width, height) or arbitrary style
|
|
let js_val = self.emit_expr(val);
|
|
if graph.name_to_id.contains_key(&js_val) || self.is_signal_ref(&js_val) {
|
|
self.emit_line(&format!(
|
|
"DS.effect(() => {{ {}.style.{} = {}.value + 'px'; }});",
|
|
node_var, key, js_val
|
|
));
|
|
} else {
|
|
self.emit_line(&format!("{}.style.{} = {};", node_var, key, js_val));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Emit children
|
|
for child in &container.children {
|
|
let child_var = self.emit_view_expr(child, graph);
|
|
self.emit_line(&format!("{}.appendChild({});", node_var, child_var));
|
|
}
|
|
|
|
node_var
|
|
}
|
|
|
|
Expr::Element(element) => {
|
|
let node_var = self.next_node_id();
|
|
let html_tag = match element.tag.as_str() {
|
|
"text" => "span",
|
|
"button" => "button",
|
|
"input" => "input",
|
|
"image" | "avatar" => "img",
|
|
"link" => "a",
|
|
"label" => "label",
|
|
"spinner" => "div",
|
|
"skeleton" => "div",
|
|
_ => "div",
|
|
};
|
|
|
|
self.emit_line(&format!("const {} = document.createElement('{}');", node_var, html_tag));
|
|
self.emit_line(&format!("{}.className = 'ds-{}';", node_var, element.tag));
|
|
|
|
// Handle text content / arguments
|
|
for arg in &element.args {
|
|
match arg {
|
|
Expr::StringLit(s) => {
|
|
let has_interp = s.segments.iter().any(|seg| matches!(seg, StringSegment::Interpolation(_)));
|
|
if has_interp {
|
|
// Reactive interpolated string: wrap in effect
|
|
let template_js = self.emit_expr(arg);
|
|
self.emit_line(&format!(
|
|
"DS.effect(() => {{ {}.textContent = {}; }});",
|
|
node_var, template_js
|
|
));
|
|
} else if let Some(StringSegment::Literal(text)) = s.segments.first() {
|
|
self.emit_line(&format!(
|
|
"{}.textContent = \"{}\";",
|
|
node_var,
|
|
text.replace('"', "\\\"")
|
|
));
|
|
}
|
|
}
|
|
Expr::Ident(name) => {
|
|
if self.is_local_var(name) {
|
|
// Non-reactive local variable (e.g., for-in loop var)
|
|
self.emit_line(&format!(
|
|
"{}.textContent = {};",
|
|
node_var, name
|
|
));
|
|
} else {
|
|
// Reactive text binding!
|
|
self.emit_line(&format!(
|
|
"DS.effect(() => {{ {}.textContent = {}.value; }});",
|
|
node_var, name
|
|
));
|
|
}
|
|
}
|
|
_ => {
|
|
let js = self.emit_expr(arg);
|
|
self.emit_line(&format!("{}.textContent = {};", node_var, js));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle props (event handlers, attributes)
|
|
for (key, val) in &element.props {
|
|
match key.as_str() {
|
|
"click" | "input" | "change" | "submit" | "keydown" | "keyup" | "mousedown" | "mouseup" => {
|
|
let handler_js = self.emit_event_handler_expr(val);
|
|
let dom_event = match key.as_str() {
|
|
"click" => "click",
|
|
"input" => "input",
|
|
"change" => "change",
|
|
"submit" => "submit",
|
|
"keydown" => "keydown",
|
|
"keyup" => "keyup",
|
|
"mousedown" => "mousedown",
|
|
"mouseup" => "mouseup",
|
|
_ => key,
|
|
};
|
|
self.emit_line(&format!(
|
|
"{}.addEventListener('{}', (e) => {{ {}; DS.flush(); }});",
|
|
node_var, dom_event, handler_js
|
|
));
|
|
}
|
|
"bind" => {
|
|
// Two-way binding: signal <-> input
|
|
// Use raw signal name (not emit_expr which adds .value)
|
|
let signal_name = match val {
|
|
Expr::Ident(name) => name.clone(),
|
|
_ => self.emit_expr(val),
|
|
};
|
|
// Signal -> input
|
|
self.emit_line(&format!(
|
|
"DS.effect(() => {{ {}.value = {}.value; }});",
|
|
node_var, signal_name
|
|
));
|
|
// Input -> signal (with streaming diff)
|
|
self.emit_line(&format!(
|
|
"{}.addEventListener('input', (e) => {{ {}.value = e.target.value; DS._streamDiff(\"{}\", e.target.value); DS.flush(); }});",
|
|
node_var, signal_name, signal_name
|
|
));
|
|
}
|
|
"class" => {
|
|
let js = self.emit_expr(val);
|
|
self.emit_line(&format!("{}.className += ' ' + {};", node_var, js));
|
|
}
|
|
"placeholder" => {
|
|
let js = self.emit_expr(val);
|
|
self.emit_line(&format!("{}.placeholder = {};", node_var, js));
|
|
}
|
|
"value" => {
|
|
let js = self.emit_expr(val);
|
|
self.emit_line(&format!(
|
|
"DS.effect(() => {{ {}.value = {}.value; }});",
|
|
node_var, js
|
|
));
|
|
}
|
|
"style" => {
|
|
let js = self.emit_expr(val);
|
|
self.emit_line(&format!("{}.style.cssText = {};", node_var, js));
|
|
}
|
|
"disabled" => {
|
|
let js = self.emit_expr(val);
|
|
self.emit_line(&format!(
|
|
"DS.effect(() => {{ {}.disabled = {}.value; }});",
|
|
node_var, js
|
|
));
|
|
}
|
|
"variant" => {
|
|
// Map variant prop to CSS class based on element tag
|
|
let tag = element.tag.as_str();
|
|
match val {
|
|
Expr::StringLit(s) if s.segments.len() == 1 => {
|
|
// Static variant: emit class directly
|
|
if let Some(ds_parser::StringSegment::Literal(v)) = s.segments.first() {
|
|
let css_class = variant_to_css(tag, v);
|
|
self.emit_line(&format!(
|
|
"{}.className += ' {}';",
|
|
node_var, css_class
|
|
));
|
|
}
|
|
}
|
|
_ => {
|
|
// Dynamic variant: reactive class via inline lookup
|
|
let js = self.emit_expr(val);
|
|
let tag = element.tag.as_str();
|
|
let map_entries = variant_map_js(tag);
|
|
self.emit_line(&format!(
|
|
"DS.effect(() => {{ const v = {js}; const cls = ({map_entries})[typeof v === 'object' ? v.value : v] || ''; {node_var}.className = 'ds-{tag} ' + cls; }});"
|
|
));
|
|
}
|
|
}
|
|
}
|
|
_ => {
|
|
let js = self.emit_expr(val);
|
|
self.emit_line(&format!("{}.setAttribute('{}', {});", node_var, key, js));
|
|
}
|
|
}
|
|
}
|
|
|
|
node_var
|
|
}
|
|
|
|
Expr::When(cond, body, else_body) => {
|
|
let else_expr = else_body.clone();
|
|
let anchor_var = self.next_node_id();
|
|
let container_var = self.next_node_id();
|
|
let cond_js = self.emit_expr(cond);
|
|
|
|
self.emit_line(&format!("const {} = document.createComment('when');", anchor_var));
|
|
self.emit_line(&format!("let {} = null;", container_var));
|
|
|
|
let has_else = else_expr.is_some();
|
|
let else_container = if has_else {
|
|
let ec = self.next_node_id();
|
|
self.emit_line(&format!("let {} = null;", ec));
|
|
Some(ec)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let effect_fn = format!("_when_{}", anchor_var);
|
|
self.emit_line(&format!("DS.effect(function {}() {{", effect_fn));
|
|
self.indent += 1;
|
|
// Guard: if anchor not yet in DOM (e.g. inside slot DocumentFragment), defer
|
|
self.emit_line(&format!(
|
|
"if (!{}.parentNode) {{ requestAnimationFrame(() => DS.effect({})); return; }}",
|
|
anchor_var, effect_fn
|
|
));
|
|
self.emit_line(&format!("const show = {};", cond_js));
|
|
|
|
// Show when: condition true and not already showing
|
|
self.emit_line(&format!("if (show && !{}) {{", container_var));
|
|
self.indent += 1;
|
|
if let Some(ref ec) = else_container {
|
|
self.emit_line(&format!("if ({}) {{ {}.remove(); {} = null; }}", ec, ec, ec));
|
|
}
|
|
let child_var = self.emit_view_expr(body, graph);
|
|
self.emit_line(&format!("{} = {};", container_var, child_var));
|
|
self.emit_line(&format!(
|
|
"{}.parentNode.insertBefore({}, {}.nextSibling);",
|
|
anchor_var, container_var, anchor_var
|
|
));
|
|
self.indent -= 1;
|
|
|
|
// Hide when: condition false and currently showing
|
|
self.emit_line(&format!("}} else if (!show && {}) {{", container_var));
|
|
self.indent += 1;
|
|
self.emit_line(&format!("{}.remove();", container_var));
|
|
self.emit_line(&format!("{} = null;", container_var));
|
|
if let Some(eb) = &else_expr {
|
|
if let Some(ec) = &else_container {
|
|
let else_child = self.emit_view_expr(eb, graph);
|
|
self.emit_line(&format!("{} = {};", ec, else_child));
|
|
self.emit_line(&format!(
|
|
"{}.parentNode.insertBefore({}, {}.nextSibling);",
|
|
anchor_var, ec, anchor_var
|
|
));
|
|
}
|
|
}
|
|
self.indent -= 1;
|
|
|
|
// Initial else: show=false and nothing rendered yet
|
|
if let Some(eb) = &else_expr {
|
|
if let Some(ec) = &else_container {
|
|
self.emit_line(&format!("}} else if (!show && !{} && !{}) {{", container_var, ec));
|
|
self.indent += 1;
|
|
let else_child2 = self.emit_view_expr(eb, graph);
|
|
self.emit_line(&format!("{} = {};", ec, else_child2));
|
|
self.emit_line(&format!(
|
|
"{}.parentNode.insertBefore({}, {}.nextSibling);",
|
|
anchor_var, ec, anchor_var
|
|
));
|
|
self.indent -= 1;
|
|
}
|
|
}
|
|
|
|
self.emit_line("}");
|
|
self.indent -= 1;
|
|
self.emit_line("});");
|
|
|
|
anchor_var
|
|
}
|
|
|
|
// Each loop: `each item in list -> template`
|
|
Expr::Each(item_name, list_expr, body) => {
|
|
let container_var = self.next_node_id();
|
|
let iter_js = self.emit_expr(list_expr);
|
|
let iter_var = self.next_node_id();
|
|
|
|
self.emit_line(&format!("const {} = document.createElement('div');", container_var));
|
|
self.emit_line(&format!("{}.className = 'ds-each-list';", container_var));
|
|
|
|
self.emit_line("DS.effect(() => {");
|
|
self.indent += 1;
|
|
self.emit_line(&format!("const {} = {};", iter_var, iter_js));
|
|
self.emit_line(&format!("{}.innerHTML = '';", container_var));
|
|
|
|
self.emit_line(&format!(
|
|
"const __list = ({0} && {0}.value !== undefined) ? {0}.value : (Array.isArray({0}) ? {0} : []);",
|
|
iter_var
|
|
));
|
|
self.emit_line(&format!("__list.forEach(({item_name}, _idx) => {{"));
|
|
self.indent += 1;
|
|
self.push_scope(&[item_name.as_str(), "_idx"]);
|
|
let child_var = self.emit_view_expr(body, graph);
|
|
self.emit_line(&format!("{}.appendChild({});", container_var, child_var));
|
|
self.pop_scope();
|
|
self.indent -= 1;
|
|
self.emit_line("});");
|
|
self.indent -= 1;
|
|
self.emit_line("});");
|
|
|
|
container_var
|
|
}
|
|
|
|
// ForIn reactive list: `for item in items -> body`
|
|
Expr::ForIn { item, index, iter, body } => {
|
|
let container_var = self.next_node_id();
|
|
let iter_js = self.emit_expr(iter);
|
|
let iter_var = self.next_node_id(); // unique name to avoid shadowing
|
|
|
|
self.emit_line(&format!("const {} = document.createElement('div');", container_var));
|
|
self.emit_line(&format!("{}.className = 'ds-for-list';", container_var));
|
|
|
|
// Reactive effect that re-renders the list when the iterable changes
|
|
self.emit_line("DS.effect(() => {");
|
|
self.indent += 1;
|
|
self.emit_line(&format!("const {} = {};", iter_var, iter_js));
|
|
self.emit_line(&format!("{}.innerHTML = '';", container_var));
|
|
|
|
let idx_var = index.as_deref().unwrap_or("_idx");
|
|
self.emit_line(&format!("const __list = ({0} && {0}.value !== undefined) ? {0}.value : (Array.isArray({0}) ? {0} : []);", iter_var));
|
|
self.emit_line(&format!("__list.forEach(({item}, {idx_var}) => {{"));
|
|
self.indent += 1;
|
|
|
|
// Push scope for loop variables
|
|
let mut scope_vars: Vec<&str> = vec![item.as_str()];
|
|
if let Some(idx) = index {
|
|
scope_vars.push(idx.as_str());
|
|
}
|
|
self.push_scope(&scope_vars);
|
|
|
|
let child_var = self.emit_view_expr(body, graph);
|
|
self.emit_line(&format!("{}.appendChild({});", container_var, child_var));
|
|
|
|
// Pop scope
|
|
self.pop_scope();
|
|
|
|
self.indent -= 1;
|
|
self.emit_line("});");
|
|
self.indent -= 1;
|
|
self.emit_line("});");
|
|
|
|
container_var
|
|
}
|
|
|
|
// Component usage: `Button { label: "hello" }` or `Card { title: "x" } [ children ]`
|
|
Expr::ComponentUse { name, props, children } => {
|
|
let node_var = self.next_node_id();
|
|
let props_js: Vec<String> = props.iter()
|
|
.map(|(k, v)| {
|
|
// Detect callback props (assignments, method calls, blocks)
|
|
let is_callback = matches!(v,
|
|
Expr::Assign(_, _, _) | Expr::MethodCall(_, _, _) | Expr::Block(_)
|
|
);
|
|
if is_callback {
|
|
let handler_js = self.emit_event_handler_expr(v);
|
|
format!("{}: () => {{ {}; DS.flush() }}", k, handler_js)
|
|
} else {
|
|
format!("{}: {}", k, self.emit_expr(v))
|
|
}
|
|
})
|
|
.collect();
|
|
if children.is_empty() {
|
|
self.emit_line(&format!("const {} = DS_{}({{ {} }});", node_var, name, props_js.join(", ")));
|
|
} else {
|
|
// Build children factory function
|
|
let children_fn = self.next_node_id();
|
|
self.emit_line(&format!("function {}() {{", children_fn));
|
|
self.indent += 1;
|
|
let container = self.next_node_id();
|
|
self.emit_line(&format!("const {} = document.createDocumentFragment();", container));
|
|
for child in children {
|
|
let child_var = self.emit_view_expr(child, graph);
|
|
self.emit_line(&format!("{}.appendChild({});", container, child_var));
|
|
}
|
|
self.emit_line(&format!("return {};", container));
|
|
self.indent -= 1;
|
|
self.emit_line("}");
|
|
self.emit_line(&format!("const {} = DS_{}({{ {} }}, {});", node_var, name, props_js.join(", "), children_fn));
|
|
}
|
|
node_var
|
|
}
|
|
|
|
Expr::Match(scrutinee, arms) => {
|
|
let container_var = self.next_node_id();
|
|
self.emit_line(&format!("const {} = document.createElement('div');", container_var));
|
|
self.emit_line(&format!("{}.className = 'ds-match';", container_var));
|
|
|
|
let scrutinee_js = self.emit_expr(scrutinee);
|
|
self.emit_line("DS.effect(() => {");
|
|
self.indent += 1;
|
|
self.emit_line(&format!("const val = {};", scrutinee_js));
|
|
self.emit_line(&format!("{}.innerHTML = '';", container_var));
|
|
|
|
for (i, arm) in arms.iter().enumerate() {
|
|
let prefix = if i == 0 { "if" } else { "} else if" };
|
|
let pattern_js = self.emit_pattern_check(&arm.pattern, "val");
|
|
self.emit_line(&format!("{} ({}) {{", prefix, pattern_js));
|
|
self.indent += 1;
|
|
let child = self.emit_view_expr(&arm.body, graph);
|
|
self.emit_line(&format!("{}.appendChild({});", container_var, child));
|
|
self.indent -= 1;
|
|
}
|
|
self.emit_line("}");
|
|
self.indent -= 1;
|
|
self.emit_line("});");
|
|
|
|
container_var
|
|
}
|
|
|
|
|
|
// Slot: render children passed to this component
|
|
Expr::Slot => {
|
|
let node_var = self.next_node_id();
|
|
self.emit_line(&format!(
|
|
"const {} = __children ? __children() : document.createComment('empty-slot');",
|
|
node_var
|
|
));
|
|
node_var
|
|
}
|
|
|
|
// Fallback: just create a text node
|
|
_ => {
|
|
let node_var = self.next_node_id();
|
|
let js = self.emit_expr(expr);
|
|
self.emit_line(&format!(
|
|
"const {} = document.createTextNode({});",
|
|
node_var, js
|
|
));
|
|
node_var
|
|
}
|
|
}
|
|
}
|
|
|
|
fn emit_handler(&mut self, handler: &OnHandler) {
|
|
let handler_js = self.emit_event_handler_expr(&handler.body);
|
|
if let Some(param) = &handler.param {
|
|
self.emit_line(&format!(
|
|
"DS.onEvent('{}', ({}) => {{ {}; DS.flush(); }});",
|
|
handler.event, param, handler_js
|
|
));
|
|
} else {
|
|
self.emit_line(&format!(
|
|
"DS.onEvent('{}', () => {{ {}; DS.flush(); }});",
|
|
handler.event, handler_js
|
|
));
|
|
}
|
|
}
|
|
|
|
// ── Expression emitters ─────────────────────────────
|
|
|
|
pub fn emit_expr(&self, expr: &Expr) -> String {
|
|
match expr {
|
|
Expr::IntLit(n) => format!("{n}"),
|
|
Expr::FloatLit(n) => format!("{n}"),
|
|
Expr::BoolLit(b) => format!("{b}"),
|
|
Expr::StringLit(s) => {
|
|
if s.segments.len() == 1 {
|
|
if let StringSegment::Literal(text) = &s.segments[0] {
|
|
return format!("\"{}\"", text.replace('"', "\\\""));
|
|
}
|
|
}
|
|
// Template literal with interpolation
|
|
let mut parts = Vec::new();
|
|
for seg in &s.segments {
|
|
match seg {
|
|
StringSegment::Literal(text) => parts.push(text.clone()),
|
|
StringSegment::Interpolation(expr) => {
|
|
parts.push(format!("${{{}}}", self.emit_expr(expr)));
|
|
}
|
|
}
|
|
}
|
|
format!("`{}`", parts.join(""))
|
|
}
|
|
Expr::Ident(name) if self.is_local_var(name) => {
|
|
format!("{name}")
|
|
}
|
|
Expr::Ident(name) => {
|
|
if self.is_local_var(name) {
|
|
// Local variables (loop vars, function params) — no .value
|
|
format!("{name}")
|
|
} else {
|
|
format!("{name}.value")
|
|
}
|
|
}
|
|
Expr::DotAccess(base, field) => {
|
|
let base_js = self.emit_expr(base);
|
|
format!("{base_js}.{field}")
|
|
}
|
|
Expr::Index(base, index) => {
|
|
let base_js = self.emit_expr(base);
|
|
let idx_js = self.emit_expr(index);
|
|
format!("{base_js}[{idx_js}]")
|
|
}
|
|
Expr::BinOp(left, op, right) => {
|
|
let l = self.emit_expr(left);
|
|
let r = self.emit_expr(right);
|
|
match op {
|
|
BinOp::Div => format!("Math.trunc({l} / {r})"),
|
|
_ => {
|
|
let op_str = match op {
|
|
BinOp::Add => "+",
|
|
BinOp::Sub => "-",
|
|
BinOp::Mul => "*",
|
|
BinOp::Div => unreachable!(),
|
|
BinOp::Mod => "%",
|
|
BinOp::Eq => "===",
|
|
BinOp::Neq => "!==",
|
|
BinOp::Lt => "<",
|
|
BinOp::Gt => ">",
|
|
BinOp::Lte => "<=",
|
|
BinOp::Gte => ">=",
|
|
BinOp::And => "&&",
|
|
BinOp::Or => "||",
|
|
};
|
|
format!("({l} {op_str} {r})")
|
|
}
|
|
}
|
|
}
|
|
Expr::UnaryOp(op, inner) => {
|
|
let inner_js = self.emit_expr(inner);
|
|
match op {
|
|
UnaryOp::Neg => format!("(-{inner_js})"),
|
|
UnaryOp::Not => format!("(!{inner_js})"),
|
|
}
|
|
}
|
|
Expr::Call(name, args) => {
|
|
let args_js: Vec<String> = args.iter().map(|a| self.emit_expr(a)).collect();
|
|
|
|
// ── Built-in function dispatch ──
|
|
match name.as_str() {
|
|
// Navigation & springs
|
|
"navigate" => format!("DS.navigate({})", args_js.join(", ")),
|
|
"spring" => format!("DS.spring({})", args_js.join(", ")),
|
|
|
|
// ── Array operations ──
|
|
"len" if args.len() == 1 => format!("{}.length", args_js[0]),
|
|
"push" if args.len() == 2 => {
|
|
// push mutates array, need to get signal root name
|
|
let root = self.get_signal_root_name(&args[0]);
|
|
format!("(() => {{ {}.push({}); {root}.value = [...{root}.value]; DS._streamDiff(\"{root}\", {root}.value); return {root}.value; }})()",
|
|
args_js[0], args_js[1], )
|
|
}
|
|
"pop" if args.len() == 1 => {
|
|
let root = self.get_signal_root_name(&args[0]);
|
|
format!("(() => {{ const _v = {}.pop(); {root}.value = [...{root}.value]; DS._streamDiff(\"{root}\", {root}.value); return _v; }})()",
|
|
args_js[0])
|
|
}
|
|
"filter" if args.len() == 2 => format!("{}.filter({})", args_js[0], args_js[1]),
|
|
"map" if args.len() == 2 => format!("{}.map({})", args_js[0], args_js[1]),
|
|
"concat" if args.len() == 2 => format!("[...{}, ...{}]", args_js[0], args_js[1]),
|
|
"contains" if args.len() == 2 => format!("{}.includes({})", args_js[0], args_js[1]),
|
|
"reverse" if args.len() == 1 => {
|
|
let root = self.get_signal_root_name(&args[0]);
|
|
format!("(() => {{ {}.reverse(); {root}.value = [...{root}.value]; DS._streamDiff(\"{root}\", {root}.value); return {root}.value; }})()",
|
|
args_js[0])
|
|
}
|
|
"slice" if args.len() >= 2 => format!("{}.slice({})", args_js[0], args_js[1..].join(", ")),
|
|
"indexOf" if args.len() == 2 => format!("{}.indexOf({})", args_js[0], args_js[1]),
|
|
"find" if args.len() == 2 => format!("{}.find({})", args_js[0], args_js[1]),
|
|
"some" if args.len() == 2 => format!("{}.some({})", args_js[0], args_js[1]),
|
|
"every" if args.len() == 2 => format!("{}.every({})", args_js[0], args_js[1]),
|
|
"flat" if args.len() == 1 => format!("{}.flat()", args_js[0]),
|
|
"sort" if args.len() >= 1 => {
|
|
let root = self.get_signal_root_name(&args[0]);
|
|
if args.len() == 2 {
|
|
format!("(() => {{ {}.sort({}); {root}.value = [...{root}.value]; return {root}.value; }})()",
|
|
args_js[0], args_js[1])
|
|
} else {
|
|
format!("(() => {{ {}.sort(); {root}.value = [...{root}.value]; return {root}.value; }})()",
|
|
args_js[0])
|
|
}
|
|
}
|
|
|
|
// ── Math operations ──
|
|
"abs" if args.len() == 1 => format!("Math.abs({})", args_js[0]),
|
|
"min" => format!("Math.min({})", args_js.join(", ")),
|
|
"max" => format!("Math.max({})", args_js.join(", ")),
|
|
"floor" if args.len() == 1 => format!("Math.floor({})", args_js[0]),
|
|
"ceil" if args.len() == 1 => format!("Math.ceil({})", args_js[0]),
|
|
"round" if args.len() == 1 => format!("Math.round({})", args_js[0]),
|
|
"random" if args.is_empty() => "Math.random()".to_string(),
|
|
"sqrt" if args.len() == 1 => format!("Math.sqrt({})", args_js[0]),
|
|
"pow" if args.len() == 2 => format!("Math.pow({}, {})", args_js[0], args_js[1]),
|
|
"sin" if args.len() == 1 => format!("Math.sin({})", args_js[0]),
|
|
"cos" if args.len() == 1 => format!("Math.cos({})", args_js[0]),
|
|
"tan" if args.len() == 1 => format!("Math.tan({})", args_js[0]),
|
|
"atan2" if args.len() == 2 => format!("Math.atan2({}, {})", args_js[0], args_js[1]),
|
|
"clamp" if args.len() == 3 => format!("Math.min(Math.max({}, {}), {})", args_js[0], args_js[1], args_js[2]),
|
|
"lerp" if args.len() == 3 => format!("({} + ({} - {}) * {})", args_js[0], args_js[1], args_js[0], args_js[2]),
|
|
|
|
// ── String operations ──
|
|
"split" if args.len() == 2 => format!("{}.split({})", args_js[0], args_js[1]),
|
|
"join" if args.len() == 2 => format!("{}.join({})", args_js[0], args_js[1]),
|
|
"trim" if args.len() == 1 => format!("{}.trim()", args_js[0]),
|
|
"upper" if args.len() == 1 => format!("{}.toUpperCase()", args_js[0]),
|
|
"lower" if args.len() == 1 => format!("{}.toLowerCase()", args_js[0]),
|
|
"replace" if args.len() == 3 => format!("{}.replace({}, {})", args_js[0], args_js[1], args_js[2]),
|
|
"starts_with" if args.len() == 2 => format!("{}.startsWith({})", args_js[0], args_js[1]),
|
|
"ends_with" if args.len() == 2 => format!("{}.endsWith({})", args_js[0], args_js[1]),
|
|
"char_at" if args.len() == 2 => format!("{}.charAt({})", args_js[0], args_js[1]),
|
|
"substring" if args.len() == 3 => format!("{}.substring({}, {})", args_js[0], args_js[1], args_js[2]),
|
|
|
|
// ── Conversion ──
|
|
"int" if args.len() == 1 => format!("parseInt({})", args_js[0]),
|
|
"float" if args.len() == 1 => format!("parseFloat({})", args_js[0]),
|
|
"string" if args.len() == 1 => format!("String({})", args_js[0]),
|
|
"bool" if args.len() == 1 => format!("Boolean({})", args_js[0]),
|
|
|
|
// ── Console / debug ──
|
|
"log" => format!("console.log({})", args_js.join(", ")),
|
|
"debug" => format!("console.debug({})", args_js.join(", ")),
|
|
"warn" => format!("console.warn({})", args_js.join(", ")),
|
|
|
|
// ── Timer ──
|
|
"delay" if args.len() == 2 => format!("setTimeout(() => {{ {} }}, {})", args_js[0], args_js[1]),
|
|
|
|
// ── Fallback: user-defined function ──
|
|
_ => format!("{}({})", name, args_js.join(", ")),
|
|
}
|
|
}
|
|
Expr::If(cond, then_b, else_b) => {
|
|
let c = self.emit_expr(cond);
|
|
let t = self.emit_expr(then_b);
|
|
let e = self.emit_expr(else_b);
|
|
format!("({c} ? {t} : {e})")
|
|
}
|
|
Expr::Lambda(params, body) => {
|
|
let body_js = self.emit_expr(body);
|
|
format!("({}) => {}", params.join(", "), body_js)
|
|
}
|
|
Expr::Record(fields) => {
|
|
let fields_js: Vec<String> = fields
|
|
.iter()
|
|
.map(|(k, v)| format!("{}: {}", k, self.emit_expr(v)))
|
|
.collect();
|
|
format!("{{ {} }}", fields_js.join(", "))
|
|
}
|
|
Expr::List(items) => {
|
|
let items_js: Vec<String> = items.iter().map(|i| self.emit_expr(i)).collect();
|
|
format!("[{}]", items_js.join(", "))
|
|
}
|
|
Expr::StreamFrom { source, select, .. } => {
|
|
if select.is_empty() {
|
|
format!("DS._connectStream(\"{}\")", source)
|
|
} else {
|
|
let select_js: Vec<String> = select.iter().map(|s| format!("\"{}\"", s)).collect();
|
|
format!("DS._connectStream(\"{}\", [{}])", source, select_js.join(","))
|
|
}
|
|
}
|
|
Expr::Match(scrutinee, arms) => {
|
|
let scrut_js = self.emit_expr(scrutinee);
|
|
if arms.is_empty() {
|
|
return "null".to_string();
|
|
}
|
|
// Build chained ternary: (s === "a" ? exprA : s === "b" ? exprB : defaultExpr)
|
|
let mut parts = Vec::new();
|
|
let mut default_js = "null".to_string();
|
|
for arm in arms {
|
|
let body_js = self.emit_expr(&arm.body);
|
|
match &arm.pattern {
|
|
Pattern::Wildcard | Pattern::Ident(_) => {
|
|
default_js = body_js;
|
|
}
|
|
Pattern::Literal(lit_expr) => {
|
|
let lit_js = self.emit_expr(lit_expr);
|
|
parts.push(format!("({scrut_js} === {lit_js} ? {body_js}"));
|
|
}
|
|
Pattern::Constructor(name, _) => {
|
|
parts.push(format!("({scrut_js} === \"{name}\" ? {body_js}"));
|
|
}
|
|
}
|
|
}
|
|
if parts.is_empty() {
|
|
default_js
|
|
} else {
|
|
let close_parens = ")".repeat(parts.len());
|
|
format!("{} : {}{}", parts.join(" : "), default_js, close_parens)
|
|
}
|
|
}
|
|
Expr::ComponentUse { name, props, children: _ } => {
|
|
let props_js: Vec<String> = props
|
|
.iter()
|
|
.map(|(k, v)| format!("{}: {}", k, self.emit_expr(v)))
|
|
.collect();
|
|
format!("DS_{}({{ {} }})", name, props_js.join(", "))
|
|
}
|
|
Expr::MethodCall(obj, method, args) => {
|
|
let obj_js = self.emit_expr(obj);
|
|
let args_js: Vec<String> = args.iter().map(|a| self.emit_expr(a)).collect();
|
|
format!("{}.{}({})", obj_js, method, args_js.join(", "))
|
|
}
|
|
_ => "null".to_string(),
|
|
}
|
|
}
|
|
|
|
/// Emit an event handler expression (assignment, etc.)
|
|
fn emit_event_handler_expr(&self, expr: &Expr) -> String {
|
|
match expr {
|
|
Expr::Assign(target, op, value) => {
|
|
let value_js = self.emit_expr(value);
|
|
// Determine the assignment target and root signal for streaming
|
|
let (assign, root_for_diff) = match target.as_ref() {
|
|
Expr::Ident(name) => {
|
|
let a = match op {
|
|
AssignOp::Set => format!("{name}.value = {value_js}"),
|
|
AssignOp::AddAssign => format!("{name}.value += {value_js}"),
|
|
AssignOp::SubAssign => format!("{name}.value -= {value_js}"),
|
|
};
|
|
(a, name.clone())
|
|
}
|
|
Expr::DotAccess(base, field) => {
|
|
let base_str = self.emit_expr(base);
|
|
let target_str = format!("{base_str}.{field}");
|
|
let a = match op {
|
|
AssignOp::Set => format!("{target_str} = {value_js}"),
|
|
AssignOp::AddAssign => format!("{target_str} += {value_js}"),
|
|
AssignOp::SubAssign => format!("{target_str} -= {value_js}"),
|
|
};
|
|
(a, base_str)
|
|
}
|
|
Expr::Index(base, index) => {
|
|
let base_str = self.emit_expr(base);
|
|
let idx_str = self.emit_expr(index);
|
|
let target_str = format!("{base_str}[{idx_str}]");
|
|
let root = match base.as_ref() {
|
|
Expr::Ident(name) => name.clone(),
|
|
_ => base_str.clone(),
|
|
};
|
|
let a = match op {
|
|
AssignOp::Set => format!("{target_str} = {value_js}"),
|
|
AssignOp::AddAssign => format!("{target_str} += {value_js}"),
|
|
AssignOp::SubAssign => format!("{target_str} -= {value_js}"),
|
|
};
|
|
(a, root)
|
|
}
|
|
_ => {
|
|
let s = self.emit_expr(target);
|
|
let a = match op {
|
|
AssignOp::Set => format!("{s} = {value_js}"),
|
|
AssignOp::AddAssign => format!("{s} += {value_js}"),
|
|
AssignOp::SubAssign => format!("{s} -= {value_js}"),
|
|
};
|
|
(a, s)
|
|
}
|
|
};
|
|
// For indexed mutations, re-trigger the signal to notify the reactive system
|
|
match target.as_ref() {
|
|
Expr::Index(_, _) => {
|
|
// Mutate in-place then re-assign to trigger signal change detection
|
|
format!(
|
|
"{}; {}.value = [...{}.value]; DS._streamDiff(\"{}\", {}.value)",
|
|
assign, root_for_diff, root_for_diff, root_for_diff, root_for_diff
|
|
)
|
|
}
|
|
_ => {
|
|
// Stream diff: broadcast signal change if streaming is active
|
|
format!("{}; DS._streamDiff(\"{}\", {}.value)", assign, root_for_diff, root_for_diff)
|
|
}
|
|
}
|
|
}
|
|
// Method calls on arrays: items.push(x), items.remove(idx), items.filter(fn)
|
|
Expr::MethodCall(obj, method, args) => {
|
|
let obj_js = self.emit_expr(obj);
|
|
let signal_name = match obj.as_ref() {
|
|
Expr::Ident(name) => name.clone(),
|
|
_ => obj_js.clone(),
|
|
};
|
|
let args_js: Vec<String> = args.iter().map(|a| self.emit_expr(a)).collect();
|
|
match method.as_str() {
|
|
"push" => {
|
|
// items.push(x) → items.value = [...items.value, x]
|
|
let val = args_js.first().map(|s| s.as_str()).unwrap_or("undefined");
|
|
format!(
|
|
"{sig}.value = [...{sig}.value, {val}]; DS._streamDiff(\"{sig}\", {sig}.value)",
|
|
sig = signal_name, val = val
|
|
)
|
|
}
|
|
"remove" => {
|
|
// items.remove(idx) → items.value = items.value.filter((_, i) => i !== idx)
|
|
let idx = args_js.first().map(|s| s.as_str()).unwrap_or("0");
|
|
format!(
|
|
"{sig}.value = {sig}.value.filter((_, _i) => _i !== {idx}); DS._streamDiff(\"{sig}\", {sig}.value)",
|
|
sig = signal_name, idx = idx
|
|
)
|
|
}
|
|
"pop" => {
|
|
// items.pop() → items.value = items.value.slice(0, -1)
|
|
format!(
|
|
"{sig}.value = {sig}.value.slice(0, -1); DS._streamDiff(\"{sig}\", {sig}.value)",
|
|
sig = signal_name
|
|
)
|
|
}
|
|
_ => {
|
|
// Generic method call: obj.method(args)
|
|
format!("{}.{}({})", obj_js, method, args_js.join(", "))
|
|
}
|
|
}
|
|
}
|
|
Expr::Block(exprs) => {
|
|
let stmts: Vec<String> = exprs.iter().map(|e| self.emit_event_handler_expr(e)).collect();
|
|
stmts.join("; ")
|
|
}
|
|
_ => {
|
|
// For plain identifiers that might be callback props, call them
|
|
if let Expr::Ident(name) = expr {
|
|
format!("if (typeof {} === 'function') {}()", name, name)
|
|
} else {
|
|
self.emit_expr(expr)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Emit a pattern matching condition check.
|
|
fn emit_pattern_check(&self, pattern: &Pattern, scrutinee: &str) -> String {
|
|
match pattern {
|
|
Pattern::Wildcard => "true".to_string(),
|
|
Pattern::Ident(name) => {
|
|
// Bind: always true, but assign
|
|
format!("(({name} = {scrutinee}), true)")
|
|
}
|
|
Pattern::Constructor(name, _fields) => {
|
|
format!("{scrutinee} === '{name}'")
|
|
}
|
|
Pattern::Literal(expr) => {
|
|
let val = self.emit_expr(expr);
|
|
format!("{scrutinee} === {val}")
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Helpers ──────────────────────────────────────────
|
|
|
|
fn next_node_id(&mut self) -> String {
|
|
let id = self.node_id_counter;
|
|
self.node_id_counter += 1;
|
|
format!("n{id}")
|
|
}
|
|
|
|
/// Extract the root signal name from an expression.
|
|
/// `Ident("todos")` → "todos", `DotAccess(Ident("a"), "b")` → "a", fallback → "_arr"
|
|
fn get_signal_root_name(&self, expr: &Expr) -> String {
|
|
match expr {
|
|
Expr::Ident(name) => name.clone(),
|
|
Expr::DotAccess(base, _) => self.get_signal_root_name(base),
|
|
Expr::Index(base, _) => self.get_signal_root_name(base),
|
|
_ => "_arr".to_string(),
|
|
}
|
|
}
|
|
|
|
fn emit_line(&mut self, line: &str) {
|
|
for _ in 0..self.indent {
|
|
self.output.push_str(" ");
|
|
}
|
|
self.output.push_str(line);
|
|
self.output.push('\n');
|
|
}
|
|
|
|
fn find_let_expr<'a>(&self, program: &'a Program, name: &str) -> Option<&'a Expr> {
|
|
for decl in &program.declarations {
|
|
if let Declaration::Let(let_decl) = decl {
|
|
if let_decl.name == name {
|
|
return Some(&let_decl.value);
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Convert a predicate expression from a `where` clause into a JavaScript boolean expression.
|
|
/// The `value` identifier is replaced with `signal_name.value` to read the signal's current value.
|
|
fn predicate_to_js(expr: &Expr, signal_name: &str) -> String {
|
|
match expr {
|
|
Expr::Ident(name) if name == "value" => format!("{}.value", signal_name),
|
|
Expr::Ident(name) => name.clone(),
|
|
Expr::IntLit(n) => format!("{}", n),
|
|
Expr::FloatLit(f) => format!("{}", f),
|
|
Expr::BoolLit(b) => format!("{}", b),
|
|
Expr::StringLit(s) => {
|
|
let text: String = s.segments.iter().map(|seg| match seg {
|
|
StringSegment::Literal(l) => l.clone(),
|
|
_ => String::new(),
|
|
}).collect();
|
|
format!("\"{}\"", text)
|
|
}
|
|
Expr::BinOp(left, op, right) => {
|
|
let l = Self::predicate_to_js(left, signal_name);
|
|
let r = Self::predicate_to_js(right, signal_name);
|
|
match op {
|
|
BinOp::Div => format!("Math.trunc({} / {})", l, r),
|
|
_ => {
|
|
let op_str = match op {
|
|
BinOp::Gt => ">",
|
|
BinOp::Gte => ">=",
|
|
BinOp::Lt => "<",
|
|
BinOp::Lte => "<=",
|
|
BinOp::Eq => "===",
|
|
BinOp::Neq => "!==",
|
|
BinOp::And => "&&",
|
|
BinOp::Or => "||",
|
|
BinOp::Add => "+",
|
|
BinOp::Sub => "-",
|
|
BinOp::Mul => "*",
|
|
BinOp::Div => unreachable!(),
|
|
BinOp::Mod => "%",
|
|
};
|
|
format!("({} {} {})", l, op_str, r)
|
|
}
|
|
}
|
|
}
|
|
Expr::UnaryOp(UnaryOp::Not, inner) => {
|
|
format!("!({})", Self::predicate_to_js(inner, signal_name))
|
|
}
|
|
Expr::UnaryOp(UnaryOp::Neg, inner) => {
|
|
format!("-({})", Self::predicate_to_js(inner, signal_name))
|
|
}
|
|
Expr::Call(name, args) => {
|
|
let js_args: Vec<String> = args.iter()
|
|
.map(|a| Self::predicate_to_js(a, signal_name))
|
|
.collect();
|
|
// Map common predicate functions to JS equivalents
|
|
match name.as_str() {
|
|
"len" => format!("{}.length", js_args.first().unwrap_or(&"null".to_string())),
|
|
"contains" if js_args.len() == 2 => format!("{}.includes({})", js_args[0], js_args[1]),
|
|
_ => format!("{}({})", name, js_args.join(", ")),
|
|
}
|
|
}
|
|
_ => format!("{}.value", signal_name), // fallback
|
|
}
|
|
}
|
|
|
|
fn is_signal_ref(&self, expr: &str) -> bool {
|
|
// Must start with a letter/underscore (not a digit) and contain only ident chars
|
|
!expr.is_empty()
|
|
&& expr.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_')
|
|
&& expr.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
|
|
}
|
|
|
|
/// Collect all element names referenced in a layout expression.
|
|
fn collect_layout_elements(expr: &LayoutExpr, out: &mut std::collections::HashSet<String>) {
|
|
match expr {
|
|
LayoutExpr::Prop(el, _) => { out.insert(el.clone()); }
|
|
LayoutExpr::Add(a, b) | LayoutExpr::Sub(a, b) | LayoutExpr::Mul(a, b) => {
|
|
Self::collect_layout_elements(a, out);
|
|
Self::collect_layout_elements(b, out);
|
|
}
|
|
LayoutExpr::Const(_) => {}
|
|
}
|
|
}
|
|
|
|
/// Convert a layout expression to a JavaScript expression string.
|
|
fn layout_expr_to_js(expr: &LayoutExpr) -> String {
|
|
match expr {
|
|
LayoutExpr::Prop(el, prop) => format!("{el}.{prop}"),
|
|
LayoutExpr::Const(v) => {
|
|
if *v == (*v as i64) as f64 {
|
|
format!("{}", *v as i64)
|
|
} else {
|
|
format!("{v}")
|
|
}
|
|
}
|
|
LayoutExpr::Add(a, b) => format!("({} + {})", Self::layout_expr_to_js(a), Self::layout_expr_to_js(b)),
|
|
LayoutExpr::Sub(a, b) => format!("({} - {})", Self::layout_expr_to_js(a), Self::layout_expr_to_js(b)),
|
|
LayoutExpr::Mul(a, b) => format!("({} * {})", Self::layout_expr_to_js(a), Self::layout_expr_to_js(b)),
|
|
}
|
|
}
|
|
|
|
/// Emit a physics scene — canvas + WASM physics engine + animation loop
|
|
fn emit_scene(&mut self, container: &Container, graph: &SignalGraph) -> String {
|
|
let wrapper_var = self.next_node_id();
|
|
let canvas_var = self.next_node_id();
|
|
|
|
// Extract scene props
|
|
let mut scene_width = "800".to_string();
|
|
let mut scene_height = "500".to_string();
|
|
let mut gravity_x_expr = None;
|
|
let mut gravity_y_expr = None;
|
|
let mut gx_is_signal = false;
|
|
let mut gy_is_signal = false;
|
|
|
|
for (key, val) in &container.props {
|
|
match key.as_str() {
|
|
"width" => scene_width = self.emit_expr(val),
|
|
"height" => scene_height = self.emit_expr(val),
|
|
"gravity_x" => {
|
|
gx_is_signal = matches!(val, Expr::Ident(_));
|
|
gravity_x_expr = Some(if let Expr::Ident(name) = val {
|
|
name.clone()
|
|
} else {
|
|
self.emit_expr(val)
|
|
});
|
|
}
|
|
"gravity_y" => {
|
|
gy_is_signal = matches!(val, Expr::Ident(_));
|
|
gravity_y_expr = Some(if let Expr::Ident(name) = val {
|
|
name.clone()
|
|
} else {
|
|
self.emit_expr(val)
|
|
});
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
// Create wrapper div
|
|
self.emit_line(&format!("const {} = document.createElement('div');", wrapper_var));
|
|
self.emit_line(&format!("{}.className = 'ds-scene-wrapper';", wrapper_var));
|
|
|
|
// Create canvas
|
|
let w_val = if graph.name_to_id.contains_key(&scene_width) || self.is_signal_ref(&scene_width) {
|
|
format!("{}.value", scene_width)
|
|
} else {
|
|
scene_width.clone()
|
|
};
|
|
let h_val = if graph.name_to_id.contains_key(&scene_height) || self.is_signal_ref(&scene_height) {
|
|
format!("{}.value", scene_height)
|
|
} else {
|
|
scene_height.clone()
|
|
};
|
|
|
|
self.emit_line(&format!("const {} = document.createElement('canvas');", canvas_var));
|
|
self.emit_line(&format!("{}.width = {};", canvas_var, w_val));
|
|
self.emit_line(&format!("{}.height = {};", canvas_var, h_val));
|
|
self.emit_line(&format!("{}.style.width = {} + 'px';", canvas_var, w_val));
|
|
self.emit_line(&format!("{}.style.height = {} + 'px';", canvas_var, h_val));
|
|
self.emit_line(&format!("{}.style.borderRadius = '16px';", canvas_var));
|
|
self.emit_line(&format!("{}.style.background = 'rgba(255,255,255,0.02)';", canvas_var));
|
|
self.emit_line(&format!("{}.style.border = '1px solid rgba(255,255,255,0.06)';", canvas_var));
|
|
self.emit_line(&format!("{}.style.cursor = 'pointer';", canvas_var));
|
|
self.emit_line(&format!("{}.appendChild({});", wrapper_var, canvas_var));
|
|
|
|
// Async IIFE to load WASM and set up physics
|
|
self.emit_line("(async () => {");
|
|
self.indent += 1;
|
|
|
|
self.emit_line("const { default: init, PhysicsWorld } = await import('./pkg/ds_physics.js');");
|
|
self.emit_line("await init();");
|
|
self.emit_line(&format!("const _sceneW = {};", w_val));
|
|
self.emit_line(&format!("const _sceneH = {};", h_val));
|
|
self.emit_line("const _world = new PhysicsWorld(_sceneW, _sceneH);");
|
|
self.emit_line(&format!("const _ctx = {}.getContext('2d');", canvas_var));
|
|
|
|
// Create bodies from child elements
|
|
for child in &container.children {
|
|
if let Expr::Element(element) = child {
|
|
match element.tag.as_str() {
|
|
"circle" => self.emit_scene_circle(element, graph),
|
|
"rect" => self.emit_scene_rect(element, graph),
|
|
_ => {} // skip unknown elements
|
|
}
|
|
}
|
|
}
|
|
|
|
// Wire reactive gravity
|
|
if let Some(ref gx) = gravity_x_expr {
|
|
if gx_is_signal {
|
|
if let Some(ref gy) = gravity_y_expr {
|
|
if gy_is_signal {
|
|
self.emit_line(&format!(
|
|
"DS.effect(() => {{ _world.set_gravity({}.value, {}.value); }});",
|
|
gx, gy
|
|
));
|
|
} else {
|
|
self.emit_line(&format!(
|
|
"DS.effect(() => {{ _world.set_gravity({}.value, {}); }});",
|
|
gx, gy
|
|
));
|
|
}
|
|
} else {
|
|
self.emit_line(&format!(
|
|
"DS.effect(() => {{ _world.set_gravity({}.value, 980); }});",
|
|
gx
|
|
));
|
|
}
|
|
}
|
|
} else if let Some(ref gy) = gravity_y_expr {
|
|
if gy_is_signal {
|
|
self.emit_line(&format!(
|
|
"DS.effect(() => {{ _world.set_gravity(0, {}.value); }});",
|
|
gy
|
|
));
|
|
} else {
|
|
self.emit_line(&format!("_world.set_gravity(0, {});", gy));
|
|
}
|
|
}
|
|
|
|
// Drag interaction
|
|
self.emit_line("let _dragBody = -1, _dragOffX = 0, _dragOffY = 0;");
|
|
self.emit_line("let _lastMX = 0, _lastMY = 0, _velX = 0, _velY = 0;");
|
|
self.emit_line(&format!(
|
|
"{}.addEventListener('mousedown', (e) => {{", canvas_var
|
|
));
|
|
self.indent += 1;
|
|
self.emit_line(&format!("const rect = {}.getBoundingClientRect();", canvas_var));
|
|
self.emit_line("const mx = e.clientX - rect.left, my = e.clientY - rect.top;");
|
|
self.emit_line("_lastMX = mx; _lastMY = my;");
|
|
self.emit_line("for (let b = 0; b < _world.body_count(); b++) {");
|
|
self.indent += 1;
|
|
self.emit_line("const c = _world.get_body_center(b);");
|
|
self.emit_line("const dx = mx - c[0], dy = my - c[1];");
|
|
self.emit_line("if (Math.sqrt(dx*dx + dy*dy) < 80) { _dragBody = b; _dragOffX = dx; _dragOffY = dy; e.preventDefault(); return; }");
|
|
self.indent -= 1;
|
|
self.emit_line("}");
|
|
self.indent -= 1;
|
|
self.emit_line("});");
|
|
|
|
self.emit_line("window.addEventListener('mousemove', (e) => {");
|
|
self.indent += 1;
|
|
self.emit_line(&format!("const rect = {}.getBoundingClientRect();", canvas_var));
|
|
self.emit_line("const mx = e.clientX - rect.left, my = e.clientY - rect.top;");
|
|
self.emit_line("_velX = mx - _lastMX; _velY = my - _lastMY; _lastMX = mx; _lastMY = my;");
|
|
self.emit_line("if (_dragBody >= 0) { const c = _world.get_body_center(_dragBody); _world.apply_impulse(_dragBody, (mx - _dragOffX - c[0]) * 5000, (my - _dragOffY - c[1]) * 5000); }");
|
|
self.indent -= 1;
|
|
self.emit_line("});");
|
|
|
|
self.emit_line("window.addEventListener('mouseup', () => { if (_dragBody >= 0) { _world.apply_impulse(_dragBody, _velX * 3000, _velY * 3000); _dragBody = -1; } });");
|
|
|
|
// Event listener for physics impulse
|
|
self.emit_line("DS.onEvent('physics_impulse', (data) => { if (data && data.body !== undefined) _world.apply_impulse(data.body, data.fx || 0, data.fy || 0); });");
|
|
|
|
// Animation loop with draw
|
|
self.emit_line("function _sceneDraw() {");
|
|
self.indent += 1;
|
|
self.emit_line("_ctx.clearRect(0, 0, _sceneW, _sceneH);");
|
|
// Grid
|
|
self.emit_line("_ctx.strokeStyle = 'rgba(255,255,255,0.02)'; _ctx.lineWidth = 1;");
|
|
self.emit_line("for (let x = 0; x < _sceneW; x += 40) { _ctx.beginPath(); _ctx.moveTo(x,0); _ctx.lineTo(x,_sceneH); _ctx.stroke(); }");
|
|
self.emit_line("for (let y = 0; y < _sceneH; y += 40) { _ctx.beginPath(); _ctx.moveTo(0,y); _ctx.lineTo(_sceneW,y); _ctx.stroke(); }");
|
|
// Bodies
|
|
self.emit_line("for (let b = 0; b < _world.body_count(); b++) {");
|
|
self.indent += 1;
|
|
self.emit_line("const pos = _world.get_body_positions(b), col = _world.get_body_color(b), bt = _world.get_body_type(b);");
|
|
self.emit_line("const r = col[0]*255|0, g = col[1]*255|0, bl = col[2]*255|0, a = col[3];");
|
|
self.emit_line("const fill = `rgba(${r},${g},${bl},${a*0.5})`, stroke = `rgba(${r},${g},${bl},${a*0.9})`, glow = `rgba(${r},${g},${bl},0.4)`;");
|
|
|
|
// Rect
|
|
self.emit_line("if (bt === 1 && pos.length >= 8) {");
|
|
self.indent += 1;
|
|
self.emit_line("_ctx.beginPath(); _ctx.moveTo(pos[0],pos[1]); _ctx.lineTo(pos[2],pos[3]); _ctx.lineTo(pos[4],pos[5]); _ctx.lineTo(pos[6],pos[7]); _ctx.closePath();");
|
|
self.emit_line("_ctx.shadowColor = glow; _ctx.shadowBlur = 18; _ctx.fillStyle = fill; _ctx.fill(); _ctx.shadowBlur = 0; _ctx.strokeStyle = stroke; _ctx.lineWidth = 2; _ctx.stroke();");
|
|
self.indent -= 1;
|
|
self.emit_line("} else {");
|
|
self.indent += 1;
|
|
// Circle
|
|
self.emit_line("const pc = _world.get_body_perimeter_count(b);");
|
|
self.emit_line("if (pc >= 3) { _ctx.beginPath(); _ctx.moveTo(pos[0],pos[1]); for (let i=1;i<pc;i++) _ctx.lineTo(pos[i*2],pos[i*2+1]); _ctx.closePath();");
|
|
self.emit_line("_ctx.shadowColor = glow; _ctx.shadowBlur = 20; _ctx.fillStyle = fill; _ctx.fill(); _ctx.shadowBlur = 0; _ctx.strokeStyle = stroke; _ctx.lineWidth = 1.5; _ctx.stroke();");
|
|
self.emit_line("if (pos.length >= (pc+1)*2) { _ctx.beginPath(); _ctx.arc(pos[pc*2],pos[pc*2+1],2.5,0,Math.PI*2); _ctx.fillStyle = stroke; _ctx.fill(); } }");
|
|
self.indent -= 1;
|
|
self.emit_line("}");
|
|
|
|
self.indent -= 1;
|
|
self.emit_line("}");
|
|
// Boundary
|
|
self.emit_line("_ctx.strokeStyle = 'rgba(99,102,241,0.06)'; _ctx.lineWidth = 2; _ctx.strokeRect(1,1,_sceneW-2,_sceneH-2);");
|
|
self.indent -= 1;
|
|
self.emit_line("}");
|
|
|
|
// Loop
|
|
self.emit_line(&format!(
|
|
"function _sceneLoop() {{ _world.step(1/60); _sceneDraw(); if (DS._streamWs) DS._streamSceneState(_world, {}, {}); requestAnimationFrame(_sceneLoop); }}",
|
|
scene_width, scene_height
|
|
));
|
|
self.emit_line("requestAnimationFrame(_sceneLoop);");
|
|
|
|
self.indent -= 1;
|
|
self.emit_line("})();");
|
|
|
|
wrapper_var
|
|
}
|
|
|
|
/// Emit a circle body creation inside a scene
|
|
fn emit_scene_circle(&mut self, element: &Element, _graph: &SignalGraph) {
|
|
let mut x = "200".to_string();
|
|
let mut y = "100".to_string();
|
|
let mut radius = "30".to_string();
|
|
let mut color = None;
|
|
let mut segments = "16".to_string();
|
|
let mut pinned = false;
|
|
|
|
for (key, val) in &element.props {
|
|
let js = self.emit_expr(val);
|
|
match key.as_str() {
|
|
"x" => x = js,
|
|
"y" => y = js,
|
|
"radius" => radius = js,
|
|
"color" => color = Some(js),
|
|
"segments" => segments = js,
|
|
"pinned" => pinned = js == "true",
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
let body_var = self.next_node_id();
|
|
self.emit_line(&format!(
|
|
"const {} = _world.create_soft_circle({}, {}, {}, {}, 50);",
|
|
body_var, x, y, radius, segments
|
|
));
|
|
|
|
if let Some(c) = color {
|
|
self.emit_scene_set_color(&body_var, c);
|
|
}
|
|
|
|
if pinned {
|
|
self.emit_line(&format!("_world.pin_particle({}, true);", body_var));
|
|
}
|
|
}
|
|
|
|
/// Emit a rect body creation inside a scene
|
|
fn emit_scene_rect(&mut self, element: &Element, _graph: &SignalGraph) {
|
|
let mut x = "200".to_string();
|
|
let mut y = "100".to_string();
|
|
let mut width = "60".to_string();
|
|
let mut height = "40".to_string();
|
|
let mut color = None;
|
|
let mut pinned = false;
|
|
|
|
for (key, val) in &element.props {
|
|
let js = self.emit_expr(val);
|
|
match key.as_str() {
|
|
"x" => x = js,
|
|
"y" => y = js,
|
|
"width" => width = js,
|
|
"height" => height = js,
|
|
"color" => color = Some(js),
|
|
"pinned" => pinned = js == "true",
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
let body_var = self.next_node_id();
|
|
self.emit_line(&format!(
|
|
"const {} = _world.create_soft_rect({}, {}, {}, {}, 4, 3, 30);",
|
|
body_var, x, y, width, height
|
|
));
|
|
|
|
if let Some(c) = color {
|
|
self.emit_scene_set_color(&body_var, c);
|
|
}
|
|
|
|
if pinned {
|
|
self.emit_line(&format!("_world.pin_particle({}, true);", body_var));
|
|
}
|
|
}
|
|
|
|
/// Emit set_body_color from a hex color string
|
|
fn emit_scene_set_color(&mut self, body_var: &str, color_str: String) {
|
|
// Parse hex color at compile time or emit runtime parser
|
|
let clean = color_str.replace('"', "").replace('\\', "");
|
|
if clean.starts_with('#') && clean.len() == 7 {
|
|
let r = u8::from_str_radix(&clean[1..3], 16).unwrap_or(128);
|
|
let g = u8::from_str_radix(&clean[3..5], 16).unwrap_or(128);
|
|
let b = u8::from_str_radix(&clean[5..7], 16).unwrap_or(128);
|
|
self.emit_line(&format!(
|
|
"_world.set_body_color({}, {:.3}, {:.3}, {:.3}, 1.0);",
|
|
body_var,
|
|
r as f64 / 255.0,
|
|
g as f64 / 255.0,
|
|
b as f64 / 255.0
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Default for JsEmitter {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
/// Minimal CSS reset and layout classes.
|
|
const CSS_RESET: &str = r#"
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body {
|
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
|
background: #0a0a0f;
|
|
color: #e2e8f0;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
min-height: 100vh;
|
|
}
|
|
#ds-root {
|
|
width: 100%;
|
|
max-width: 800px;
|
|
padding: 2rem;
|
|
}
|
|
.ds-column {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
}
|
|
.ds-row {
|
|
display: flex;
|
|
flex-direction: row;
|
|
gap: 1rem;
|
|
align-items: center;
|
|
}
|
|
.ds-stack {
|
|
position: relative;
|
|
}
|
|
.ds-panel {
|
|
position: absolute;
|
|
}
|
|
.ds-text, .ds-text span {
|
|
font-size: 1.25rem;
|
|
color: #e2e8f0;
|
|
}
|
|
.ds-button, button.ds-button {
|
|
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
|
color: white;
|
|
border: none;
|
|
padding: 0.75rem 1.5rem;
|
|
border-radius: 12px;
|
|
font-size: 1rem;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
box-shadow: 0 4px 15px rgba(99, 102, 241, 0.3);
|
|
}
|
|
.ds-button:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 6px 20px rgba(99, 102, 241, 0.4);
|
|
}
|
|
.ds-button:active {
|
|
transform: translateY(0);
|
|
}
|
|
/* ── Button Variants ── */
|
|
button.ds-btn-primary, .ds-button.ds-btn-primary {
|
|
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
|
box-shadow: 0 4px 15px rgba(99, 102, 241, 0.3);
|
|
}
|
|
button.ds-btn-primary:hover { box-shadow: 0 6px 20px rgba(99, 102, 241, 0.4); }
|
|
button.ds-btn-secondary, .ds-button.ds-btn-secondary {
|
|
background: rgba(255, 255, 255, 0.08);
|
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
|
color: #e2e8f0;
|
|
box-shadow: none;
|
|
}
|
|
button.ds-btn-secondary:hover { background: rgba(255, 255, 255, 0.12); }
|
|
button.ds-btn-ghost, .ds-button.ds-btn-ghost {
|
|
background: transparent;
|
|
box-shadow: none;
|
|
color: #a5b4fc;
|
|
}
|
|
button.ds-btn-ghost:hover { background: rgba(99, 102, 241, 0.1); }
|
|
button.ds-btn-destructive, .ds-button.ds-btn-destructive {
|
|
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
|
box-shadow: 0 4px 15px rgba(239, 68, 68, 0.3);
|
|
}
|
|
button.ds-btn-destructive:hover { box-shadow: 0 6px 20px rgba(239, 68, 68, 0.4); }
|
|
/* ── Card ── */
|
|
.ds-card {
|
|
background: rgba(255, 255, 255, 0.04);
|
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
border-radius: 16px;
|
|
padding: 1.5rem;
|
|
backdrop-filter: blur(12px);
|
|
transition: border-color 0.2s, box-shadow 0.2s;
|
|
}
|
|
.ds-card:hover {
|
|
border-color: rgba(99, 102, 241, 0.3);
|
|
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3);
|
|
}
|
|
.ds-card-title {
|
|
font-size: 1.25rem;
|
|
font-weight: 700;
|
|
color: #f1f5f9;
|
|
margin-bottom: 0.25rem;
|
|
}
|
|
.ds-card-subtitle {
|
|
font-size: 0.875rem;
|
|
color: #94a3b8;
|
|
}
|
|
/* ── Badge ── */
|
|
.ds-badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
padding: 0.25rem 0.75rem;
|
|
border-radius: 9999px;
|
|
font-size: 0.75rem;
|
|
font-weight: 600;
|
|
letter-spacing: 0.025em;
|
|
text-transform: uppercase;
|
|
}
|
|
.ds-badge-success { background: rgba(34, 197, 94, 0.15); color: #4ade80; border: 1px solid rgba(34, 197, 94, 0.2); }
|
|
.ds-badge-warning { background: rgba(234, 179, 8, 0.15); color: #facc15; border: 1px solid rgba(234, 179, 8, 0.2); }
|
|
.ds-badge-error { background: rgba(239, 68, 68, 0.15); color: #f87171; border: 1px solid rgba(239, 68, 68, 0.2); }
|
|
.ds-badge-info { background: rgba(56, 189, 248, 0.15); color: #38bdf8; border: 1px solid rgba(56, 189, 248, 0.2); }
|
|
.ds-badge-default { background: rgba(148, 163, 184, 0.15); color: #94a3b8; border: 1px solid rgba(148, 163, 184, 0.2); }
|
|
/* ── Dialog ── */
|
|
.ds-dialog-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(0, 0, 0, 0.6);
|
|
backdrop-filter: blur(4px);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 100;
|
|
animation: ds-fade-in 0.2s ease-out;
|
|
}
|
|
.ds-dialog-content {
|
|
background: #1a1a2e;
|
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
border-radius: 16px;
|
|
padding: 2rem;
|
|
min-width: 400px;
|
|
max-width: 90vw;
|
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
|
animation: ds-fade-in 0.3s ease-out;
|
|
}
|
|
.ds-dialog-title {
|
|
font-size: 1.375rem;
|
|
font-weight: 700;
|
|
color: #f1f5f9;
|
|
margin-bottom: 1rem;
|
|
}
|
|
/* ── Toast ── */
|
|
.ds-toast {
|
|
position: fixed;
|
|
bottom: 1.5rem;
|
|
right: 1.5rem;
|
|
background: #1e1e3a;
|
|
border: 1px solid rgba(99, 102, 241, 0.3);
|
|
border-radius: 12px;
|
|
padding: 1rem 1.5rem;
|
|
color: #e2e8f0;
|
|
font-size: 0.875rem;
|
|
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.4);
|
|
animation: ds-slide-in 0.3s ease-out;
|
|
z-index: 200;
|
|
}
|
|
@keyframes ds-slide-in {
|
|
from { opacity: 0; transform: translateY(20px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
/* ── Input Label ── */
|
|
.ds-input, input.ds-input {
|
|
background: rgba(255, 255, 255, 0.05);
|
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
color: #e2e8f0;
|
|
padding: 0.75rem 1rem;
|
|
border-radius: 12px;
|
|
font-size: 1rem;
|
|
outline: none;
|
|
transition: border-color 0.2s;
|
|
width: 100%;
|
|
box-sizing: border-box;
|
|
}
|
|
.ds-input-label {
|
|
font-size: 0.875rem;
|
|
font-weight: 500;
|
|
color: #94a3b8;
|
|
margin-bottom: 0.375rem;
|
|
}
|
|
.ds-input-error {
|
|
font-size: 0.75rem;
|
|
color: #f87171;
|
|
margin-top: 0.25rem;
|
|
}
|
|
.ds-input.ds-input-has-error {
|
|
border-color: rgba(239, 68, 68, 0.5);
|
|
}
|
|
.ds-input:focus {
|
|
border-color: #6366f1;
|
|
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15);
|
|
}
|
|
.ds-spinner {
|
|
width: 2rem;
|
|
height: 2rem;
|
|
border: 3px solid rgba(255,255,255,0.1);
|
|
border-top-color: #6366f1;
|
|
border-radius: 50%;
|
|
animation: ds-spin 0.8s linear infinite;
|
|
}
|
|
@keyframes ds-spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
@keyframes ds-fade-in {
|
|
from { opacity: 0; transform: translateY(8px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
.ds-fade-in {
|
|
animation: ds-fade-in 0.3s ease-out;
|
|
}
|
|
/* ── Progress ── */
|
|
.ds-progress-track {
|
|
width: 100%;
|
|
height: 8px;
|
|
background: rgba(255, 255, 255, 0.08);
|
|
border-radius: 9999px;
|
|
overflow: hidden;
|
|
}
|
|
.ds-progress-fill {
|
|
height: 100%;
|
|
border-radius: 9999px;
|
|
background: linear-gradient(90deg, #6366f1 0%, #8b5cf6 100%);
|
|
transition: width 0.4s ease;
|
|
}
|
|
/* ── Avatar ── */
|
|
.ds-avatar {
|
|
width: 40px;
|
|
height: 40px;
|
|
border-radius: 50%;
|
|
background: linear-gradient(135deg, #6366f1 0%, #a855f7 100%);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: white;
|
|
font-weight: 700;
|
|
font-size: 1rem;
|
|
flex-shrink: 0;
|
|
}
|
|
.ds-avatar-lg { width: 56px; height: 56px; font-size: 1.25rem; }
|
|
.ds-avatar-sm { width: 28px; height: 28px; font-size: 0.75rem; }
|
|
/* ── Separator ── */
|
|
.ds-separator {
|
|
width: 100%;
|
|
height: 1px;
|
|
background: rgba(255, 255, 255, 0.08);
|
|
border: none;
|
|
margin: 0.5rem 0;
|
|
}
|
|
/* ── Alert ── */
|
|
.ds-alert {
|
|
padding: 1rem 1.25rem;
|
|
border-radius: 12px;
|
|
font-size: 0.875rem;
|
|
line-height: 1.5;
|
|
}
|
|
.ds-alert-info {
|
|
background: rgba(56, 189, 248, 0.08);
|
|
border: 1px solid rgba(56, 189, 248, 0.2);
|
|
color: #7dd3fc;
|
|
}
|
|
.ds-alert-warning {
|
|
background: rgba(234, 179, 8, 0.08);
|
|
border: 1px solid rgba(234, 179, 8, 0.2);
|
|
color: #fde047;
|
|
}
|
|
.ds-alert-error {
|
|
background: rgba(239, 68, 68, 0.08);
|
|
border: 1px solid rgba(239, 68, 68, 0.2);
|
|
color: #fca5a5;
|
|
}
|
|
.ds-alert-success {
|
|
background: rgba(34, 197, 94, 0.08);
|
|
border: 1px solid rgba(34, 197, 94, 0.2);
|
|
color: #86efac;
|
|
}
|
|
/* ── Toggle ── */
|
|
.ds-toggle {
|
|
width: 44px;
|
|
height: 24px;
|
|
border-radius: 12px;
|
|
background: rgba(255, 255, 255, 0.1);
|
|
position: relative;
|
|
cursor: pointer;
|
|
transition: background 0.2s;
|
|
border: none;
|
|
padding: 0;
|
|
}
|
|
.ds-toggle-on {
|
|
background: #6366f1;
|
|
}
|
|
.ds-toggle::after {
|
|
content: '';
|
|
position: absolute;
|
|
width: 18px;
|
|
height: 18px;
|
|
border-radius: 50%;
|
|
background: white;
|
|
top: 3px;
|
|
left: 3px;
|
|
transition: transform 0.2s;
|
|
}
|
|
.ds-toggle-on::after {
|
|
transform: translateX(20px);
|
|
}
|
|
/* ── Stat ── */
|
|
.ds-stat-value {
|
|
font-size: 2rem;
|
|
font-weight: 800;
|
|
color: #f1f5f9;
|
|
line-height: 1;
|
|
}
|
|
.ds-stat-delta-up {
|
|
font-size: 0.75rem;
|
|
font-weight: 600;
|
|
color: #4ade80;
|
|
}
|
|
.ds-stat-delta-down {
|
|
font-size: 0.75rem;
|
|
font-weight: 600;
|
|
color: #f87171;
|
|
}
|
|
"#;
|
|
|
|
/// Map a DreamStack variant prop to CSS class(es) based on element tag.
|
|
fn variant_to_css(tag: &str, variant: &str) -> String {
|
|
match tag {
|
|
"button" => match variant {
|
|
"primary" => "ds-btn-primary".to_string(),
|
|
"secondary" => "ds-btn-secondary".to_string(),
|
|
"ghost" => "ds-btn-ghost".to_string(),
|
|
"destructive" => "ds-btn-destructive".to_string(),
|
|
_ => format!("ds-btn-{}", variant),
|
|
},
|
|
"text" => match variant {
|
|
"success" | "warning" | "error" | "info" | "default" =>
|
|
format!("ds-badge ds-badge-{}", variant),
|
|
"title" => "ds-card-title".to_string(),
|
|
"subtitle" | "muted" => "ds-card-subtitle".to_string(),
|
|
"label" => "ds-input-label".to_string(),
|
|
_ => format!("ds-text-{}", variant),
|
|
},
|
|
"column" => match variant {
|
|
"card" => "ds-card".to_string(),
|
|
"dialog" => "ds-dialog-content".to_string(),
|
|
"overlay" => "ds-dialog-overlay".to_string(),
|
|
_ => format!("ds-column-{}", variant),
|
|
},
|
|
"row" => match variant {
|
|
"card" => "ds-card".to_string(),
|
|
_ => format!("ds-row-{}", variant),
|
|
},
|
|
"input" => match variant {
|
|
"error" => "ds-input-has-error".to_string(),
|
|
_ => format!("ds-input-{}", variant),
|
|
},
|
|
_ => format!("ds-{}-{}", tag, variant),
|
|
}
|
|
}
|
|
|
|
/// Generate a JS object literal mapping variant names to CSS classes for dynamic variant.
|
|
fn variant_map_js(tag: &str) -> String {
|
|
match tag {
|
|
"button" => "{'primary':'ds-btn-primary','secondary':'ds-btn-secondary','ghost':'ds-btn-ghost','destructive':'ds-btn-destructive'}".to_string(),
|
|
"text" => "{'success':'ds-badge ds-badge-success','warning':'ds-badge ds-badge-warning','error':'ds-badge ds-badge-error','info':'ds-badge ds-badge-info','default':'ds-badge ds-badge-default','title':'ds-card-title','subtitle':'ds-card-subtitle','label':'ds-input-label'}".to_string(),
|
|
"column" => "{'card':'ds-card','dialog':'ds-dialog-content','overlay':'ds-dialog-overlay'}".to_string(),
|
|
"input" => "{'error':'ds-input-has-error'}".to_string(),
|
|
_ => "{}".to_string(),
|
|
}
|
|
}
|
|
|
|
/// The DreamStack client-side reactive runtime (~3KB).
|
|
const RUNTIME_JS: &str = r#"
|
|
const DS = (() => {
|
|
// ── Signal System ──
|
|
let currentEffect = null;
|
|
let batchDepth = 0;
|
|
let pendingEffects = new Set();
|
|
|
|
class Signal {
|
|
constructor(initialValue) {
|
|
this._value = initialValue;
|
|
this._subscribers = new Set();
|
|
}
|
|
get value() {
|
|
if (currentEffect) {
|
|
this._subscribers.add(currentEffect);
|
|
}
|
|
return this._value;
|
|
}
|
|
set value(newValue) {
|
|
if (this._value === newValue) return;
|
|
this._value = newValue;
|
|
if (batchDepth > 0) {
|
|
for (const sub of this._subscribers) {
|
|
pendingEffects.add(sub);
|
|
}
|
|
} else {
|
|
for (const sub of [...this._subscribers]) {
|
|
sub._run();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
class Derived {
|
|
constructor(fn) {
|
|
this._fn = fn;
|
|
this._value = undefined;
|
|
this._dirty = true;
|
|
this._subscribers = new Set();
|
|
this._effect = new Effect(() => {
|
|
this._value = this._fn();
|
|
this._dirty = false;
|
|
// Notify our subscribers
|
|
if (batchDepth > 0) {
|
|
for (const sub of this._subscribers) {
|
|
pendingEffects.add(sub);
|
|
}
|
|
} else {
|
|
for (const sub of [...this._subscribers]) {
|
|
sub._run();
|
|
}
|
|
}
|
|
});
|
|
this._effect._run();
|
|
}
|
|
get value() {
|
|
if (currentEffect) {
|
|
this._subscribers.add(currentEffect);
|
|
}
|
|
return this._value;
|
|
}
|
|
}
|
|
|
|
class Effect {
|
|
constructor(fn) {
|
|
this._fn = fn;
|
|
this._disposed = false;
|
|
}
|
|
_run() {
|
|
if (this._disposed) return;
|
|
const prev = currentEffect;
|
|
currentEffect = this;
|
|
try {
|
|
this._fn();
|
|
} finally {
|
|
currentEffect = prev;
|
|
}
|
|
}
|
|
dispose() {
|
|
this._disposed = true;
|
|
}
|
|
}
|
|
|
|
// ── Public API ──
|
|
function signal(value) {
|
|
return new Signal(value);
|
|
}
|
|
|
|
function derived(fn) {
|
|
return new Derived(fn);
|
|
}
|
|
|
|
function effect(fn) {
|
|
const eff = new Effect(fn);
|
|
eff._run();
|
|
return eff;
|
|
}
|
|
|
|
function batch(fn) {
|
|
batchDepth++;
|
|
try {
|
|
fn();
|
|
} finally {
|
|
batchDepth--;
|
|
if (batchDepth === 0) {
|
|
flush();
|
|
}
|
|
}
|
|
}
|
|
|
|
function flush() {
|
|
const effects = [...pendingEffects];
|
|
pendingEffects.clear();
|
|
for (const eff of effects) {
|
|
eff._run();
|
|
}
|
|
// After effects recompute derived signals, flush batched diffs to stream
|
|
// (individual _streamDiff calls already batched changes — no full sync needed)
|
|
}
|
|
|
|
// ── Event System ──
|
|
const eventHandlers = {};
|
|
|
|
function onEvent(name, handler) {
|
|
if (!eventHandlers[name]) eventHandlers[name] = [];
|
|
eventHandlers[name].push(handler);
|
|
}
|
|
|
|
function emit(name, data) {
|
|
const handlers = eventHandlers[name];
|
|
if (handlers) {
|
|
batch(() => {
|
|
for (const h of handlers) {
|
|
h(data);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// ── Keyed List Reconciliation ──
|
|
function keyedList(container, getItems, keyFn, renderFn) {
|
|
let nodeMap = new Map();
|
|
const eff = effect(() => {
|
|
const raw = getItems();
|
|
const items = (raw && raw.value !== undefined) ? raw.value : (Array.isArray(raw) ? raw : []);
|
|
const newMap = new Map();
|
|
const frag = document.createDocumentFragment();
|
|
items.forEach((item, idx) => {
|
|
const key = keyFn ? keyFn(item, idx) : idx;
|
|
let node = nodeMap.get(key);
|
|
if (!node) {
|
|
node = renderFn(item, idx);
|
|
}
|
|
newMap.set(key, node);
|
|
frag.appendChild(node);
|
|
});
|
|
container.innerHTML = '';
|
|
container.appendChild(frag);
|
|
nodeMap = newMap;
|
|
});
|
|
return eff;
|
|
}
|
|
|
|
// ── Router ──
|
|
const _route = new Signal(window.location.hash.slice(1) || '/');
|
|
window.addEventListener('hashchange', () => {
|
|
_route.value = window.location.hash.slice(1) || '/';
|
|
});
|
|
|
|
function navigate(path) {
|
|
window.location.hash = path;
|
|
}
|
|
|
|
function matchRoute(pattern, path) {
|
|
if (pattern === path) return {};
|
|
const patParts = pattern.split('/');
|
|
const pathParts = path.split('/');
|
|
if (patParts.length !== pathParts.length) return null;
|
|
const params = {};
|
|
for (let i = 0; i < patParts.length; i++) {
|
|
if (patParts[i].startsWith(':')) {
|
|
params[patParts[i].slice(1)] = pathParts[i];
|
|
} else if (patParts[i] !== pathParts[i]) {
|
|
return null;
|
|
}
|
|
}
|
|
return params;
|
|
}
|
|
|
|
// ── Async Resources ──
|
|
function resource(fetcher) {
|
|
const state = new Signal({ status: 'loading', data: null, error: null });
|
|
const eff = effect(() => {
|
|
state.value = { status: 'loading', data: state._value.data, error: null };
|
|
Promise.resolve(fetcher())
|
|
.then(data => { state.value = { status: 'ok', data, error: null }; })
|
|
.catch(err => { state.value = { status: 'error', data: null, error: err.message || String(err) }; });
|
|
});
|
|
return state;
|
|
}
|
|
|
|
function fetchJSON(url) {
|
|
return resource(() => fetch(url).then(r => r.json()));
|
|
}
|
|
|
|
// ── Spring Physics Engine ──
|
|
const _activeSprings = new Set();
|
|
let _rafId = null;
|
|
let _lastTime = 0;
|
|
|
|
class Spring {
|
|
constructor({ value = 0, target, stiffness = 170, damping = 26, mass = 1 } = {}) {
|
|
this._signal = new Signal(value);
|
|
this._velocity = 0;
|
|
this._target = target !== undefined ? target : value;
|
|
this.stiffness = stiffness;
|
|
this.damping = damping;
|
|
this.mass = mass;
|
|
this._settled = true;
|
|
}
|
|
get value() { return this._signal.value; }
|
|
set value(v) {
|
|
// Assignment animates to new target (not instant set)
|
|
this.target = v;
|
|
}
|
|
get target() { return this._target; }
|
|
set target(t) {
|
|
this._target = t;
|
|
this._settled = false;
|
|
_activeSprings.add(this);
|
|
_startSpringLoop();
|
|
}
|
|
set(v) {
|
|
this._signal.value = v;
|
|
this._target = v;
|
|
this._velocity = 0;
|
|
this._settled = true;
|
|
_activeSprings.delete(this);
|
|
}
|
|
_step(dt) {
|
|
const pos = this._signal._value;
|
|
const vel = this._velocity;
|
|
const k = this.stiffness, d = this.damping, m = this.mass;
|
|
const accel = (p, v) => (-k * (p - this._target) - d * v) / m;
|
|
const k1v = accel(pos, vel), k1p = vel;
|
|
const k2v = accel(pos + k1p*dt/2, vel + k1v*dt/2), k2p = vel + k1v*dt/2;
|
|
const k3v = accel(pos + k2p*dt/2, vel + k2v*dt/2), k3p = vel + k2v*dt/2;
|
|
const k4v = accel(pos + k3p*dt, vel + k3v*dt), k4p = vel + k3v*dt;
|
|
this._velocity = vel + (dt/6)*(k1v + 2*k2v + 2*k3v + k4v);
|
|
this._signal.value = pos + (dt/6)*(k1p + 2*k2p + 2*k3p + k4p);
|
|
if (Math.abs(this._velocity) < 0.01 && Math.abs(this._signal._value - this._target) < 0.01) {
|
|
this._signal.value = this._target;
|
|
this._velocity = 0;
|
|
this._settled = true;
|
|
_activeSprings.delete(this);
|
|
}
|
|
}
|
|
}
|
|
|
|
function _startSpringLoop() {
|
|
if (_rafId !== null) return;
|
|
_lastTime = performance.now();
|
|
_rafId = requestAnimationFrame(_springLoop);
|
|
}
|
|
|
|
function _springLoop(now) {
|
|
const dt = Math.min((now - _lastTime) / 1000, 0.064);
|
|
_lastTime = now;
|
|
batch(() => {
|
|
for (const s of _activeSprings) {
|
|
const steps = Math.ceil(dt / (1/120));
|
|
const subDt = dt / steps;
|
|
for (let i = 0; i < steps; i++) s._step(subDt);
|
|
}
|
|
});
|
|
if (_activeSprings.size > 0) _rafId = requestAnimationFrame(_springLoop);
|
|
else _rafId = null;
|
|
}
|
|
|
|
function spring(opts) { return new Spring(typeof opts === 'object' ? opts : { value: opts, target: opts }); }
|
|
|
|
// ── Constraint Solver ──
|
|
function constrain(element, prop, fn) {
|
|
return effect(() => {
|
|
const val = fn();
|
|
if (prop === 'width' || prop === 'height' || prop === 'left' || prop === 'top' ||
|
|
prop === 'right' || prop === 'bottom' || prop === 'maxWidth' || prop === 'minWidth' ||
|
|
prop === 'maxHeight' || prop === 'minHeight' || prop === 'fontSize' ||
|
|
prop === 'padding' || prop === 'margin' || prop === 'gap') {
|
|
element.style[prop] = typeof val === 'number' ? val + 'px' : val;
|
|
} else if (prop === 'opacity' || prop === 'scale') {
|
|
element.style[prop] = val;
|
|
} else {
|
|
element.style[prop] = val;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Viewport signal for responsive constraints
|
|
const _viewport = {
|
|
width: new Signal(window.innerWidth),
|
|
height: new Signal(window.innerHeight)
|
|
};
|
|
window.addEventListener('resize', () => {
|
|
_viewport.width.value = window.innerWidth;
|
|
_viewport.height.value = window.innerHeight;
|
|
});
|
|
|
|
// ── 2D Scene Rendering Engine ──
|
|
function scene(width, height) {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = width || 600;
|
|
canvas.height = height || 400;
|
|
canvas.style.borderRadius = '16px';
|
|
canvas.style.background = 'rgba(255,255,255,0.03)';
|
|
canvas.style.border = '1px solid rgba(255,255,255,0.08)';
|
|
canvas.style.display = 'block';
|
|
canvas.style.margin = '8px 0';
|
|
const ctx = canvas.getContext('2d');
|
|
const shapes = [];
|
|
let _dirty = false;
|
|
|
|
function _render() {
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
for (const s of shapes) s._draw(ctx);
|
|
}
|
|
|
|
function _scheduleRender() {
|
|
if (!_dirty) {
|
|
_dirty = true;
|
|
queueMicrotask(() => { _dirty = false; _render(); });
|
|
}
|
|
}
|
|
|
|
return { canvas, ctx, shapes, _render, _scheduleRender };
|
|
}
|
|
|
|
function circle(scn, opts) {
|
|
const shape = {
|
|
type: 'circle',
|
|
_draw(ctx) {
|
|
const x = typeof opts.x === 'object' && 'value' in opts.x ? opts.x.value : (typeof opts.x === 'function' ? opts.x() : opts.x);
|
|
const y = typeof opts.y === 'object' && 'value' in opts.y ? opts.y.value : (typeof opts.y === 'function' ? opts.y() : opts.y);
|
|
const r = typeof opts.r === 'object' && 'value' in opts.r ? opts.r.value : (typeof opts.r === 'function' ? opts.r() : (opts.r || 20));
|
|
const fill = opts.fill || '#8b5cf6';
|
|
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, r, 0, Math.PI * 2);
|
|
|
|
// Gradient fill for nice look
|
|
const grad = ctx.createRadialGradient(x - r*0.3, y - r*0.3, r*0.1, x, y, r);
|
|
grad.addColorStop(0, '#a78bfa');
|
|
grad.addColorStop(1, fill);
|
|
ctx.fillStyle = grad;
|
|
ctx.fill();
|
|
|
|
// Glow
|
|
ctx.shadowColor = fill;
|
|
ctx.shadowBlur = 20;
|
|
ctx.fill();
|
|
ctx.shadowBlur = 0;
|
|
}
|
|
};
|
|
scn.shapes.push(shape);
|
|
// Reactive: re-render when signal deps change
|
|
effect(() => {
|
|
shape._draw; // trigger reads
|
|
if (opts.x && typeof opts.x === 'object' && 'value' in opts.x) opts.x.value;
|
|
if (opts.y && typeof opts.y === 'object' && 'value' in opts.y) opts.y.value;
|
|
if (opts.r && typeof opts.r === 'object' && 'value' in opts.r) opts.r.value;
|
|
scn._scheduleRender();
|
|
});
|
|
return shape;
|
|
}
|
|
|
|
function rect(scn, opts) {
|
|
const shape = {
|
|
type: 'rect',
|
|
_draw(ctx) {
|
|
const x = typeof opts.x === 'object' && 'value' in opts.x ? opts.x.value : (typeof opts.x === 'function' ? opts.x() : opts.x);
|
|
const y = typeof opts.y === 'object' && 'value' in opts.y ? opts.y.value : (typeof opts.y === 'function' ? opts.y() : opts.y);
|
|
const w = typeof opts.w === 'object' && 'value' in opts.w ? opts.w.value : (typeof opts.w === 'function' ? opts.w() : (opts.w || 40));
|
|
const h = typeof opts.h === 'object' && 'value' in opts.h ? opts.h.value : (typeof opts.h === 'function' ? opts.h() : (opts.h || 40));
|
|
const fill = opts.fill || '#6366f1';
|
|
const r = opts.radius || 8;
|
|
|
|
ctx.beginPath();
|
|
ctx.roundRect(x, y, w, h, r);
|
|
ctx.fillStyle = fill;
|
|
ctx.fill();
|
|
}
|
|
};
|
|
scn.shapes.push(shape);
|
|
effect(() => {
|
|
if (opts.x && typeof opts.x === 'object' && 'value' in opts.x) opts.x.value;
|
|
if (opts.y && typeof opts.y === 'object' && 'value' in opts.y) opts.y.value;
|
|
if (opts.w && typeof opts.w === 'object' && 'value' in opts.w) opts.w.value;
|
|
if (opts.h && typeof opts.h === 'object' && 'value' in opts.h) opts.h.value;
|
|
scn._scheduleRender();
|
|
});
|
|
return shape;
|
|
}
|
|
|
|
function line(scn, opts) {
|
|
const shape = {
|
|
type: 'line',
|
|
_draw(ctx) {
|
|
const x1 = typeof opts.x1 === 'object' && 'value' in opts.x1 ? opts.x1.value : opts.x1;
|
|
const y1 = typeof opts.y1 === 'object' && 'value' in opts.y1 ? opts.y1.value : opts.y1;
|
|
const x2 = typeof opts.x2 === 'object' && 'value' in opts.x2 ? opts.x2.value : opts.x2;
|
|
const y2 = typeof opts.y2 === 'object' && 'value' in opts.y2 ? opts.y2.value : opts.y2;
|
|
ctx.beginPath();
|
|
ctx.moveTo(x1, y1);
|
|
ctx.lineTo(x2, y2);
|
|
ctx.strokeStyle = opts.stroke || 'rgba(139,92,246,0.3)';
|
|
ctx.lineWidth = opts.width || 2;
|
|
ctx.stroke();
|
|
}
|
|
};
|
|
scn.shapes.push(shape);
|
|
effect(() => {
|
|
if (opts.x1 && typeof opts.x1 === 'object' && 'value' in opts.x1) opts.x1.value;
|
|
if (opts.y1 && typeof opts.y1 === 'object' && 'value' in opts.y1) opts.y1.value;
|
|
if (opts.x2 && typeof opts.x2 === 'object' && 'value' in opts.x2) opts.x2.value;
|
|
if (opts.y2 && typeof opts.y2 === 'object' && 'value' in opts.y2) opts.y2.value;
|
|
scn._scheduleRender();
|
|
});
|
|
return shape;
|
|
}
|
|
|
|
// ── Bitstream Streaming ──
|
|
const HEADER_SIZE = 16;
|
|
let _streamWs = null;
|
|
let _streamSeq = 0;
|
|
let _streamMode = 'signal';
|
|
let _streamStart = 0;
|
|
let _prevSignals = null;
|
|
|
|
// ── Diff Batching — coalesce multiple signal changes into one WS frame ──
|
|
var _pendingDiffs = {}; // accumulated changes: { name: value }
|
|
var _pendingVersions = {}; // version for each pending change
|
|
var _diffFlushTimer = null; // microtask coalescing
|
|
|
|
// ── Connection Status — reactive signals for stream health ──
|
|
var _streamConnected = false;
|
|
var _streamLatency = 0; // RTT in milliseconds
|
|
var _streamReconnects = 0;
|
|
var _streamFrameCount = 0;
|
|
var _streamByteCount = 0;
|
|
var _lastPingTime = 0; // for RTT calculation
|
|
|
|
function _encodeHeader(type, flags, seq, ts, w, h, len) {
|
|
const b = new ArrayBuffer(HEADER_SIZE);
|
|
const v = new DataView(b);
|
|
v.setUint8(0, type); v.setUint8(1, flags);
|
|
v.setUint16(2, seq, true); v.setUint32(4, ts, true);
|
|
v.setUint16(8, w, true); v.setUint16(10, h, true);
|
|
v.setUint32(12, len, true);
|
|
return new Uint8Array(b);
|
|
}
|
|
|
|
function _decodeHeader(buf) {
|
|
const v = new DataView(buf.buffer || buf, buf.byteOffset || 0);
|
|
return {
|
|
type: v.getUint8(0), flags: v.getUint8(1),
|
|
seq: v.getUint16(2, true), timestamp: v.getUint32(4, true),
|
|
width: v.getUint16(8, true), height: v.getUint16(10, true),
|
|
length: v.getUint32(12, true)
|
|
};
|
|
}
|
|
|
|
// ── Signal registry for bidirectional sync ──
|
|
var _signalRegistry = {};
|
|
var _signalVersions = {}; // per-signal version counters for conflict resolution
|
|
var _applyingRemoteDiff = false;
|
|
var _peerId = Math.random().toString(36).substr(2, 8); // unique per client
|
|
|
|
function _registerSignal(name, sig) {
|
|
_signalRegistry[name] = sig;
|
|
_signalVersions[name] = 0;
|
|
}
|
|
|
|
function _applyRemoteDiff(json) {
|
|
try {
|
|
var data = JSON.parse(json);
|
|
// Ignore our own diffs echoed back by the relay
|
|
if (data._pid === _peerId) return;
|
|
var versions = data._v || {};
|
|
_applyingRemoteDiff = true;
|
|
for (var name in data) {
|
|
if (name === '_pid' || name === '_v') continue;
|
|
var sig = _signalRegistry[name];
|
|
if (!sig) continue;
|
|
var remoteV = versions[name] || 0;
|
|
var localV = _signalVersions[name] || 0;
|
|
// Only apply if remote version >= local version (last-write-wins)
|
|
if (remoteV >= localV) {
|
|
sig.value = data[name];
|
|
_signalVersions[name] = remoteV;
|
|
}
|
|
}
|
|
flush();
|
|
} catch(e) { console.warn('[ds-stream] bad diff:', e); }
|
|
_applyingRemoteDiff = false;
|
|
}
|
|
|
|
function _initStream(url, mode) {
|
|
_streamMode = mode || 'signal';
|
|
_streamStart = performance.now();
|
|
|
|
// Use peer path for bidirectional sync: ws://host:port → ws://host:port/peer/default
|
|
var peerUrl = url;
|
|
if (!url.match(/\/(source|stream|peer)\//)) {
|
|
peerUrl = url.replace(/\/?$/, '/peer/default');
|
|
}
|
|
_streamWs = new WebSocket(peerUrl);
|
|
_streamWs.binaryType = 'arraybuffer';
|
|
_streamWs.onmessage = function(e) {
|
|
if (!(e.data instanceof ArrayBuffer) || e.data.byteLength < HEADER_SIZE) return;
|
|
var bytes = new Uint8Array(e.data);
|
|
var view = new DataView(bytes.buffer);
|
|
var type = view.getUint8(0);
|
|
_streamFrameCount++;
|
|
_streamByteCount += e.data.byteLength;
|
|
// Signal diff from another peer — apply to local signals
|
|
if (type === 0x31) {
|
|
var payload = bytes.subarray(HEADER_SIZE);
|
|
var json = new TextDecoder().decode(payload);
|
|
_applyRemoteDiff(json);
|
|
}
|
|
// Pong response — calculate RTT
|
|
if (type === 0xFE) {
|
|
if (_lastPingTime > 0) {
|
|
_streamLatency = Math.round(performance.now() - _lastPingTime);
|
|
}
|
|
}
|
|
};
|
|
_streamWs.onclose = function() {
|
|
_streamConnected = false;
|
|
_streamReconnects++;
|
|
console.log('[ds-stream] Disconnected, reconnecting in 2s (attempt ' + _streamReconnects + ')');
|
|
setTimeout(function() { _initStream(url, mode); }, 2000);
|
|
};
|
|
_streamWs.onerror = function() {
|
|
_streamConnected = false;
|
|
};
|
|
_streamWs.onopen = function() {
|
|
_streamConnected = true;
|
|
console.log('[ds-stream] Peer connected:', peerUrl);
|
|
// Send schema announcement (0x32) with output signal list
|
|
var outputNames = Object.keys(_signalRegistry);
|
|
if (outputNames.length > 0) {
|
|
_streamSend(0x32, 0, new TextEncoder().encode(
|
|
JSON.stringify({ signals: outputNames, mode: _streamMode })
|
|
));
|
|
}
|
|
// Broadcast full state snapshot with versions so other peers can sync
|
|
var fullState = { _pid: _peerId, _v: {} };
|
|
for (var name in _signalRegistry) {
|
|
var sig = _signalRegistry[name];
|
|
fullState[name] = (typeof sig === 'object' && sig !== null && '_value' in sig) ? sig._value : sig;
|
|
fullState._v[name] = _signalVersions[name] || 0;
|
|
}
|
|
_streamSend(0x31, 0, new TextEncoder().encode(JSON.stringify(fullState)));
|
|
|
|
// Start periodic ping for RTT measurement (every 5s)
|
|
if (!_streamWs._pingInterval) {
|
|
_streamWs._pingInterval = setInterval(function() {
|
|
if (_streamWs && _streamWs.readyState === 1) {
|
|
_lastPingTime = performance.now();
|
|
_streamSend(0xFE, 0, new Uint8Array(0));
|
|
}
|
|
}, 5000);
|
|
}
|
|
};
|
|
}
|
|
|
|
function _streamSend(type, flags, payload) {
|
|
if (!_streamWs || _streamWs.readyState !== 1) return;
|
|
var ts = (performance.now() - _streamStart) | 0;
|
|
var msg = new Uint8Array(HEADER_SIZE + payload.length);
|
|
var v = new DataView(msg.buffer);
|
|
v.setUint8(0, type);
|
|
v.setUint8(1, flags);
|
|
v.setUint16(2, (_streamSeq++) & 0xFFFF, true);
|
|
v.setUint32(4, ts, true);
|
|
v.setUint32(12, payload.length, true);
|
|
msg.set(payload, HEADER_SIZE);
|
|
_streamWs.send(msg.buffer);
|
|
}
|
|
|
|
// ── Batched diff: coalesce multiple signal changes into one WS frame ──
|
|
function _streamDiff(name, value) {
|
|
if (!_streamWs || _streamWs.readyState !== 1 || _streamMode !== 'signal') return;
|
|
if (_applyingRemoteDiff) return; // prevent echo loops
|
|
// Increment version for conflict resolution
|
|
_signalVersions[name] = (_signalVersions[name] || 0) + 1;
|
|
// Accumulate into pending batch
|
|
_pendingDiffs[name] = (typeof value === 'object' && value !== null && 'value' in value) ? value.value : value;
|
|
_pendingVersions[name] = _signalVersions[name];
|
|
// Schedule flush on next microtask (coalesces all changes in current event loop tick)
|
|
if (!_diffFlushTimer) {
|
|
_diffFlushTimer = Promise.resolve().then(_flushDiffBatch);
|
|
}
|
|
}
|
|
|
|
function _flushDiffBatch() {
|
|
_diffFlushTimer = null;
|
|
var keys = Object.keys(_pendingDiffs);
|
|
if (keys.length === 0) return;
|
|
// Build single frame with all changes
|
|
var obj = { _pid: _peerId, _v: {} };
|
|
for (var i = 0; i < keys.length; i++) {
|
|
var k = keys[i];
|
|
obj[k] = _pendingDiffs[k];
|
|
obj._v[k] = _pendingVersions[k];
|
|
}
|
|
_streamSend(0x31, 0, new TextEncoder().encode(JSON.stringify(obj)));
|
|
_pendingDiffs = {};
|
|
_pendingVersions = {};
|
|
}
|
|
|
|
function _streamSync(signals) {
|
|
var state = {};
|
|
for (var name in signals) {
|
|
var sig = signals[name];
|
|
state[name] = (typeof sig === 'object' && sig !== null && '_value' in sig) ? sig._value : sig;
|
|
}
|
|
_streamSend(0x30, 0x02, new TextEncoder().encode(JSON.stringify(state)));
|
|
}
|
|
|
|
function _streamSceneState(world, w, h) {
|
|
if (_streamMode === 'signal') {
|
|
var bodies = [];
|
|
for (var b = 0; b < world.body_count(); b++) {
|
|
var p = world.get_body_center(b);
|
|
bodies.push({ x: p[0] | 0, y: p[1] | 0 });
|
|
}
|
|
_streamSend(0x31, 0, new TextEncoder().encode(JSON.stringify({ _bodies: bodies })));
|
|
}
|
|
}
|
|
|
|
function _handleRemoteInput(type, payload) {
|
|
if (payload.length < 4) return;
|
|
var view = new DataView(payload.buffer, payload.byteOffset);
|
|
switch (type) {
|
|
case 0x01:
|
|
case 0x02:
|
|
emit('remote_pointer', {
|
|
x: view.getUint16(0, true), y: view.getUint16(2, true),
|
|
buttons: payload.length > 4 ? view.getUint8(4) : 0,
|
|
type: type === 0x02 ? 'down' : 'move'
|
|
});
|
|
break;
|
|
case 0x03:
|
|
emit('remote_pointer', { x: 0, y: 0, buttons: 0, type: 'up' });
|
|
break;
|
|
case 0x10:
|
|
emit('remote_key', { keyCode: view.getUint16(0, true), type: 'down' });
|
|
break;
|
|
case 0x11:
|
|
emit('remote_key', { keyCode: view.getUint16(0, true), type: 'up' });
|
|
break;
|
|
case 0x50:
|
|
emit('remote_scroll', { dx: view.getInt16(0, true), dy: view.getInt16(2, true) });
|
|
break;
|
|
}
|
|
}
|
|
|
|
function _connectStream(url, selectFields) {
|
|
var _csSelect = selectFields || [];
|
|
// Auto-detect bare relay URL and append default receiver path
|
|
var _csUrl = url;
|
|
try {
|
|
var u = new URL(url);
|
|
if (u.pathname === '/' || u.pathname === '') {
|
|
_csUrl = url.replace(/\/$/, '') + '/stream/default';
|
|
}
|
|
} catch(e) {}
|
|
var state = signal({});
|
|
var _csWs = null;
|
|
var _csReconnectDelay = 1000;
|
|
var _csStats = { frames: 0, bytes: 0, reconnects: 0 };
|
|
var _csPixelBuffer = null;
|
|
var _csSchema = null; // cached schema from 0x32
|
|
var _csConnected = false;
|
|
var _csLastPingTime = 0;
|
|
var _csLatency = 0;
|
|
|
|
// RLE decoder — 2-byte LE count (matches relay's codec::rle_encode)
|
|
function _csRleDecode(data) {
|
|
var out = [];
|
|
var i = 0;
|
|
while (i < data.length) {
|
|
if (data[i] === 0 && i + 2 < data.length) {
|
|
// 0x00 followed by 2-byte little-endian count
|
|
var count = data[i + 1] | (data[i + 2] << 8);
|
|
for (var j = 0; j < count; j++) out.push(0);
|
|
i += 3;
|
|
} else if (data[i] === 0) {
|
|
// Trailing 0x00 without enough bytes for count — output as literal
|
|
out.push(0);
|
|
i++;
|
|
} else {
|
|
out.push(data[i]);
|
|
i++;
|
|
}
|
|
}
|
|
return new Uint8Array(out);
|
|
}
|
|
|
|
// Apply select filter to state object
|
|
function _csFilter(obj) {
|
|
if (_csSelect.length === 0) return obj;
|
|
var filtered = {};
|
|
for (var i = 0; i < _csSelect.length; i++) {
|
|
var k = _csSelect[i];
|
|
if (k in obj) filtered[k] = obj[k];
|
|
}
|
|
return filtered;
|
|
}
|
|
|
|
function _csConnect() {
|
|
_csWs = new WebSocket(_csUrl);
|
|
_csWs.binaryType = 'arraybuffer';
|
|
_csWs.onopen = function() {
|
|
console.log('[ds-stream] Receiver connected:', _csUrl);
|
|
_csReconnectDelay = 1000;
|
|
_csConnected = true;
|
|
// Update connection status on signal proxy
|
|
var cur = state._value || {};
|
|
state.value = Object.assign({}, cur, {
|
|
_connected: true, _latency: _csLatency,
|
|
_frames: _csStats.frames, _reconnects: _csStats.reconnects
|
|
});
|
|
// Send subscribe filter (0x33) if select is set
|
|
if (_csSelect.length > 0) {
|
|
var filterPayload = new TextEncoder().encode(JSON.stringify({ select: _csSelect }));
|
|
var msg = new Uint8Array(HEADER_SIZE + filterPayload.length);
|
|
var v = new DataView(msg.buffer);
|
|
v.setUint8(0, 0x33); // SubscribeFilter
|
|
v.setUint8(1, 0);
|
|
v.setUint32(12, filterPayload.length, true);
|
|
msg.set(filterPayload, HEADER_SIZE);
|
|
_csWs.send(msg.buffer);
|
|
}
|
|
// Start periodic ping for RTT measurement
|
|
if (!_csWs._pingInterval) {
|
|
_csWs._pingInterval = setInterval(function() {
|
|
if (_csWs && _csWs.readyState === 1) {
|
|
_csLastPingTime = performance.now();
|
|
var pingPayload = new Uint8Array(0);
|
|
var msg = new Uint8Array(HEADER_SIZE);
|
|
var v = new DataView(msg.buffer);
|
|
v.setUint8(0, 0xFE); // Ping
|
|
_csWs.send(msg.buffer);
|
|
}
|
|
}, 5000);
|
|
}
|
|
};
|
|
_csWs.onmessage = function(e) {
|
|
if (!(e.data instanceof ArrayBuffer) || e.data.byteLength < HEADER_SIZE) return;
|
|
var bytes = new Uint8Array(e.data);
|
|
var view = new DataView(bytes.buffer);
|
|
var type = view.getUint8(0);
|
|
var flags = view.getUint8(1);
|
|
var payloadLen = view.getUint32(12, true);
|
|
var pl = bytes.subarray(HEADER_SIZE, HEADER_SIZE + payloadLen);
|
|
|
|
_csStats.frames++;
|
|
_csStats.bytes += e.data.byteLength;
|
|
|
|
// Handle input events forwarded from source
|
|
if (flags & 0x01) {
|
|
_handleRemoteInput(type, pl);
|
|
return;
|
|
}
|
|
|
|
switch (type) {
|
|
case 0x30: // SignalSync — full state
|
|
case 0x31: // SignalDiff — partial state update
|
|
try {
|
|
var newState = JSON.parse(new TextDecoder().decode(pl));
|
|
// Strip internal sync metadata
|
|
delete newState._pid;
|
|
delete newState._v;
|
|
// Apply select filter
|
|
newState = _csFilter(newState);
|
|
// Inject connection metadata
|
|
newState._connected = _csConnected;
|
|
newState._latency = _csLatency;
|
|
newState._frames = _csStats.frames;
|
|
if (type === 0x30) {
|
|
state.value = newState;
|
|
} else {
|
|
state.value = Object.assign({}, state._value || {}, newState);
|
|
}
|
|
} catch(ex) {}
|
|
break;
|
|
case 0x32: // SchemaAnnounce — cache signal names
|
|
try {
|
|
_csSchema = JSON.parse(new TextDecoder().decode(pl));
|
|
console.log('[ds-stream] Schema:', _csSchema.signals || []);
|
|
} catch(ex) {}
|
|
break;
|
|
case 0x01: // Pixels (keyframe)
|
|
case 0x04: // Keyframe
|
|
var w = view.getUint16(8, true);
|
|
var h = view.getUint16(10, true);
|
|
_csPixelBuffer = pl.slice();
|
|
emit('stream_frame', { type: 'keyframe', width: w, height: h, pixels: _csPixelBuffer });
|
|
break;
|
|
case 0x03: // DeltaPixels (XOR + RLE)
|
|
if (_csPixelBuffer) {
|
|
var decoded = _csRleDecode(pl);
|
|
for (var i = 0; i < _csPixelBuffer.length && i < decoded.length; i++) {
|
|
_csPixelBuffer[i] ^= decoded[i];
|
|
}
|
|
emit('stream_frame', { type: 'delta', pixels: _csPixelBuffer });
|
|
}
|
|
break;
|
|
case 0xFE: // Pong — calculate RTT
|
|
if (_csLastPingTime > 0) {
|
|
_csLatency = Math.round(performance.now() - _csLastPingTime);
|
|
var cur = state._value || {};
|
|
state.value = Object.assign({}, cur, { _latency: _csLatency });
|
|
}
|
|
break;
|
|
case 0xFF: // StreamEnd
|
|
console.log('[ds-stream] Stream ended by source');
|
|
emit('stream_end', {});
|
|
break;
|
|
}
|
|
};
|
|
_csWs.onclose = function() {
|
|
_csConnected = false;
|
|
_csStats.reconnects++;
|
|
var delay = Math.min(_csReconnectDelay, 10000);
|
|
console.log('[ds-stream] Receiver disconnected from', _csUrl, '— reconnecting in', delay, 'ms (attempt', _csStats.reconnects, ')');
|
|
// Update connection status on signal proxy
|
|
var cur = state._value || {};
|
|
state.value = Object.assign({}, cur, {
|
|
_connected: false, _reconnects: _csStats.reconnects
|
|
});
|
|
setTimeout(_csConnect, delay);
|
|
_csReconnectDelay = Math.min(_csReconnectDelay * 1.5, 10000);
|
|
};
|
|
_csWs.onerror = function() {
|
|
_csConnected = false;
|
|
};
|
|
}
|
|
_csConnect();
|
|
return state;
|
|
}
|
|
|
|
// ── WebRTC Data Channel Transport ──
|
|
var _rtcPc = null;
|
|
var _rtcDc = null;
|
|
|
|
function _initWebRTC(signalingUrl, streamUrl, mode) {
|
|
var sigWs = new WebSocket(signalingUrl);
|
|
var pc = new RTCPeerConnection({
|
|
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
|
|
});
|
|
_rtcPc = pc;
|
|
|
|
// Create data channel for frames (source side)
|
|
var dc = pc.createDataChannel('ds-frames', { ordered: false, maxRetransmits: 0 });
|
|
dc.binaryType = 'arraybuffer';
|
|
dc.onopen = function() {
|
|
console.log('[ds-webrtc] Data channel open');
|
|
_rtcDc = dc;
|
|
// Override stream send to use data channel
|
|
var origSend = _streamSend;
|
|
_streamSend = function(type, flags, payload) {
|
|
if (_rtcDc && _rtcDc.readyState === 'open') {
|
|
var ts = (performance.now() - _streamStart) | 0;
|
|
var msg = new Uint8Array(16 + payload.length);
|
|
var v = new DataView(msg.buffer);
|
|
v.setUint8(0, type);
|
|
v.setUint8(1, flags);
|
|
v.setUint16(2, (_streamSeq++) & 0xFFFF, true);
|
|
v.setUint32(4, ts, true);
|
|
v.setUint32(12, payload.length, true);
|
|
msg.set(payload, 16);
|
|
_rtcDc.send(msg.buffer);
|
|
} else {
|
|
origSend(type, flags, payload);
|
|
}
|
|
};
|
|
};
|
|
dc.onclose = function() {
|
|
console.log('[ds-webrtc] Data channel closed, falling back to WebSocket');
|
|
_rtcDc = null;
|
|
};
|
|
|
|
// Handle incoming data channels (receiver side)
|
|
pc.ondatachannel = function(event) {
|
|
var incoming = event.channel;
|
|
incoming.binaryType = 'arraybuffer';
|
|
incoming.onmessage = function(e) {
|
|
if (_streamWs && _streamWs.onmessage) {
|
|
_streamWs.onmessage(e);
|
|
}
|
|
};
|
|
incoming.onopen = function() {
|
|
console.log('[ds-webrtc] Incoming data channel open');
|
|
_rtcDc = incoming;
|
|
};
|
|
};
|
|
|
|
// ICE candidate exchange
|
|
pc.onicecandidate = function(e) {
|
|
if (e.candidate && sigWs.readyState === 1) {
|
|
sigWs.send(JSON.stringify({ type: 'ice', candidate: e.candidate }));
|
|
}
|
|
};
|
|
|
|
// Signaling messages
|
|
sigWs.onmessage = function(e) {
|
|
try {
|
|
var msg = JSON.parse(e.data);
|
|
if (msg.type === 'offer') {
|
|
pc.setRemoteDescription(new RTCSessionDescription(msg.sdp))
|
|
.then(function() { return pc.createAnswer(); })
|
|
.then(function(answer) {
|
|
pc.setLocalDescription(answer);
|
|
sigWs.send(JSON.stringify({ type: 'answer', sdp: answer }));
|
|
});
|
|
} else if (msg.type === 'answer') {
|
|
pc.setRemoteDescription(new RTCSessionDescription(msg.sdp));
|
|
} else if (msg.type === 'ice' && msg.candidate) {
|
|
pc.addIceCandidate(new RTCIceCandidate(msg.candidate));
|
|
}
|
|
} catch(ex) {}
|
|
};
|
|
|
|
sigWs.onopen = function() {
|
|
console.log('[ds-webrtc] Signaling connected:', signalingUrl);
|
|
// Source creates offer
|
|
pc.createOffer()
|
|
.then(function(offer) {
|
|
pc.setLocalDescription(offer);
|
|
sigWs.send(JSON.stringify({ type: 'offer', sdp: offer }));
|
|
});
|
|
};
|
|
|
|
// Fallback: if WebRTC doesn't connect in 5s, use WebSocket
|
|
setTimeout(function() {
|
|
if (!_rtcDc || _rtcDc.readyState !== 'open') {
|
|
console.log('[ds-webrtc] Timeout, falling back to WebSocket');
|
|
_initStream(streamUrl, mode);
|
|
}
|
|
}, 5000);
|
|
|
|
// Also init WebSocket as immediate fallback
|
|
_initStream(streamUrl, mode);
|
|
}
|
|
|
|
var _ds = { signal: signal, derived: derived, effect: effect, batch: batch, flush: flush, onEvent: onEvent, emit: emit,
|
|
keyedList: keyedList, route: _route, navigate: navigate, matchRoute: matchRoute,
|
|
resource: resource, fetchJSON: fetchJSON,
|
|
spring: spring, constrain: constrain, viewport: _viewport,
|
|
scene: scene, circle: circle, rect: rect, line: line,
|
|
_initStream: _initStream, _streamDiff: _streamDiff, _streamSync: _streamSync,
|
|
_streamSceneState: _streamSceneState, _connectStream: _connectStream,
|
|
_initWebRTC: _initWebRTC, _registerSignal: _registerSignal,
|
|
Signal: Signal, Derived: Derived, Effect: Effect, Spring: Spring };
|
|
Object.defineProperty(_ds, '_streamWs', { get: function() { return _streamWs; } });
|
|
Object.defineProperty(_ds, '_rtcDc', { get: function() { return _rtcDc; } });
|
|
return _ds;
|
|
})();
|
|
"#;
|