feat(compiler): complete bitstream integration — all 9 changes

- AST: StreamDecl, StreamMode, Declaration::Stream, StreamFrom struct variant
- Lexer: Pixel, Delta, Signals keywords
- Parser: parse_stream_decl with mode block, fixed TokenKind::On match
- Signal graph: streamable flag, SignalManifest, Declaration::Stream detection
- Checker: StreamFrom { source, .. } pattern
- Codegen: DS._initStream(), DS._connectStream(), DS._streamDiff() hooks
- Runtime JS: full streaming layer with binary protocol encoding
- Layout: to_bytes/from_bytes on LayoutRect

82 tests pass (5 new: 3 parser stream + 2 analyzer streamable)
This commit is contained in:
enzotar 2026-02-25 13:26:59 -08:00
parent 980ac5c9b3
commit 439a775dec
7 changed files with 753 additions and 94 deletions

View file

@ -16,9 +16,9 @@ All changes in this spec have been implemented. Status per change:
| 6. Codegen | `emit_stream_init`, `StreamFrom`, runtime JS | ✅ Done | `js_emitter.rs` |
| 7. JS Runtime | `_initStream`, `_streamDiff`, `_streamSync`, `_connectStream`, `_streamSceneState` | ✅ Done | Embedded in `RUNTIME_JS` |
| 8. CLI | `dreamstack stream` command | ✅ Done | `main.rs` |
| 9. Layout | `to_bytes`/`from_bytes` on `LayoutRect` | ⬜ Deferred | Low priority, not yet needed |
| 9. Layout | `to_bytes`/`from_bytes` on `LayoutRect` | ✅ Done | `solver.rs` |
**Test counts**: 77 tests passing across full workspace (ds-stream: 38, ds-parser: 12, ds-analyzer: 4, ds-types: 11, plus others).
**Test counts**: 82 tests passing across full workspace (ds-stream: 38, ds-parser: 15, ds-analyzer: 6, ds-types: 11, plus others).
## Background

View file

@ -510,4 +510,20 @@ view counter =
// Should have: container, text binding, static text, event handler
assert!(views[0].bindings.len() >= 3);
}
#[test]
fn test_streamable_signals() {
let (graph, _) = analyze(
"stream main on \"ws://localhost:9100\"\nlet count = 0\nview main = column [ text \"hello\" ]"
);
let count_node = graph.nodes.iter().find(|n| n.name == "count").unwrap();
assert!(count_node.streamable, "source signal should be streamable when stream decl present");
}
#[test]
fn test_not_streamable_without_decl() {
let (graph, _) = analyze("let count = 0\nview main = column [ text \"hi\" ]");
let count_node = graph.nodes.iter().find(|n| n.name == "count").unwrap();
assert!(!count_node.streamable, "signals should not be streamable without stream decl");
}
}

View file

