Skip to main content

ftui_runtime/
wasm_runner.rs

1#![forbid(unsafe_code)]
2
3//! Step-based program runner for WASM targets.
4//!
5//! [`WasmRunner`] drives an ftui [`Model`] without threads, blocking polls, or
6//! OS-level I/O. Instead, the host (JavaScript) delivers events via
7//! [`push_event`] and calls [`step`] / [`render`] from its own animation loop.
8//!
9//! The execution model is:
10//! ```text
11//! JS animation frame
12//!   → push_event(Event)        // keyboard, mouse, resize
13//!   → step(now)                // drain events, fire ticks, run model.update
14//!   → render()                 // if dirty: model.view → Buffer + optional Diff
15//!   → present the output       // apply patches to WebGPU / canvas
16//! ```
17//!
18//! Deterministic record/replay: all inputs go through the event queue with
19//! monotonic timestamps from the host clock (`performance.now()`), so replaying
20//! the same event stream produces identical frames.
21
22use crate::program::{Cmd, Model};
23use ftui_core::event::Event;
24use ftui_render::buffer::Buffer;
25use ftui_render::diff::BufferDiff;
26use ftui_render::frame::Frame;
27use ftui_render::grapheme_pool::GraphemePool;
28use std::collections::VecDeque;
29use std::time::Duration;
30
31// ---------------------------------------------------------------------------
32// Public types
33// ---------------------------------------------------------------------------
34
35/// Outcome of a single [`WasmRunner::step`] call.
36#[derive(Debug, Clone, Copy, Default)]
37pub struct StepResult {
38    /// Number of queued events processed in this step.
39    pub events_processed: u32,
40    /// Whether a tick was delivered to the model.
41    pub tick_fired: bool,
42    /// Whether the model's view is dirty (render needed).
43    pub dirty: bool,
44    /// Whether the model issued `Cmd::Quit`.
45    pub quit: bool,
46}
47
48/// Rendered frame output from [`WasmRunner::render`].
49pub struct RenderedFrame<'a> {
50    /// The full rendered buffer.
51    pub buffer: &'a Buffer,
52    /// Diff against the previous frame (`None` on first render or after resize).
53    pub diff: Option<BufferDiff>,
54    /// Sequential frame index (starts at 0).
55    pub frame_idx: u64,
56}
57
58// ---------------------------------------------------------------------------
59// WasmRunner
60// ---------------------------------------------------------------------------
61
62/// Step-based program runner for WASM (no threads, no blocking).
63///
64/// Accepts an ftui [`Model`] and drives it through explicit `step` / `render`
65/// calls controlled by the JavaScript host.
66pub struct WasmRunner<M: Model> {
67    model: M,
68    pool: GraphemePool,
69
70    /// Current rendered buffer.
71    current: Buffer,
72    /// Previous buffer for diffing.
73    previous: Option<Buffer>,
74
75    running: bool,
76    dirty: bool,
77    initialized: bool,
78
79    width: u16,
80    height: u16,
81    frame_idx: u64,
82
83    /// Tick interval requested by the model (via `Cmd::Tick`).
84    tick_rate: Option<Duration>,
85    /// Monotonic timestamp of the last tick delivery.
86    last_tick_at: Duration,
87
88    /// Buffered events from the host.
89    event_queue: VecDeque<Event>,
90
91    /// Log messages emitted via `Cmd::Log`.
92    logs: Vec<String>,
93}
94
95impl<M: Model> WasmRunner<M> {
96    /// Create a new runner with the given model and initial grid size.
97    ///
98    /// The model is not initialized until [`init`](Self::init) is called.
99    #[must_use]
100    pub fn new(model: M, width: u16, height: u16) -> Self {
101        Self {
102            model,
103            pool: GraphemePool::new(),
104            current: Buffer::new(width, height),
105            previous: None,
106            running: true,
107            dirty: true, // First frame is always dirty.
108            initialized: false,
109            width,
110            height,
111            frame_idx: 0,
112            tick_rate: None,
113            last_tick_at: Duration::ZERO,
114            event_queue: VecDeque::new(),
115            logs: Vec::new(),
116        }
117    }
118
119    /// Initialize the model by calling `Model::init()`.
120    ///
121    /// Must be called exactly once before `step` / `render`. Returns the
122    /// result of executing the init command.
123    pub fn init(&mut self) -> StepResult {
124        let cmd = self.model.init();
125        self.initialized = true;
126        self.dirty = true;
127        let mut result = StepResult {
128            dirty: true,
129            ..Default::default()
130        };
131        self.execute_cmd(cmd, &mut result);
132        result
133    }
134
135    // -- Event delivery -----------------------------------------------------
136
137    /// Buffer a single event for processing on the next `step`.
138    pub fn push_event(&mut self, event: Event) {
139        self.event_queue.push_back(event);
140    }
141
142    /// Buffer multiple events for processing on the next `step`.
143    pub fn push_events(&mut self, events: impl IntoIterator<Item = Event>) {
144        self.event_queue.extend(events);
145    }
146
147    // -- Step ---------------------------------------------------------------
148
149    /// Process all buffered events and fire a tick if due.
150    ///
151    /// `now` is the monotonic timestamp from the host clock (e.g.
152    /// `performance.now()` converted to `Duration`).
153    ///
154    /// Returns a [`StepResult`] summarizing what happened.
155    pub fn step(&mut self, now: Duration) -> StepResult {
156        if !self.running || !self.initialized {
157            return StepResult {
158                quit: !self.running,
159                ..Default::default()
160            };
161        }
162
163        let mut result = StepResult::default();
164
165        // Drain all buffered events.
166        while let Some(event) = self.event_queue.pop_front() {
167            if !self.running {
168                break;
169            }
170            self.handle_event(event, &mut result);
171            result.events_processed += 1;
172        }
173
174        // Tick check.
175        if let Some(rate) = self.tick_rate
176            && now.saturating_sub(self.last_tick_at) >= rate
177        {
178            self.last_tick_at = now;
179            let msg = M::Message::from(Event::Tick);
180            let cmd = self.model.update(msg);
181            self.dirty = true;
182            result.tick_fired = true;
183            self.execute_cmd(cmd, &mut result);
184        }
185
186        result.dirty = self.dirty;
187        result.quit = !self.running;
188        result
189    }
190
191    /// Process a single event immediately (without buffering).
192    pub fn step_event(&mut self, event: Event) -> StepResult {
193        if !self.running || !self.initialized {
194            return StepResult {
195                quit: !self.running,
196                ..Default::default()
197            };
198        }
199
200        let mut result = StepResult::default();
201        self.handle_event(event, &mut result);
202        result.events_processed = 1;
203        result.dirty = self.dirty;
204        result.quit = !self.running;
205        result
206    }
207
208    // -- Render -------------------------------------------------------------
209
210    /// Render the current frame if dirty.
211    ///
212    /// Returns `Some(RenderedFrame)` with the buffer and optional diff, or
213    /// `None` if the view is clean (no events since last render).
214    pub fn render(&mut self) -> Option<RenderedFrame<'_>> {
215        if !self.dirty {
216            return None;
217        }
218        Some(self.force_render())
219    }
220
221    /// Render the current frame unconditionally.
222    pub fn force_render(&mut self) -> RenderedFrame<'_> {
223        let mut frame = Frame::new(self.width, self.height, &mut self.pool);
224        self.model.view(&mut frame);
225
226        // Compute diff against previous buffer.
227        let diff = self
228            .previous
229            .as_ref()
230            .map(|prev| BufferDiff::compute(prev, &frame.buffer));
231
232        // Rotate buffers.
233        self.previous = Some(std::mem::replace(&mut self.current, frame.buffer));
234
235        self.dirty = false;
236        let idx = self.frame_idx;
237        self.frame_idx += 1;
238
239        RenderedFrame {
240            buffer: &self.current,
241            diff,
242            frame_idx: idx,
243        }
244    }
245
246    // -- Resize -------------------------------------------------------------
247
248    /// Resize the grid. Marks the view dirty and invalidates the diff baseline.
249    pub fn resize(&mut self, width: u16, height: u16) {
250        if width == self.width && height == self.height {
251            return;
252        }
253        self.width = width;
254        self.height = height;
255        self.current = Buffer::new(width, height);
256        self.previous = None; // Force full repaint.
257        self.dirty = true;
258
259        // Deliver resize event to the model.
260        if self.running && self.initialized {
261            let msg = M::Message::from(Event::Resize { width, height });
262            let cmd = self.model.update(msg);
263            let mut result = StepResult::default();
264            self.execute_cmd(cmd, &mut result);
265        }
266    }
267
268    // -- Accessors ----------------------------------------------------------
269
270    /// Whether the program is still running (no `Cmd::Quit` received).
271    #[must_use]
272    pub fn is_running(&self) -> bool {
273        self.running
274    }
275
276    /// Whether the view needs rendering.
277    #[must_use]
278    pub fn is_dirty(&self) -> bool {
279        self.dirty
280    }
281
282    /// Whether `init()` has been called.
283    #[must_use]
284    pub fn is_initialized(&self) -> bool {
285        self.initialized
286    }
287
288    /// Current grid dimensions.
289    #[must_use]
290    pub fn size(&self) -> (u16, u16) {
291        (self.width, self.height)
292    }
293
294    /// Sequential frame index (incremented on each render).
295    #[must_use]
296    pub fn frame_idx(&self) -> u64 {
297        self.frame_idx
298    }
299
300    /// Current tick rate, if set by the model.
301    #[must_use]
302    pub fn tick_rate(&self) -> Option<Duration> {
303        self.tick_rate
304    }
305
306    /// Number of buffered events awaiting processing.
307    #[must_use]
308    pub fn pending_events(&self) -> usize {
309        self.event_queue.len()
310    }
311
312    /// Reference to the model.
313    #[inline]
314    #[must_use]
315    pub fn model(&self) -> &M {
316        &self.model
317    }
318
319    /// Mutable reference to the model.
320    #[inline]
321    pub fn model_mut(&mut self) -> &mut M {
322        &mut self.model
323    }
324
325    /// Drain and return accumulated log messages.
326    #[inline]
327    pub fn drain_logs(&mut self) -> Vec<String> {
328        std::mem::take(&mut self.logs)
329    }
330
331    /// Reference to accumulated log messages.
332    #[inline]
333    #[must_use]
334    pub fn logs(&self) -> &[String] {
335        &self.logs
336    }
337
338    /// Reference to the most recently rendered buffer.
339    #[inline]
340    #[must_use]
341    pub fn current_buffer(&self) -> &Buffer {
342        &self.current
343    }
344
345    // -- Internal -----------------------------------------------------------
346
347    fn handle_event(&mut self, event: Event, result: &mut StepResult) {
348        // Handle resize events specially: update our dimensions.
349        if let Event::Resize { width, height } = event
350            && (width != self.width || height != self.height)
351        {
352            self.width = width;
353            self.height = height;
354            self.current = Buffer::new(width, height);
355            self.previous = None;
356        }
357
358        let msg = M::Message::from(event);
359        let cmd = self.model.update(msg);
360        self.dirty = true;
361        self.execute_cmd(cmd, result);
362    }
363
364    fn execute_cmd(&mut self, cmd: Cmd<M::Message>, result: &mut StepResult) {
365        match cmd {
366            Cmd::None => {}
367            Cmd::Quit => {
368                self.running = false;
369                result.quit = true;
370            }
371            Cmd::Msg(m) => {
372                let cmd = self.model.update(m);
373                self.execute_cmd(cmd, result);
374            }
375            Cmd::Batch(cmds) => {
376                for c in cmds {
377                    if !self.running {
378                        break;
379                    }
380                    self.execute_cmd(c, result);
381                }
382            }
383            Cmd::Sequence(cmds) => {
384                for c in cmds {
385                    if !self.running {
386                        break;
387                    }
388                    self.execute_cmd(c, result);
389                }
390            }
391            Cmd::Tick(duration) => {
392                self.tick_rate = Some(duration);
393            }
394            Cmd::Log(text) => {
395                self.logs.push(text);
396            }
397            Cmd::Task(_, f) => {
398                // Execute synchronously (no threads in WASM).
399                let msg = f();
400                let cmd = self.model.update(msg);
401                self.execute_cmd(cmd, result);
402            }
403            Cmd::SetMouseCapture(_) => {
404                // No-op: mouse capture is managed by the JS host.
405            }
406            Cmd::SaveState | Cmd::RestoreState => {
407                // No-op: state persistence is managed by the JS host
408                // (localStorage / IndexedDB).
409            }
410        }
411    }
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417    use ftui_core::event::{KeyCode, KeyEvent, KeyEventKind, Modifiers};
418    use ftui_render::cell::Cell;
419
420    // -- Test model ---------------------------------------------------------
421
422    struct Counter {
423        value: i32,
424    }
425
426    #[derive(Debug)]
427    #[allow(dead_code)]
428    enum CounterMsg {
429        Increment,
430        Decrement,
431        Quit,
432        ScheduleTick,
433        LogValue,
434    }
435
436    impl From<Event> for CounterMsg {
437        fn from(event: Event) -> Self {
438            match event {
439                Event::Key(k) if k.code == KeyCode::Char('+') => CounterMsg::Increment,
440                Event::Key(k) if k.code == KeyCode::Char('-') => CounterMsg::Decrement,
441                Event::Key(k) if k.code == KeyCode::Char('q') => CounterMsg::Quit,
442                _ => CounterMsg::Increment,
443            }
444        }
445    }
446
447    impl Model for Counter {
448        type Message = CounterMsg;
449
450        fn init(&mut self) -> Cmd<Self::Message> {
451            Cmd::none()
452        }
453
454        fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
455            match msg {
456                CounterMsg::Increment => {
457                    self.value += 1;
458                    Cmd::none()
459                }
460                CounterMsg::Decrement => {
461                    self.value -= 1;
462                    Cmd::none()
463                }
464                CounterMsg::Quit => Cmd::quit(),
465                CounterMsg::ScheduleTick => Cmd::tick(Duration::from_millis(100)),
466                CounterMsg::LogValue => Cmd::log(format!("value={}", self.value)),
467            }
468        }
469
470        fn view(&self, frame: &mut Frame) {
471            let text = format!("Count: {}", self.value);
472            for (i, c) in text.chars().enumerate() {
473                if (i as u16) < frame.width() {
474                    frame.buffer.set_raw(i as u16, 0, Cell::from_char(c));
475                }
476            }
477        }
478    }
479
480    fn key_event(c: char) -> Event {
481        Event::Key(KeyEvent {
482            code: KeyCode::Char(c),
483            modifiers: Modifiers::empty(),
484            kind: KeyEventKind::Press,
485        })
486    }
487
488    // -- Tests --------------------------------------------------------------
489
490    #[test]
491    fn init_marks_dirty() {
492        let mut runner = WasmRunner::new(Counter { value: 0 }, 80, 24);
493        let result = runner.init();
494        assert!(result.dirty);
495        assert!(runner.is_initialized());
496    }
497
498    #[test]
499    fn step_event_updates_model() {
500        let mut runner = WasmRunner::new(Counter { value: 0 }, 80, 24);
501        runner.init();
502
503        let r = runner.step_event(key_event('+'));
504        assert_eq!(runner.model().value, 1);
505        assert!(r.dirty);
506        assert_eq!(r.events_processed, 1);
507    }
508
509    #[test]
510    fn buffered_events_drain_on_step() {
511        let mut runner = WasmRunner::new(Counter { value: 0 }, 80, 24);
512        runner.init();
513
514        runner.push_event(key_event('+'));
515        runner.push_event(key_event('+'));
516        runner.push_event(key_event('+'));
517
518        let r = runner.step(Duration::ZERO);
519        assert_eq!(r.events_processed, 3);
520        assert_eq!(runner.model().value, 3);
521        assert_eq!(runner.pending_events(), 0);
522    }
523
524    #[test]
525    fn quit_stops_processing() {
526        let mut runner = WasmRunner::new(Counter { value: 0 }, 80, 24);
527        runner.init();
528
529        runner.push_event(key_event('+'));
530        runner.push_event(key_event('q'));
531        runner.push_event(key_event('+'));
532
533        let r = runner.step(Duration::ZERO);
534        assert!(r.quit);
535        assert!(!runner.is_running());
536        assert_eq!(runner.model().value, 1);
537    }
538
539    #[test]
540    fn render_produces_buffer() {
541        let mut runner = WasmRunner::new(Counter { value: 42 }, 80, 24);
542        runner.init();
543
544        let frame = runner.render().expect("should be dirty after init");
545        assert_eq!(frame.frame_idx, 0);
546        // First render has no diff (no previous buffer).
547        assert!(frame.diff.is_none());
548        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('C'));
549    }
550
551    #[test]
552    fn render_returns_none_when_clean() {
553        let mut runner = WasmRunner::new(Counter { value: 0 }, 80, 24);
554        runner.init();
555
556        runner.render(); // Consume dirty.
557        assert!(runner.render().is_none());
558    }
559
560    #[test]
561    fn second_render_has_diff() {
562        let mut runner = WasmRunner::new(Counter { value: 0 }, 80, 24);
563        runner.init();
564
565        runner.render(); // First frame, no diff.
566        runner.step_event(key_event('+'));
567
568        let frame = runner.render().expect("dirty after event");
569        assert_eq!(frame.frame_idx, 1);
570        // Should have a diff since we have a previous buffer.
571        assert!(frame.diff.is_some());
572    }
573
574    #[test]
575    fn resize_invalidates_diff_baseline() {
576        let mut runner = WasmRunner::new(Counter { value: 0 }, 80, 24);
577        runner.init();
578        runner.render();
579
580        runner.resize(100, 40);
581        assert!(runner.is_dirty());
582        assert_eq!(runner.size(), (100, 40));
583
584        let frame = runner.render().expect("dirty after resize");
585        // No diff after resize (baseline invalidated).
586        assert!(frame.diff.is_none());
587    }
588
589    #[test]
590    fn tick_fires_when_due() {
591        let mut runner = WasmRunner::new(Counter { value: 0 }, 80, 24);
592        runner.init();
593        runner.render();
594
595        // Schedule tick at 100ms.
596        runner.step_event(Event::Key(KeyEvent {
597            code: KeyCode::Char('t'),
598            modifiers: Modifiers::empty(),
599            kind: KeyEventKind::Press,
600        }));
601        // 't' maps to Increment (default), so override:
602        // We need a model that emits Cmd::Tick. Let's just set tick_rate directly.
603
604        // Actually, let's test by sending a message.
605        runner.model_mut().value = 0;
606        // Force a tick rate.
607        let cmd: Cmd<CounterMsg> = Cmd::tick(Duration::from_millis(100));
608        let mut result = StepResult::default();
609        runner.execute_cmd(cmd, &mut result);
610
611        // Step at t=50ms: no tick.
612        let r = runner.step(Duration::from_millis(50));
613        assert!(!r.tick_fired);
614
615        // Step at t=100ms: tick fires.
616        let r = runner.step(Duration::from_millis(100));
617        assert!(r.tick_fired);
618    }
619
620    #[test]
621    fn logs_accumulate() {
622        let mut runner = WasmRunner::new(Counter { value: 5 }, 80, 24);
623        runner.init();
624
625        runner.step_event(key_event('+'));
626        let cmd: Cmd<CounterMsg> = Cmd::log("hello");
627        let mut result = StepResult::default();
628        runner.execute_cmd(cmd, &mut result);
629
630        assert_eq!(runner.logs(), &["hello"]);
631
632        let drained = runner.drain_logs();
633        assert_eq!(drained, &["hello"]);
634        assert!(runner.logs().is_empty());
635    }
636
637    #[test]
638    fn deterministic_replay() {
639        fn run_scenario() -> Vec<Option<char>> {
640            let mut runner = WasmRunner::new(Counter { value: 0 }, 20, 1);
641            runner.init();
642
643            runner.push_event(key_event('+'));
644            runner.push_event(key_event('+'));
645            runner.push_event(key_event('-'));
646            runner.push_event(key_event('+'));
647            runner.step(Duration::ZERO);
648
649            let frame = runner.render().unwrap();
650            (0..20)
651                .map(|x| frame.buffer.get(x, 0).and_then(|c| c.content.as_char()))
652                .collect()
653        }
654
655        let r1 = run_scenario();
656        let r2 = run_scenario();
657        let r3 = run_scenario();
658        assert_eq!(r1, r2);
659        assert_eq!(r2, r3);
660    }
661
662    #[test]
663    fn events_after_quit_ignored() {
664        let mut runner = WasmRunner::new(Counter { value: 0 }, 80, 24);
665        runner.init();
666
667        runner.step_event(key_event('q'));
668        assert!(!runner.is_running());
669
670        let r = runner.step_event(key_event('+'));
671        assert_eq!(r.events_processed, 0);
672        assert_eq!(runner.model().value, 0);
673    }
674
675    #[test]
676    fn step_before_init_is_noop() {
677        let mut runner = WasmRunner::new(Counter { value: 0 }, 80, 24);
678        let r = runner.step(Duration::ZERO);
679        assert_eq!(r.events_processed, 0);
680        assert!(!runner.is_initialized());
681    }
682
683    #[test]
684    fn task_executes_synchronously() {
685        struct TaskModel {
686            result: Option<i32>,
687        }
688
689        #[derive(Debug)]
690        enum TaskMsg {
691            SpawnTask,
692            SetResult(i32),
693        }
694
695        impl From<Event> for TaskMsg {
696            fn from(_: Event) -> Self {
697                TaskMsg::SpawnTask
698            }
699        }
700
701        impl Model for TaskModel {
702            type Message = TaskMsg;
703
704            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
705                match msg {
706                    TaskMsg::SpawnTask => Cmd::task(|| TaskMsg::SetResult(42)),
707                    TaskMsg::SetResult(v) => {
708                        self.result = Some(v);
709                        Cmd::none()
710                    }
711                }
712            }
713
714            fn view(&self, _frame: &mut Frame) {}
715        }
716
717        let mut runner = WasmRunner::new(TaskModel { result: None }, 80, 24);
718        runner.init();
719
720        // Any key event maps to SpawnTask.
721        runner.step_event(key_event('x'));
722        assert_eq!(runner.model().result, Some(42));
723    }
724
725    #[test]
726    fn resize_delivers_event_to_model() {
727        struct SizeModel {
728            last_size: Option<(u16, u16)>,
729        }
730
731        #[derive(Debug)]
732        enum SizeMsg {
733            Resize(u16, u16),
734            Other,
735        }
736
737        impl From<Event> for SizeMsg {
738            fn from(event: Event) -> Self {
739                match event {
740                    Event::Resize { width, height } => SizeMsg::Resize(width, height),
741                    _ => SizeMsg::Other,
742                }
743            }
744        }
745
746        impl Model for SizeModel {
747            type Message = SizeMsg;
748
749            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
750                if let SizeMsg::Resize(w, h) = msg {
751                    self.last_size = Some((w, h));
752                }
753                Cmd::none()
754            }
755
756            fn view(&self, _frame: &mut Frame) {}
757        }
758
759        let mut runner = WasmRunner::new(SizeModel { last_size: None }, 80, 24);
760        runner.init();
761        runner.resize(120, 40);
762        assert_eq!(runner.model().last_size, Some((120, 40)));
763    }
764
765    #[test]
766    fn force_render_always_produces_frame() {
767        let mut runner = WasmRunner::new(Counter { value: 0 }, 80, 24);
768        runner.init();
769
770        runner.render(); // Consume dirty.
771        assert!(!runner.is_dirty());
772
773        let frame = runner.force_render();
774        assert_eq!(frame.frame_idx, 1);
775    }
776}