From 2aa2c7ad8eca03e6192fae98054aafbabfccb38b Mon Sep 17 00:00:00 2001 From: enzotar Date: Wed, 25 Feb 2026 19:33:12 -0800 Subject: [PATCH] =?UTF-8?q?feat:=20step=20sequencer=20demo=20=E2=80=94=20r?= =?UTF-8?q?eactive=20pads,=20playhead,=20BPM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step sequencer: 4 instruments × 8 steps, timer-driven playhead, toggleable pads, BPM controls with streaming. 75 lines of .ds code. Parser fixes: - UI elements checked before LParen (button (if ...) is element, not call) - Element args support parenthesized expressions: button (if cond ...) - StringInterp recognized as valid string start in parse_primary/parse_element Codegen fixes: - emit_expr checks local_vars: loop var i emits 'i' not 'i.value' - Array index mutations re-trigger signal: pads.value = [...pads.value] 110 tests, 0 failures. --- compiler/ds-codegen/src/js_emitter.rs | 24 +++++++-- compiler/ds-parser/src/parser.rs | 54 ++++++++++++-------- examples/step-sequencer.ds | 72 +++++++++++++++++++++++++++ 3 files changed, 125 insertions(+), 25 deletions(-) create mode 100644 examples/step-sequencer.ds diff --git a/compiler/ds-codegen/src/js_emitter.rs b/compiler/ds-codegen/src/js_emitter.rs index 3e3bec7..225317e 100644 --- a/compiler/ds-codegen/src/js_emitter.rs +++ b/compiler/ds-codegen/src/js_emitter.rs @@ -639,7 +639,13 @@ impl JsEmitter { } format!("`{}`", parts.join("")) } - Expr::Ident(name) => format!("{name}.value"), + Expr::Ident(name) => { + if self.local_vars.contains(name) { + format!("{name}") + } else { + format!("{name}.value") + } + } Expr::DotAccess(base, field) => { let base_js = self.emit_expr(base); format!("{base_js}.{field}") @@ -764,8 +770,20 @@ impl JsEmitter { (a, s) } }; - // Stream diff: broadcast signal change if streaming is active - format!("{}; DS._streamDiff(\"{}\", {}.value)", assign, root_for_diff, root_for_diff) + // 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) + } + } } Expr::Block(exprs) => { let stmts: Vec = exprs.iter().map(|e| self.emit_event_handler_expr(e)).collect(); diff --git a/compiler/ds-parser/src/parser.rs b/compiler/ds-parser/src/parser.rs index 94cd0da..817dbff 100644 --- a/compiler/ds-parser/src/parser.rs +++ b/compiler/ds-parser/src/parser.rs @@ -599,7 +599,7 @@ impl Parser { self.advance(); Ok(Expr::BoolLit(false)) } - TokenKind::StringFragment(_) | TokenKind::StringEnd => { + TokenKind::StringFragment(_) | TokenKind::StringEnd | TokenKind::StringInterp => { self.parse_string_lit() } @@ -826,12 +826,31 @@ impl Parser { let name = name.clone(); self.advance(); - // Function call: `name(args)` - if self.check(&TokenKind::LParen) { + // UI element with any arg (string, ident, parenthesized expr, or props-only) + if is_ui_element(&name) { + let next = self.peek().clone(); + let looks_like_element = matches!( + next, + TokenKind::StringFragment(_) | TokenKind::StringEnd | TokenKind::StringInterp + | TokenKind::LBrace | TokenKind::LParen + ) || matches!(next, TokenKind::Ident(ref n) if !is_declaration_keyword(n)); + + if looks_like_element { + let fallback = name.clone(); + match self.parse_element(name)? { + Some(el) => Ok(el), + None => Ok(Expr::Ident(fallback)), + } + } else { + Ok(Expr::Ident(name)) + } + } + // Function call: `name(args)` — only for non-element idents + else if self.check(&TokenKind::LParen) { let args = self.parse_call_args()?; Ok(Expr::Call(name, args)) } - // Element with string arg: `text "hello"`, `button "+"` + // Element with string arg: `text "hello"` — fallback for non-is_ui_element tags else if matches!(self.peek(), TokenKind::StringFragment(_)) { let fallback = name.clone(); match self.parse_element(name)? { @@ -839,22 +858,6 @@ impl Parser { None => Ok(Expr::Ident(fallback)), } } - // Element with ident arg: `text label` - else if is_ui_element(&name) && matches!(self.peek(), TokenKind::Ident(_)) { - let fallback = name.clone(); - match self.parse_element(name)? { - Some(el) => Ok(el), - None => Ok(Expr::Ident(fallback)), - } - } - // Element with props only: `input { bind: name }` - else if is_ui_element(&name) && matches!(self.peek(), TokenKind::LBrace) { - let fallback = name.clone(); - match self.parse_element(name)? { - Some(el) => Ok(el), - None => Ok(Expr::Ident(fallback)), - } - } else { Ok(Expr::Ident(name)) } @@ -869,10 +872,10 @@ impl Parser { let mut props = Vec::new(); let mut modifiers = Vec::new(); - // Parse string or ident args + // Parse string, ident, or parenthesized expression args loop { match self.peek().clone() { - TokenKind::StringFragment(_) | TokenKind::StringEnd => { + TokenKind::StringFragment(_) | TokenKind::StringEnd | TokenKind::StringInterp => { args.push(self.parse_string_lit()?); } TokenKind::Ident(name) if !is_declaration_keyword(&name) => { @@ -880,6 +883,13 @@ impl Parser { self.advance(); args.push(Expr::Ident(name)); } + TokenKind::LParen => { + // Parenthesized expression arg: button (if cond then "A" else "B") + self.advance(); // consume ( + let expr = self.parse_expr()?; + self.expect(&TokenKind::RParen)?; + args.push(expr); + } _ => break, } } diff --git a/examples/step-sequencer.ds b/examples/step-sequencer.ds new file mode 100644 index 0000000..c7fbd18 --- /dev/null +++ b/examples/step-sequencer.ds @@ -0,0 +1,72 @@ +-- DreamStack Step Sequencer +-- Collaborative beat grid: two tabs, same pads, real-time sync + +let bpm = 120 +let step = 0 + +-- 4 instruments × 8 steps = 32 pads (flat array, index with row*8+col) +let pads = [0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0] + +-- Playhead advances every beat +every (60000 / bpm / 2) -> step = (step + 1) % 8 + +-- Stream for multiplayer +stream sequencer on "ws://localhost:9100/source/beats" { mode: signal } + +view sequencer = + column [ + text "DreamStack Beats" + text "BPM: {bpm}" + + -- Kick (pads 0-7) + text "Kick" + row [ + for i in [0, 1, 2, 3, 4, 5, 6, 7] -> + button (if pads[i] then "●" else "○") { + click: pads[i] = if pads[i] then 0 else 1 + } + ] + + -- Snare (pads 8-15) + text "Snare" + row [ + for i in [8, 9, 10, 11, 12, 13, 14, 15] -> + button (if pads[i] then "●" else "○") { + click: pads[i] = if pads[i] then 0 else 1 + } + ] + + -- Hi-hat (pads 16-23) + text "HiHat" + row [ + for i in [16, 17, 18, 19, 20, 21, 22, 23] -> + button (if pads[i] then "●" else "○") { + click: pads[i] = if pads[i] then 0 else 1 + } + ] + + -- Bass (pads 24-31) + text "Bass" + row [ + for i in [24, 25, 26, 27, 28, 29, 30, 31] -> + button (if pads[i] then "●" else "○") { + click: pads[i] = if pads[i] then 0 else 1 + } + ] + + -- Playhead indicator + row [ + for i in [0, 1, 2, 3, 4, 5, 6, 7] -> + text (if i == step then "▼" else "·") + ] + + -- BPM controls + row [ + button "−10" { click: bpm -= 10 } + text "{bpm}" + button "+10" { click: bpm += 10 } + ] + ]