feat: step sequencer demo — reactive pads, playhead, BPM

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.
This commit is contained in:
enzotar 2026-02-25 19:33:12 -08:00
parent 2d07b1652a
commit 2aa2c7ad8e
3 changed files with 125 additions and 25 deletions

View file

@ -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<String> = exprs.iter().map(|e| self.emit_event_handler_expr(e)).collect();

View file

@ -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,
}
}

View file

@ -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 }
]
]