@ -237,8 +237,8 @@ impl JsEmitter {
};
let url = self.emit_expr(&stream.relay_url);
self.emit_line(&format!(
"DS.streamInit({{ view: '{}', relay: {}, mode: '{}' }});",
stream.view_name, url, mode
"DS._initStream({}, '{}');",
url, mode
));
}
}
@ -670,13 +670,8 @@ impl JsEmitter {
let items_js: Vec<String> = items.iter().map(|i| self.emit_expr(i)).collect();
format!("[{}]", items_js.join(", "))
}
Expr::StreamFrom { source, mode } => {
let mode_str = match mode {
Some(StreamMode::Pixel) => "pixel",
Some(StreamMode::Delta) => "delta",
Some(StreamMode::Signal) | None => "signal",
};
format!("DS.streamConnect(\"{}\", \"{}\")", source, mode_str)
Expr::StreamFrom { source, .. } => {
format!("DS._connectStream(\"{}\")", source)
}
_ => "null".to_string(),
}
@ -694,11 +689,13 @@ impl JsEmitter {
_ => self.emit_expr(target),
};
let value_js = self.emit_expr(value);
match op {
let assign = match op {
AssignOp::Set => format!("{target_js}.value = {value_js}"),
AssignOp::AddAssign => format!("{target_js}.value += {value_js}"),
AssignOp::SubAssign => format!("{target_js}.value -= {value_js}"),
}
};
// Stream diff: broadcast signal change if streaming is active
format!("{}; DS._streamDiff(\"{}\", {}.value)", assign, target_js, target_js)
}
Expr::Block(exprs) => {
let stmts: Vec<String> = exprs.iter().map(|e| self.emit_event_handler_expr(e)).collect();
@ -952,7 +949,10 @@ impl JsEmitter {
self.emit_line("}");
// Loop
self.emit_line("function _sceneLoop() { _world.step(1/60); _sceneDraw(); requestAnimationFrame(_sceneLoop); }");
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;
@ -1608,87 +1608,122 @@ const DS = (() => {
};
}
function streamConnect(url, mode) {
const state = signal({ connected: false, mode: mode || 'signal' });
function _initStream(url, mode) {
_streamMode = mode || 'signal';
_connectRelay(url);
_streamStart = performance.now();
_streamWs = new WebSocket(url);
_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);
var flags = view.getUint8(1);
if (flags & 0x01) _handleRemoteInput(type, bytes.subarray(HEADER_SIZE));
};
_streamWs.onclose = function() { setTimeout(function() { _initStream(url, mode); }, 2000); };
console.log('[ds-stream] Source connected:', url, 'mode:', mode);
}
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);
}
function _streamDiff(name, value) {
if (!_streamWs || _streamMode !== 'signal') return;
var obj = {};
obj[name] = (typeof value === 'object' && value !== null && 'value' in value) ? value.value : value;
_streamSend(0x31, 0, new TextEncoder().encode(JSON.stringify(obj)));
}
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) {
var state = signal(null);
var ws = new WebSocket(url);
ws.binaryType = 'arraybuffer';
ws.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 payloadLen = view.getUint32(12, true);
var pl = bytes.subarray(HEADER_SIZE, HEADER_SIZE + payloadLen);
if (type === 0x30 || type === 0x31) {
try {
var newState = JSON.parse(new TextDecoder().decode(pl));
state.value = Object.assign(state._value || {}, newState);
} catch(ex) {}
}
};
ws.onclose = function() { setTimeout(function() { _connectStream(url); }, 2000); };
return state;
}
function _connectRelay(url) {
_streamWs = new WebSocket(url);
_streamWs.binaryType = 'arraybuffer';
_streamWs.onopen = () => {
_streamStart = performance.now();
console.log('[ds-stream] Connected to relay:', url);
};
_streamWs.onclose = () => {
console.log('[ds-stream] Disconnected, reconnecting...');
setTimeout(() => _connectRelay(url), 2000);
};
_streamWs.onmessage = (e) => {
if (!(e.data instanceof ArrayBuffer) || e.data.byteLength < HEADER_SIZE) return;
const header = _decodeHeader(new Uint8Array(e.data));
if (header.flags & 0x01) {
_handleRemoteInput(header, new Uint8Array(e.data, HEADER_SIZE));
}
};
}
function _handleRemoteInput(header, payload) {
// Remote input events — emit as DS events
const v = new DataView(payload.buffer, payload.byteOffset);
switch (header.type) {
case 0x02: // PointerDown
if (payload.length >= 5) emit('remote_pointer_down', { x: v.getUint16(0, true), y: v.getUint16(2, true) });
break;
case 0x01: // Pointer
if (payload.length >= 5) emit('remote_pointer', { x: v.getUint16(0, true), y: v.getUint16(2, true) });
break;
case 0x03: // PointerUp
emit('remote_pointer_up', {});
break;
case 0x10: // KeyDown
if (payload.length >= 3) emit('remote_key_down', { keycode: v.getUint16(0, true), mods: payload[2] });
break;
}
}
function streamInit(config) {
_streamMode = config.mode || 'signal';
_connectRelay(config.relay);
if (_streamMode === 'signal') {
// Auto-send signal diffs at 30fps
setInterval(() => _sendSignalFrame(), 1000 / 30);
}
}
function _sendSignalFrame() {
if (!_streamWs || _streamWs.readyState !== WebSocket.OPEN) return;
// Collect all signal values
const state = {};
// TODO: populated by compiler-generated code per-signal
const json = JSON.stringify(state);
const payload = new TextEncoder().encode(json);
const ts = Math.round(performance.now() - _streamStart);
const isSync = !_prevSignals || (_streamSeq % 150 === 0);
const frameType = isSync ? 0x30 : 0x31;
const flags = isSync ? 0x02 : 0;
const header = _encodeHeader(frameType, flags, _streamSeq & 0xFFFF, ts, 0, 0, payload.length);
const msg = new Uint8Array(HEADER_SIZE + payload.length);
msg.set(header, 0); msg.set(payload, HEADER_SIZE);
_streamWs.send(msg.buffer);
_streamSeq++;
_prevSignals = state;
}
return { signal, derived, effect, batch, flush, onEvent, emit,
keyedList, route: _route, navigate, matchRoute,
resource, fetchJSON,
spring, constrain, viewport: _viewport,
scene, circle, rect, line,
streamConnect, streamInit,
Signal, Derived, Effect, Spring };
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,
Signal: Signal, Derived: Derived, Effect: Effect, Spring: Spring };
Object.defineProperty(_ds, '_streamWs', { get: function() { return _streamWs; } });
return _ds;
})();
"#;

