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(""))
|
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) => {
|
Expr::DotAccess(base, field) => {
|
||||||
let base_js = self.emit_expr(base);
|
let base_js = self.emit_expr(base);
|
||||||
format!("{base_js}.{field}")
|
format!("{base_js}.{field}")
|
||||||
|
|
@ -764,8 +770,20 @@ impl JsEmitter {
|
||||||
(a, s)
|
(a, s)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// Stream diff: broadcast signal change if streaming is active
|
// For indexed mutations, re-trigger the signal to notify the reactive system
|
||||||
format!("{}; DS._streamDiff(\"{}\", {}.value)", assign, root_for_diff, root_for_diff)
|
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) => {
|
Expr::Block(exprs) => {
|
||||||
let stmts: Vec<String> = exprs.iter().map(|e| self.emit_event_handler_expr(e)).collect();
|
let stmts: Vec<String> = exprs.iter().map(|e| self.emit_event_handler_expr(e)).collect();
|
||||||
|
|
|
||||||
|
|
@ -599,7 +599,7 @@ impl Parser {
|
||||||
self.advance();
|
self.advance();
|
||||||
Ok(Expr::BoolLit(false))
|
Ok(Expr::BoolLit(false))
|
||||||
}
|
}
|
||||||
TokenKind::StringFragment(_) | TokenKind::StringEnd => {
|
TokenKind::StringFragment(_) | TokenKind::StringEnd | TokenKind::StringInterp => {
|
||||||
self.parse_string_lit()
|
self.parse_string_lit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -826,12 +826,31 @@ impl Parser {
|
||||||
let name = name.clone();
|
let name = name.clone();
|
||||||
self.advance();
|
self.advance();
|
||||||
|
|
||||||
// Function call: `name(args)`
|
// UI element with any arg (string, ident, parenthesized expr, or props-only)
|
||||||
if self.check(&TokenKind::LParen) {
|
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()?;
|
let args = self.parse_call_args()?;
|
||||||
Ok(Expr::Call(name, 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(_)) {
|
else if matches!(self.peek(), TokenKind::StringFragment(_)) {
|
||||||
let fallback = name.clone();
|
let fallback = name.clone();
|
||||||
match self.parse_element(name)? {
|
match self.parse_element(name)? {
|
||||||
|
|
@ -839,22 +858,6 @@ impl Parser {
|
||||||
None => Ok(Expr::Ident(fallback)),
|
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 {
|
else {
|
||||||
Ok(Expr::Ident(name))
|
Ok(Expr::Ident(name))
|
||||||
}
|
}
|
||||||
|
|
@ -869,10 +872,10 @@ impl Parser {
|
||||||
let mut props = Vec::new();
|
let mut props = Vec::new();
|
||||||
let mut modifiers = Vec::new();
|
let mut modifiers = Vec::new();
|
||||||
|
|
||||||
// Parse string or ident args
|
// Parse string, ident, or parenthesized expression args
|
||||||
loop {
|
loop {
|
||||||
match self.peek().clone() {
|
match self.peek().clone() {
|
||||||
TokenKind::StringFragment(_) | TokenKind::StringEnd => {
|
TokenKind::StringFragment(_) | TokenKind::StringEnd | TokenKind::StringInterp => {
|
||||||
args.push(self.parse_string_lit()?);
|
args.push(self.parse_string_lit()?);
|
||||||
}
|
}
|
||||||
TokenKind::Ident(name) if !is_declaration_keyword(&name) => {
|
TokenKind::Ident(name) if !is_declaration_keyword(&name) => {
|
||||||
|
|
@ -880,6 +883,13 @@ impl Parser {
|
||||||
self.advance();
|
self.advance();
|
||||||
args.push(Expr::Ident(name));
|
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,
|
_ => 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