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:
parent
2d07b1652a
commit
2aa2c7ad8e
3 changed files with 125 additions and 25 deletions
|
|
@ -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,9 +770,21 @@ impl JsEmitter {
|
|||
(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Expr::Block(exprs) => {
|
||||
let stmts: Vec<String> = exprs.iter().map(|e| self.emit_event_handler_expr(e)).collect();
|
||||
stmts.join("; ")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
72
examples/step-sequencer.ds
Normal file
72
examples/step-sequencer.ds
Normal 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 }
|
||||
]
|
||||
]
|
||||
Loading…
Add table
Reference in a new issue