View file

@ -147,7 +147,27 @@ pub struct LayoutRect {
pub height: f64,
}
// ─── Solver ─────────────────────────────────────────────────
impl LayoutRect {
/// Serialize to 16 bytes: x(f32) + y(f32) + w(f32) + h(f32)
pub fn to_bytes(&self) -> [u8; 16] {
let mut buf = [0u8; 16];
buf[0..4].copy_from_slice(&(self.x as f32).to_le_bytes());
buf[4..8].copy_from_slice(&(self.y as f32).to_le_bytes());
buf[8..12].copy_from_slice(&(self.width as f32).to_le_bytes());
buf[12..16].copy_from_slice(&(self.height as f32).to_le_bytes());
buf
}
pub fn from_bytes(buf: &[u8; 16]) -> Self {
Self {
x: f32::from_le_bytes(buf[0..4].try_into().unwrap()) as f64,
y: f32::from_le_bytes(buf[4..8].try_into().unwrap()) as f64,
width: f32::from_le_bytes(buf[8..12].try_into().unwrap()) as f64,
height: f32::from_le_bytes(buf[12..16].try_into().unwrap()) as f64,
}
}
}
/// The constraint solver.
///

View file

@ -275,7 +275,7 @@ impl Parser {
// Expect `on`
match self.peek() {
TokenKind::Ident(s) if s == "on" => { self.advance(); }
TokenKind::On => { self.advance(); }
_ => return Err(self.error("Expected 'on' after stream view name".into())),
}
@ -1135,4 +1135,38 @@ view counter =
);
assert_eq!(prog.declarations.len(), 4); // 3 lets + 1 view
}
#[test]
fn test_stream_decl() {
let prog = parse(r#"stream main on "ws://localhost:9100" { mode: signal }"#);
match &prog.declarations[0] {
Declaration::Stream(s) => {
assert_eq!(s.view_name, "main");
assert_eq!(s.mode, StreamMode::Signal);
}
other => panic!("expected Stream, got {other:?}"),
}
}
#[test]
fn test_stream_decl_pixel_mode() {
let prog = parse(r#"stream main on "ws://localhost:9100" { mode: pixel }"#);
match &prog.declarations[0] {
Declaration::Stream(s) => {
assert_eq!(s.mode, StreamMode::Pixel);
}
other => panic!("expected Stream, got {other:?}"),
}
}
#[test]
fn test_stream_decl_default_mode() {
let prog = parse(r#"stream main on "ws://localhost:9100""#);
match &prog.declarations[0] {
Declaration::Stream(s) => {
assert_eq!(s.mode, StreamMode::Signal);
}
other => panic!("expected Stream, got {other:?}"),
}
}
}

View file

@ -0,0 +1,530 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DreamStack — Spring Physics + 2D Scene</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', -apple-system, system-ui, sans-serif;
background: #0a0a0f;
color: #e2e8f0;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 2rem;
}
h1 {
font-size: 1.8rem;
font-weight: 700;
background: linear-gradient(135deg, #6366f1, #8b5cf6, #c084fc);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 0.5rem;
}
.subtitle {
color: rgba(255, 255, 255, 0.3);
font-size: 0.8rem;
margin-bottom: 1.5rem;
}
.controls {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
justify-content: center;
margin-bottom: 1rem;
}
.controls button {
padding: 0.5rem 1.2rem;
border: none;
border-radius: 12px;
background: linear-gradient(135deg, rgba(99, 102, 241, 0.2), rgba(139, 92, 246, 0.2));
color: #c4b5fd;
cursor: pointer;
font-size: 0.8rem;
font-weight: 500;
transition: all 0.15s;
border: 1px solid rgba(139, 92, 246, 0.15);
}
.controls button:hover {
background: linear-gradient(135deg, rgba(99, 102, 241, 0.4), rgba(139, 92, 246, 0.4));
transform: translateY(-1px);
}
.info {
font-family: 'JetBrains Mono', monospace;
font-size: 0.7rem;
color: rgba(255, 255, 255, 0.2);
margin-top: 0.5rem;
text-align: center;
}
.sliders {
display: flex;
gap: 1.5rem;
margin-top: 1rem;
flex-wrap: wrap;
justify-content: center;
}
.slider-group {
display: flex;
align-items: center;
gap: 0.4rem;
}
.slider-group label {
font-size: 0.7rem;
color: rgba(255, 255, 255, 0.3);
min-width: 55px;
}
.slider-group input[type="range"] {
width: 100px;
accent-color: #8b5cf6;
}
.slider-group .val {
font-size: 0.7rem;
color: #8b5cf6;
min-width: 25px;
text-align: right;
}
.powered {
margin-top: 1.5rem;
font-size: 0.65rem;
color: rgba(255, 255, 255, 0.1);
}
.powered span {
color: rgba(139, 92, 246, 0.3);
}
</style>
</head>
<body>
<h1>Spring Physics + 2D Scene</h1>
<p class="subtitle">Springs drive positions. Canvas renders frames. No CSS animations.</p>
<div class="controls" id="controls"></div>
<div id="scene"></div>
<div class="info" id="info"></div>
<div class="sliders" id="sliders"></div>
<div class="powered">Built with <span>DreamStack</span> — springs + signals + 2D scene</div>
<script>
// ═══════════════════════════════════════════════════════════
// DreamStack Runtime v0.5.0 — Signals + Springs + 2D Scene
// ═══════════════════════════════════════════════════════════
const DS = (() => {
let currentEffect = null;
let batchDepth = 0;
let pendingEffects = new Set();
class Signal {
constructor(val) { this._value = val; this._subs = new Set(); }
get value() { if (currentEffect) this._subs.add(currentEffect); return this._value; }
set value(v) {
if (this._value === v) return;
this._value = v;
if (batchDepth > 0) for (const s of this._subs) pendingEffects.add(s);
else for (const s of [...this._subs]) s._run();
}
}
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; }
}
const signal = v => new Signal(v);
const effect = fn => { const e = new Effect(fn); e._run(); return e; };
const batch = fn => {
batchDepth++;
try { fn(); } finally { batchDepth--; if (batchDepth === 0) flush(); }
};
const flush = () => {
const effs = [...pendingEffects]; pendingEffects.clear();
for (const e of effs) e._run();
};
// ── Spring Physics ──
const _activeSprings = new Set();
let _rafId = null, _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) { this.target = v; }
get target() { return this._target; }
set target(t) {
this._target = t;
this._settled = false;
_activeSprings.add(this);
_startLoop();
}
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, vel = this._velocity;
const k = this.stiffness, d = this.damping, m = this.mass;
const a = (p, v) => (-k * (p - this._target) - d * v) / m;
const k1v = a(pos, vel), k1p = vel;
const k2v = a(pos + k1p * dt / 2, vel + k1v * dt / 2), k2p = vel + k1v * dt / 2;
const k3v = a(pos + k2p * dt / 2, vel + k2v * dt / 2), k3p = vel + k2v * dt / 2;
const k4v = a(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 _startLoop() {
if (_rafId !== null) return;
_lastTime = performance.now();
_rafId = requestAnimationFrame(_loop);
}
function _loop(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(_loop);
else _rafId = null;
}
function spring(opts) {
return new Spring(typeof opts === 'object' ? opts : { value: opts, target: opts });
}
// ── 2D Scene Engine ──
function scene(width, height) {
const canvas = document.createElement('canvas');
const dpr = window.devicePixelRatio || 1;
canvas.width = (width || 600) * dpr;
canvas.height = (height || 400) * dpr;
canvas.style.width = (width || 600) + 'px';
canvas.style.height = (height || 400) + 'px';
canvas.style.borderRadius = '16px';
canvas.style.background = 'rgba(255,255,255,0.02)';
canvas.style.border = '1px solid rgba(255,255,255,0.06)';
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
const shapes = [];
let _dirty = false;
const w = width || 600, h = height || 400;
function _render() {
ctx.clearRect(0, 0, w, h);
// Draw grid
ctx.strokeStyle = 'rgba(255,255,255,0.03)';
ctx.lineWidth = 1;
for (let x = 0; x < w; x += 40) {
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke();
}
for (let y = 0; y < h; y += 40) {
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke();
}
for (const s of shapes) s._draw(ctx);
}
function _scheduleRender() {
if (!_dirty) {
_dirty = true;
queueMicrotask(() => { _dirty = false; _render(); });
}
}
return { canvas, ctx, shapes, w, h, _render, _scheduleRender };
}
function _readVal(v) {
return v && typeof v === 'object' && 'value' in v ? v.value : (typeof v === 'function' ? v() : v);
}
function circle(scn, opts) {
const shape = {
type: 'circle',
_draw(ctx) {
const x = _readVal(opts.x), y = _readVal(opts.y);
const r = _readVal(opts.r) || 20;
const fill = opts.fill || '#8b5cf6';
// Trail effect
if (opts.trail) {
ctx.beginPath();
ctx.arc(x, y, r + 8, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(139,92,246,0.08)';
ctx.fill();
ctx.beginPath();
ctx.arc(x, y, r + 16, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(139,92,246,0.03)';
ctx.fill();
}
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
const grad = ctx.createRadialGradient(x - r * 0.3, y - r * 0.3, r * 0.1, x, y, r);
grad.addColorStop(0, opts.highlight || '#c4b5fd');
grad.addColorStop(1, fill);
ctx.fillStyle = grad;
ctx.shadowColor = fill;
ctx.shadowBlur = 25;
ctx.fill();
ctx.shadowBlur = 0;
}
};
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.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 = _readVal(opts.x), y = _readVal(opts.y);
const w = _readVal(opts.w) || 40, h = _readVal(opts.h) || 40;
const fill = opts.fill || '#6366f1';
ctx.beginPath();
ctx.roundRect(x, y, w, h, opts.radius || 8);
ctx.fillStyle = fill;
ctx.shadowColor = fill;
ctx.shadowBlur = 15;
ctx.fill();
ctx.shadowBlur = 0;
}
};
scn.shapes.push(shape);
effect(() => {
if (opts.x && typeof opts.x === 'object') opts.x.value;
if (opts.y && typeof opts.y === 'object') opts.y.value;
if (opts.w && typeof opts.w === 'object') opts.w.value;
if (opts.h && typeof opts.h === 'object') opts.h.value;
scn._scheduleRender();
});
return shape;
}
function line(scn, opts) {
const shape = {
type: 'line',
_draw(ctx) {
ctx.beginPath();
ctx.moveTo(_readVal(opts.x1), _readVal(opts.y1));
ctx.lineTo(_readVal(opts.x2), _readVal(opts.y2));
ctx.strokeStyle = opts.stroke || 'rgba(139,92,246,0.2)';
ctx.lineWidth = opts.width || 1;
ctx.setLineDash(opts.dash || []);
ctx.stroke();
ctx.setLineDash([]);
}
};
scn.shapes.push(shape);
effect(() => {
if (opts.x1 && typeof opts.x1 === 'object') opts.x1.value;
if (opts.y1 && typeof opts.y1 === 'object') opts.y1.value;
if (opts.x2 && typeof opts.x2 === 'object') opts.x2.value;
if (opts.y2 && typeof opts.y2 === 'object') opts.y2.value;
scn._scheduleRender();
});
return shape;
}
return { signal, effect, batch, flush, spring, scene, circle, rect, line, Signal, Spring };
})();
// ═══════════════════════════════════════════════════════════
// Spring Ball Demo
// ═══════════════════════════════════════════════════════════
(() => {
const scn = DS.scene(700, 400);
document.getElementById('scene').appendChild(scn.canvas);
// Springs for ball position
const ballX = DS.spring({ value: 350, stiffness: 170, damping: 26 });
const ballY = DS.spring({ value: 200, stiffness: 170, damping: 26 });
const ballR = DS.spring({ value: 25, stiffness: 300, damping: 20 });
// Target marker (non-spring, just a signal)
const targetX = DS.signal(350);
const targetY = DS.signal(200);
// Draw target crosshair
DS.line(scn, { x1: targetX, y1: 0, x2: targetX, y2: 400, stroke: 'rgba(99,102,241,0.08)', dash: [4, 4] });
DS.line(scn, { x1: 0, y1: targetY, x2: 700, y2: targetY, stroke: 'rgba(99,102,241,0.08)', dash: [4, 4] });
// Draw connection line from ball to target
DS.line(scn, { x1: ballX, y1: ballY, x2: targetX, y2: targetY, stroke: 'rgba(139,92,246,0.15)', width: 1, dash: [3, 3] });
// Target dot
DS.circle(scn, { x: targetX, y: targetY, r: 6, fill: 'rgba(99,102,241,0.3)' });
// The ball!
DS.circle(scn, { x: ballX, y: ballY, r: ballR, fill: '#8b5cf6', trail: true });
// Click to move
scn.canvas.addEventListener('click', (e) => {
const rect = scn.canvas.getBoundingClientRect();
const x = (e.clientX - rect.left);
const y = (e.clientY - rect.top);
targetX.value = x;
targetY.value = y;
ballX.value = x; // triggers spring animation
ballY.value = y;
});
// Drag support
let dragging = false;
scn.canvas.addEventListener('mousedown', (e) => {
const rect = scn.canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const bx = ballX._signal._value, by = ballY._signal._value;
if (Math.hypot(x - bx, y - by) < 40) {
dragging = true;
scn.canvas.style.cursor = 'grabbing';
e.preventDefault();
}
});
window.addEventListener('mousemove', (e) => {
if (!dragging) return;
const rect = scn.canvas.getBoundingClientRect();
const x = Math.max(20, Math.min(680, e.clientX - rect.left));
const y = Math.max(20, Math.min(380, e.clientY - rect.top));
ballX.set(x); ballY.set(y); // instant set while dragging
targetX.value = x; targetY.value = y;
});
window.addEventListener('mouseup', () => {
if (!dragging) return;
dragging = false;
scn.canvas.style.cursor = 'pointer';
// Spring back to center
ballX.value = 350;
ballY.value = 200;
targetX.value = 350;
targetY.value = 200;
ballR.value = 25;
});
scn.canvas.style.cursor = 'pointer';
// Info display
const info = document.getElementById('info');
DS.effect(() => {
info.textContent = `ball: (${ballX.value.toFixed(0)}, ${ballY.value.toFixed(0)}) → target: (${targetX.value.toFixed(0)}, ${targetY.value.toFixed(0)}) | r: ${ballR.value.toFixed(0)}`;
});
// Buttons
const controls = document.getElementById('controls');
const presets = [
{ label: '↖ Top-Left', x: 60, y: 60 },
{ label: '↗ Top-Right', x: 640, y: 60 },
{ label: '⊙ Center', x: 350, y: 200 },
{ label: '↙ Bot-Left', x: 60, y: 340 },
{ label: '↘ Bot-Right', x: 640, y: 340 },
{ label: '🎾 Bounce', action: 'bounce' },
{ label: '💥 Explode', action: 'explode' },
];
presets.forEach(p => {
const btn = document.createElement('button');
btn.textContent = p.label;
btn.addEventListener('click', () => {
if (p.action === 'bounce') {
const positions = [
[100, 100], [600, 100], [600, 300], [100, 300]
];
let i = 0;
const interval = setInterval(() => {
const [x, y] = positions[i % positions.length];
targetX.value = x; targetY.value = y;
ballX.value = x; ballY.value = y;
i++;
if (i >= 8) clearInterval(interval);
}, 400);
} else if (p.action === 'explode') {
ballR.value = 80;
setTimeout(() => { ballR.value = 25; }, 300);
} else {
targetX.value = p.x; targetY.value = p.y;
ballX.value = p.x; ballY.value = p.y;
}
});
controls.appendChild(btn);
});
// Spring parameter sliders
const sliders = document.getElementById('sliders');
function makeSlider(label, min, max, initial, onChange) {
const group = document.createElement('div');
group.className = 'slider-group';
const lbl = document.createElement('label');
lbl.textContent = label;
const input = document.createElement('input');
input.type = 'range'; input.min = min; input.max = max; input.value = initial;
const val = document.createElement('span');
val.className = 'val'; val.textContent = initial;
input.addEventListener('input', () => {
val.textContent = input.value;
onChange(Number(input.value));
});
group.appendChild(lbl);
group.appendChild(input);
group.appendChild(val);
sliders.appendChild(group);
}
makeSlider('Stiffness', 10, 500, 170, v => { ballX.stiffness = v; ballY.stiffness = v; });
makeSlider('Damping', 1, 60, 26, v => { ballX.damping = v; ballY.damping = v; });
makeSlider('Mass', 1, 20, 1, v => { ballX.mass = v; ballY.mass = v; });
})();
</script>
</body>
</html>

24
examples/springs.ds Normal file
View file

@ -0,0 +1,24 @@
-- DreamStack Springs + 2D Scene
-- Physics-driven rendering. No CSS for animation.
let ball_x = spring(200)
let ball_y = spring(150)
let sidebar_w = spring(240)
view main = column [
text "Spring Physics"
text ball_x
text ball_y
row [
button "Center" { click: ball_x = 200 }
button "Left" { click: ball_x = 50 }
button "Right" { click: ball_x = 350 }
button "Top" { click: ball_y = 50 }
button "Bottom" { click: ball_y = 300 }
]
text sidebar_w
button "Wide" { click: sidebar_w = 300 }
button "Narrow" { click: sidebar_w = 60 }
]