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            Cmd::SetTickStrategy(_) => {
411                // No-op: tick strategy is managed by the host runtime.
412            }
413        }
414    }
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420    use ftui_core::event::{KeyCode, KeyEvent, KeyEventKind, Modifiers};
421    use ftui_render::cell::Cell;
422
423    // -- Test model ---------------------------------------------------------
424
425    struct Counter {
426        value: i32,
427    }
428
429    #[derive(Debug)]
430    #[allow(dead_code)]
431    enum CounterMsg {
432        Increment,
433        Decrement,
434        Quit,
435        ScheduleTick,
436        LogValue,
437    }
438
439    impl From<Event> for CounterMsg {
440        fn from(event: Event) -> Self {
441            match event {
442                Event::Key(k) if k.code == KeyCode::Char('+') => CounterMsg::Increment,
443                Event::Key(k) if k.code == KeyCode::Char('-') => CounterMsg::Decrement,
444                Event::Key(k) if k.code == KeyCode::Char('q') => CounterMsg::Quit,
445                _ => CounterMsg::Increment,
446            }
447        }
448    }
449
450    impl Model for Counter {
451        type Message = CounterMsg;
452
453        fn init(&mut self) -> Cmd<Self::Message> {
454            Cmd::none()
455        }
456
457        fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
458            match msg {
459                CounterMsg::Increment => {
460                    self.value += 1;
461                    Cmd::none()
462                }
463                CounterMsg::Decrement => {
464                    self.value -= 1;
465                    Cmd::none()
466                }
467                CounterMsg::Quit => Cmd::quit(),
468                CounterMsg::ScheduleTick => Cmd::tick(Duration::from_millis(100)),
469                CounterMsg::LogValue => Cmd::log(format!("value={}", self.value)),
470            }
471        }
472
473        fn view(&self, frame: &mut Frame) {
474            let text = format!("Count: {}", self.value);
475            for (i, c) in text.chars().enumerate() {
476                if (i as u16) < frame.width() {
477                    frame.buffer.set_raw(i as u16, 0, Cell::from_char(c));
478                }
479            }
480        }
481    }
482
483    fn key_event(c: char) -> Event {
484        Event::Key(KeyEvent {
485            code: KeyCode::Char(c),
486            modifiers: Modifiers::empty(),
487            kind: KeyEventKind::Press,
488        })
489    }
490
491    // -- Tests --------------------------------------------------------------
492
493    #[test]
494    fn init_marks_dirty() {
495        let mut runner = WasmRunner::new(Counter { value: 0 }, 80, 24);
496        let result = runner.init();
497        assert!(result.dirty);
498        assert!(runner.is_initialized());
499    }
500
501    #[test]
502    fn step_event_updates_model() {
503        let mut runner = WasmRunner::new(Counter { value: 0 }, 80, 24);
504        runner.init();
505
506        let r = runner.step_event(key_event('+'));
507        assert_eq!(runner.model().value, 1);
508        assert!(r.dirty);
509        assert_eq!(r.events_processed, 1);
510    }
511
512    #[test]
513    fn buffered_events_drain_on_step() {
514        let mut runner = WasmRunner::new(Counter { value: 0 }, 80, 24);
515        runner.init();
516
517        runner.push_event(key_event('+'));
518        runner.push_event(key_event('+'));
519        runner.push_event(key_event('+'));
520
521        let r = runner.step(Duration::ZERO);
522        assert_eq!(r.events_processed, 3);
523        assert_eq!(runner.model().value, 3);
524        assert_eq!(runner.pending_events(), 0);
525    }
526
527    #[test]
528    fn quit_stops_processing() {
529        let mut runner = WasmRunner::new(Counter { value: 0 }, 80, 24);
530        runner.init();
531
532        runner.push_event(key_event('+'));
533        runner.push_event(key_event('q'));
534        runner.push_event(key_event('+'));
535
536        let r = runner.step(Duration::ZERO);
537        assert!(r.quit);
538        assert!(!runner.is_running());
539        assert_eq!(runner.model().value, 1);
540    }
541
542    #[test]
543    fn render_produces_buffer() {
544        let mut runner = WasmRunner::new(Counter { value: 42 }, 80, 24);
545        runner.init();
546
547        let frame = runner.render().expect("should be dirty after init");
548        assert_eq!(frame.frame_idx, 0);
549        // First render has no diff (no previous buffer).
550        assert!(frame.diff.is_none());
551        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('C'));
552    }
553
554    #[test]
555    fn render_returns_none_when_clean() {
556        let mut runner = WasmRunner::new(Counter { value: 0 }, 80, 24);
557        runner.init();
558
559        runner.render(); // Consume dirty.
560        assert!(runner.render().is_none());
561    }
562
563    #[test]
564    fn second_render_has_diff() {
565        let mut runner = WasmRunner::new(Counter { value: 0 }, 80, 24);
566        runner.init();
567
568        runner.render(); // First frame, no diff.
569        runner.step_event(key_event('+'));
570
571        let frame = runner.render().expect("dirty after event");
572        assert_eq!(frame.frame_idx, 1);
573        // Should have a diff since we have a previous buffer.
574        assert!(frame.diff.is_some());
575    }
576
577    #[test]
578    fn resize_invalidates_diff_baseline() {
579        let mut runner = WasmRunner::new(Counter { value: 0 }, 80, 24);
580        runner.init();
581        runner.render();
582
583        runner.resize(100, 40);
584        assert!(runner.is_dirty());
585        assert_eq!(runner.size(), (100, 40));
586
587        let frame = runner.render().expect("dirty after resize");
588        // No diff after resize (baseline invalidated).
589        assert!(frame.diff.is_none());
590    }
591
592    #[test]
593    fn tick_fires_when_due() {
594        let mut runner = WasmRunner::new(Counter { value: 0 }, 80, 24);
595        runner.init();
596        runner.render();
597
598        // Schedule tick at 100ms.
599        runner.step_event(Event::Key(KeyEvent {
600            code: KeyCode::Char('t'),
601            modifiers: Modifiers::empty(),
602            kind: KeyEventKind::Press,
603        }));
604        // 't' maps to Increment (default), so override:
605        // We need a model that emits Cmd::Tick. Let's just set tick_rate directly.
606
607        // Actually, let's test by sending a message.
608        runner.model_mut().value = 0;
609        // Force a tick rate.
610        let cmd: Cmd<CounterMsg> = Cmd::tick(Duration::from_millis(100));
611        let mut result = StepResult::default();
612        runner.execute_cmd(cmd, &mut result);
613
614        // Step at t=50ms: no tick.
615        let r = runner.step(Duration::from_millis(50));
616        assert!(!r.tick_fired);
617
618        // Step at t=100ms: tick fires.
619        let r = runner.step(Duration::from_millis(100));
620        assert!(r.tick_fired);
621    }
622
623    #[test]
624    fn logs_accumulate() {
625        let mut runner = WasmRunner::new(Counter { value: 5 }, 80, 24);
626        runner.init();
627
628        runner.step_event(key_event('+'));
629        let cmd: Cmd<CounterMsg> = Cmd::log("hello");
630        let mut result = StepResult::default();
631        runner.execute_cmd(cmd, &mut result);
632
633        assert_eq!(runner.logs(), &["hello"]);
634
635        let drained = runner.drain_logs();
636        assert_eq!(drained, &["hello"]);
637        assert!(runner.logs().is_empty());
638    }
639
640    #[test]
641    fn deterministic_replay() {
642        fn run_scenario() -> Vec<Option<char>> {
643            let mut runner = WasmRunner::new(Counter { value: 0 }, 20, 1);
644            runner.init();
645
646            runner.push_event(key_event('+'));
647            runner.push_event(key_event('+'));
648            runner.push_event(key_event('-'));
649            runner.push_event(key_event('+'));
650            runner.step(Duration::ZERO);
651
652            let frame = runner.render().unwrap();
653            (0..20)
654                .map(|x| frame.buffer.get(x, 0).and_then(|c| c.content.as_char()))
655                .collect()
656        }
657
658        let r1 = run_scenario();
659        let r2 = run_scenario();
660        let r3 = run_scenario();
661        assert_eq!(r1, r2);
662        assert_eq!(r2, r3);
663    }
664
665    #[test]
666    fn events_after_quit_ignored() {
667        let mut runner = WasmRunner::new(Counter { value: 0 }, 80, 24);
668        runner.init();
669
670        runner.step_event(key_event('q'));
671        assert!(!runner.is_running());
672
673        let r = runner.step_event(key_event('+'));
674        assert_eq!(r.events_processed, 0);
675        assert_eq!(runner.model().value, 0);
676    }
677
678    #[test]
679    fn step_before_init_is_noop() {
680        let mut runner = WasmRunner::new(Counter { value: 0 }, 80, 24);
681        let r = runner.step(Duration::ZERO);
682        assert_eq!(r.events_processed, 0);
683        assert!(!runner.is_initialized());
684    }
685
686    #[test]
687    fn task_executes_synchronously() {
688        struct TaskModel {
689            result: Option<i32>,
690        }
691
692        #[derive(Debug)]
693        enum TaskMsg {
694            SpawnTask,
695            SetResult(i32),
696        }
697
698        impl From<Event> for TaskMsg {
699            fn from(_: Event) -> Self {
700                TaskMsg::SpawnTask
701            }
702        }
703
704        impl Model for TaskModel {
705            type Message = TaskMsg;
706
707            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
708                match msg {
709                    TaskMsg::SpawnTask => Cmd::task(|| TaskMsg::SetResult(42)),
710                    TaskMsg::SetResult(v) => {
711                        self.result = Some(v);
712                        Cmd::none()
713                    }
714                }
715            }
716
717            fn view(&self, _frame: &mut Frame) {}
718        }
719
720        let mut runner = WasmRunner::new(TaskModel { result: None }, 80, 24);
721        runner.init();
722
723        // Any key event maps to SpawnTask.
724        runner.step_event(key_event('x'));
725        assert_eq!(runner.model().result, Some(42));
726    }
727
728    #[test]
729    fn resize_delivers_event_to_model() {
730        struct SizeModel {
731            last_size: Option<(u16, u16)>,
732        }
733
734        #[derive(Debug)]
735        enum SizeMsg {
736            Resize(u16, u16),
737            Other,
738        }
739
740        impl From<Event> for SizeMsg {
741            fn from(event: Event) -> Self {
742                match event {
743                    Event::Resize { width, height } => SizeMsg::Resize(width, height),
744                    _ => SizeMsg::Other,
745                }
746            }
747        }
748
749        impl Model for SizeModel {
750            type Message = SizeMsg;
751
752            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
753                if let SizeMsg::Resize(w, h) = msg {
754                    self.last_size = Some((w, h));
755                }
756                Cmd::none()
757            }
758
759            fn view(&self, _frame: &mut Frame) {}
760        }
761
762        let mut runner = WasmRunner::new(SizeModel { last_size: None }, 80, 24);
763        runner.init();
764        runner.resize(120, 40);
765        assert_eq!(runner.model().last_size, Some((120, 40)));
766    }
767
768    #[test]
769    fn force_render_always_produces_frame() {
770        let mut runner = WasmRunner::new(Counter { value: 0 }, 80, 24);
771        runner.init();
772
773        runner.render(); // Consume dirty.
774        assert!(!runner.is_dirty());
775
776        let frame = runner.force_render();
777        assert_eq!(frame.frame_idx, 1);
778    }
779}