From 439a775dece30289939f865555783877983ae2ed Mon Sep 17 00:00:00 2001 From: enzotar Date: Wed, 25 Feb 2026 13:26:59 -0800 Subject: [PATCH] =?UTF-8?q?feat(compiler):=20complete=20bitstream=20integr?= =?UTF-8?q?ation=20=E2=80=94=20all=209=20changes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- BITSTREAM_INTEGRATION.md | 4 +- compiler/ds-analyzer/src/signal_graph.rs | 16 + compiler/ds-codegen/src/js_emitter.rs | 215 +++++---- compiler/ds-layout/src/solver.rs | 22 +- compiler/ds-parser/src/parser.rs | 36 +- examples/springs-visual.html | 530 +++++++++++++++++++++++ examples/springs.ds | 24 + 7 files changed, 753 insertions(+), 94 deletions(-) create mode 100644 examples/springs-visual.html create mode 100644 examples/springs.ds diff --git a/BITSTREAM_INTEGRATION.md b/BITSTREAM_INTEGRATION.md index be5c983..807c52f 100644 --- a/BITSTREAM_INTEGRATION.md +++ b/BITSTREAM_INTEGRATION.md @@ -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 diff --git a/compiler/ds-analyzer/src/signal_graph.rs b/compiler/ds-analyzer/src/signal_graph.rs index dabf0d5..57fd357 100644 --- a/compiler/ds-analyzer/src/signal_graph.rs +++ b/compiler/ds-analyzer/src/signal_graph.rs @@ -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"); + } } diff --git a/compiler/ds-codegen/src/js_emitter.rs b/compiler/ds-codegen/src/js_emitter.rs index 366ccda..90f79d5 100644 --- a/compiler/ds-codegen/src/js_emitter.rs +++ b/compiler/ds-codegen/src/js_emitter.rs @@ -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 = 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 = 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; })(); "#; diff --git a/compiler/ds-layout/src/solver.rs b/compiler/ds-layout/src/solver.rs index 4b429b5..c524773 100644 --- a/compiler/ds-layout/src/solver.rs +++ b/compiler/ds-layout/src/solver.rs @@ -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. /// diff --git a/compiler/ds-parser/src/parser.rs b/compiler/ds-parser/src/parser.rs index 219f9e2..80ca411 100644 --- a/compiler/ds-parser/src/parser.rs +++ b/compiler/ds-parser/src/parser.rs @@ -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:?}"), + } + } } diff --git a/examples/springs-visual.html b/examples/springs-visual.html new file mode 100644 index 0000000..d45badd --- /dev/null +++ b/examples/springs-visual.html @@ -0,0 +1,530 @@ + + + + + + + DreamStack — Spring Physics + 2D Scene + + + + + +

Spring Physics + 2D Scene

+

Springs drive positions. Canvas renders frames. No CSS animations.

+ +
+
+
+
+
Built with DreamStack — springs + signals + 2D scene
+ + + + + \ No newline at end of file diff --git a/examples/springs.ds b/examples/springs.ds new file mode 100644 index 0000000..4a74a2d --- /dev/null +++ b/examples/springs.ds @@ -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 } +]