Skip to main content

ftui_web/
lib.rs

1#![forbid(unsafe_code)]
2
3//! `ftui-web` provides a WASM-friendly backend implementation for FrankenTUI.
4//!
5//! Design goals:
6//! - **Host-driven I/O**: the embedding environment (JS) pushes input events and size changes.
7//! - **Deterministic time**: the host advances a monotonic clock explicitly.
8//! - **No blocking / no threads**: suitable for `wasm32-unknown-unknown`.
9//!
10//! This crate intentionally does not bind to `wasm-bindgen` yet. The primary
11//! purpose is to provide backend building blocks that `frankenterm-web` can
12//! wrap with a stable JS API.
13
14#[cfg(feature = "input-parser")]
15pub mod input_parser;
16pub mod pane_pointer_capture;
17pub mod session_record;
18pub mod step_program;
19
20use core::time::Duration;
21use std::collections::VecDeque;
22
23use ftui_backend::{Backend, BackendClock, BackendEventSource, BackendFeatures, BackendPresenter};
24use ftui_core::event::Event;
25use ftui_core::terminal_capabilities::TerminalCapabilities;
26use ftui_render::buffer::Buffer;
27use ftui_render::cell::{Cell, CellAttrs, CellContent};
28use ftui_render::diff::BufferDiff;
29
30const GRAPHEME_FALLBACK_CODEPOINT: u32 = '□' as u32;
31const ATTR_STYLE_MASK: u32 = 0xFF;
32const ATTR_LINK_ID_MAX: u32 = CellAttrs::LINK_ID_MAX;
33const WEB_PATCH_CELL_BYTES: u64 = 16;
34const PATCH_HASH_ALGO: &str = "fnv1a64";
35const FNV64_OFFSET_BASIS: u64 = 0xcbf29ce484222325;
36const FNV64_PRIME: u64 = 0x100000001b3;
37
38/// Web backend error type.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum WebBackendError {
41    /// Generic unsupported operation.
42    Unsupported(&'static str),
43}
44
45impl core::fmt::Display for WebBackendError {
46    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
47        match self {
48            Self::Unsupported(msg) => write!(f, "unsupported: {msg}"),
49        }
50    }
51}
52
53impl std::error::Error for WebBackendError {}
54
55/// Deterministic monotonic clock controlled by the host.
56#[derive(Debug, Default, Clone)]
57pub struct DeterministicClock {
58    now: Duration,
59}
60
61impl DeterministicClock {
62    /// Create a clock starting at `0`.
63    #[must_use]
64    pub const fn new() -> Self {
65        Self {
66            now: Duration::ZERO,
67        }
68    }
69
70    /// Set current monotonic time.
71    pub fn set(&mut self, now: Duration) {
72        self.now = now;
73    }
74
75    /// Advance monotonic time by `dt`.
76    pub fn advance(&mut self, dt: Duration) {
77        self.now = self.now.saturating_add(dt);
78    }
79}
80
81impl BackendClock for DeterministicClock {
82    fn now_mono(&self) -> Duration {
83        self.now
84    }
85}
86
87/// Host-driven event source for WASM.
88///
89/// The host is responsible for pushing [`Event`] values and updating size.
90#[derive(Debug, Clone)]
91pub struct WebEventSource {
92    size: (u16, u16),
93    features: BackendFeatures,
94    queue: VecDeque<Event>,
95}
96
97impl WebEventSource {
98    /// Create a new event source with an initial size.
99    #[must_use]
100    pub fn new(width: u16, height: u16) -> Self {
101        Self {
102            size: (width, height),
103            features: BackendFeatures::default(),
104            queue: VecDeque::new(),
105        }
106    }
107
108    /// Update the current size.
109    pub fn set_size(&mut self, width: u16, height: u16) {
110        self.size = (width, height);
111    }
112
113    /// Read back the currently requested backend features.
114    #[must_use]
115    pub const fn features(&self) -> BackendFeatures {
116        self.features
117    }
118
119    /// Push a canonical event into the queue.
120    pub fn push_event(&mut self, event: Event) {
121        self.queue.push_back(event);
122    }
123
124    /// Drain all pending events.
125    pub fn drain_events(&mut self) -> impl Iterator<Item = Event> + '_ {
126        self.queue.drain(..)
127    }
128}
129
130impl BackendEventSource for WebEventSource {
131    type Error = WebBackendError;
132
133    fn size(&self) -> Result<(u16, u16), Self::Error> {
134        Ok(self.size)
135    }
136
137    fn set_features(&mut self, features: BackendFeatures) -> Result<(), Self::Error> {
138        self.features = features;
139        Ok(())
140    }
141
142    fn poll_event(&mut self, timeout: Duration) -> Result<bool, Self::Error> {
143        // WASM backend is host-driven; we never block.
144        let _ = timeout;
145        Ok(!self.queue.is_empty())
146    }
147
148    fn read_event(&mut self) -> Result<Option<Event>, Self::Error> {
149        Ok(self.queue.pop_front())
150    }
151}
152
153/// Captured presentation outputs for host consumption.
154#[derive(Debug, Default, Clone)]
155pub struct WebOutputs {
156    /// Log lines written by the runtime.
157    pub logs: Vec<String>,
158    /// Last fully-rendered buffer presented.
159    pub last_buffer: Option<Buffer>,
160    /// Last emitted incremental/full patch runs in row-major order.
161    pub last_patches: Vec<WebPatchRun>,
162    /// Aggregate patch upload accounting for the last present.
163    pub last_patch_stats: Option<WebPatchStats>,
164    /// Deterministic hash of the last patch batch (lazy — computed on first read).
165    pub last_patch_hash: Option<String>,
166    /// Whether the last present requested a full repaint.
167    pub last_full_repaint_hint: bool,
168    /// Whether the hash has been computed for the current patches.
169    hash_computed: bool,
170}
171
172impl WebOutputs {
173    /// Compute and return the patch hash, caching the result.
174    ///
175    /// The hash is computed lazily on first call and cached for subsequent reads.
176    pub fn compute_patch_hash(&mut self) -> Option<&str> {
177        if !self.hash_computed && !self.last_patches.is_empty() {
178            self.last_patch_hash = Some(patch_batch_hash(&self.last_patches));
179            self.hash_computed = true;
180        }
181        self.last_patch_hash.as_deref()
182    }
183}
184
185impl WebOutputs {
186    /// Flatten patch runs for low-overhead JS/WASM bridge transport.
187    ///
188    /// Cells are emitted as a contiguous `u32` payload in:
189    /// `[bg, fg, glyph, attrs]` order. Spans are emitted as `u32` pairs:
190    /// `[offset, len, offset, len, ...]`.
191    #[must_use]
192    pub fn flatten_patches_u32(&self) -> WebFlatPatchBatch {
193        let total_cells = self
194            .last_patches
195            .iter()
196            .map(|patch| patch.cells.len())
197            .sum::<usize>();
198        let mut cells = Vec::with_capacity(total_cells.saturating_mul(4));
199        let mut spans = Vec::with_capacity(self.last_patches.len().saturating_mul(2));
200
201        for patch in &self.last_patches {
202            spans.push(patch.offset);
203            let len = patch.cells.len().min(u32::MAX as usize) as u32;
204            spans.push(len);
205
206            for cell in &patch.cells {
207                cells.push(cell.bg);
208                cells.push(cell.fg);
209                cells.push(cell.glyph);
210                cells.push(cell.attrs);
211            }
212        }
213
214        WebFlatPatchBatch { cells, spans }
215    }
216}
217
218/// One GPU patch cell payload (`bg`, `fg`, `glyph`, `attrs`) matching the
219/// `frankenterm-web` `applyPatch` schema.
220#[derive(Debug, Clone, Copy, PartialEq, Eq)]
221pub struct WebPatchCell {
222    pub bg: u32,
223    pub fg: u32,
224    pub glyph: u32,
225    pub attrs: u32,
226}
227
228/// One contiguous run of changed cells starting at linear `offset`.
229#[derive(Debug, Clone, PartialEq, Eq)]
230pub struct WebPatchRun {
231    pub offset: u32,
232    pub cells: Vec<WebPatchCell>,
233}
234
235/// Compact, flat patch payload for JS/WASM transport.
236#[derive(Debug, Default, Clone, PartialEq, Eq)]
237pub struct WebFlatPatchBatch {
238    /// Cell payload in `[bg, fg, glyph, attrs]` order.
239    pub cells: Vec<u32>,
240    /// Span payload in `[offset, len, offset, len, ...]` order.
241    pub spans: Vec<u32>,
242}
243
244/// Aggregate patch-upload stats for host instrumentation and JSONL reporting.
245#[derive(Debug, Clone, Copy, PartialEq, Eq)]
246pub struct WebPatchStats {
247    pub dirty_cells: u32,
248    pub patch_count: u32,
249    pub bytes_uploaded: u64,
250}
251
252/// WASM presenter that captures buffers and logs for the host.
253#[derive(Debug, Clone)]
254pub struct WebPresenter {
255    caps: TerminalCapabilities,
256    outputs: WebOutputs,
257}
258
259impl WebPresenter {
260    /// Create a new presenter with modern capabilities.
261    #[must_use]
262    pub fn new() -> Self {
263        Self {
264            caps: TerminalCapabilities::modern(),
265            outputs: WebOutputs::default(),
266        }
267    }
268
269    /// Get captured outputs.
270    #[must_use]
271    pub const fn outputs(&self) -> &WebOutputs {
272        &self.outputs
273    }
274
275    /// Mutably access captured outputs.
276    pub fn outputs_mut(&mut self) -> &mut WebOutputs {
277        &mut self.outputs
278    }
279
280    /// Take captured outputs, leaving empty defaults.
281    pub fn take_outputs(&mut self) -> WebOutputs {
282        std::mem::take(&mut self.outputs)
283    }
284
285    /// Flatten patch runs into caller-provided reusable buffers.
286    ///
287    /// Clears and refills the provided `cells` and `spans` Vecs, reusing
288    /// their heap capacity across frames to avoid per-frame allocation.
289    pub fn flatten_patches_into(&self, cells: &mut Vec<u32>, spans: &mut Vec<u32>) {
290        cells.clear();
291        spans.clear();
292
293        let total_cells = self
294            .outputs
295            .last_patches
296            .iter()
297            .map(|p| p.cells.len())
298            .sum::<usize>();
299        cells.reserve(total_cells.saturating_mul(4));
300        spans.reserve(self.outputs.last_patches.len().saturating_mul(2));
301
302        for patch in &self.outputs.last_patches {
303            spans.push(patch.offset);
304            let len = patch.cells.len().min(u32::MAX as usize) as u32;
305            spans.push(len);
306
307            for cell in &patch.cells {
308                cells.push(cell.bg);
309                cells.push(cell.fg);
310                cells.push(cell.glyph);
311                cells.push(cell.attrs);
312            }
313        }
314    }
315
316    /// Present a frame, taking ownership of the buffer to avoid cloning.
317    ///
318    /// This is the zero-copy fast path for callers that can give up ownership
319    /// (e.g. `StepProgram::render_frame`). The buffer is moved directly into
320    /// `last_buffer` instead of being cloned.
321    pub fn present_ui_owned(
322        &mut self,
323        buf: Buffer,
324        diff: Option<&BufferDiff>,
325        full_repaint_hint: bool,
326    ) {
327        let patches = build_patch_runs(&buf, diff, full_repaint_hint);
328        let stats = patch_batch_stats(&patches);
329        self.outputs.last_buffer = Some(buf);
330        self.outputs.last_patches = patches;
331        self.outputs.last_patch_stats = Some(stats);
332        self.outputs.last_patch_hash = None;
333        self.outputs.hash_computed = false;
334        self.outputs.last_full_repaint_hint = full_repaint_hint;
335    }
336}
337
338impl Default for WebPresenter {
339    fn default() -> Self {
340        Self::new()
341    }
342}
343
344impl BackendPresenter for WebPresenter {
345    type Error = WebBackendError;
346
347    fn capabilities(&self) -> &TerminalCapabilities {
348        &self.caps
349    }
350
351    fn write_log(&mut self, text: &str) -> Result<(), Self::Error> {
352        self.outputs.logs.push(text.to_owned());
353        Ok(())
354    }
355
356    fn present_ui(
357        &mut self,
358        buf: &Buffer,
359        diff: Option<&BufferDiff>,
360        full_repaint_hint: bool,
361    ) -> Result<(), Self::Error> {
362        let patches = build_patch_runs(buf, diff, full_repaint_hint);
363        let stats = patch_batch_stats(&patches);
364        self.outputs.last_buffer = Some(buf.clone());
365        self.outputs.last_patches = patches;
366        self.outputs.last_patch_stats = Some(stats);
367        self.outputs.last_patch_hash = None;
368        self.outputs.hash_computed = false;
369        self.outputs.last_full_repaint_hint = full_repaint_hint;
370        Ok(())
371    }
372}
373
374#[must_use]
375fn fnv1a64_extend(mut hash: u64, bytes: &[u8]) -> u64 {
376    for &byte in bytes {
377        hash ^= u64::from(byte);
378        hash = hash.wrapping_mul(FNV64_PRIME);
379    }
380    hash
381}
382
383#[must_use]
384fn cell_to_patch(cell: &Cell) -> WebPatchCell {
385    let glyph = match cell.content {
386        CellContent::EMPTY | CellContent::CONTINUATION => 0,
387        other if other.is_grapheme() => GRAPHEME_FALLBACK_CODEPOINT,
388        other => other.as_char().map_or(0, |c| c as u32),
389    };
390    let style_bits = u32::from(cell.attrs.flags().bits()) & ATTR_STYLE_MASK;
391    let link_id = cell.attrs.link_id().min(ATTR_LINK_ID_MAX);
392    WebPatchCell {
393        bg: cell.bg.0,
394        fg: cell.fg.0,
395        glyph,
396        attrs: style_bits | (link_id << 8),
397    }
398}
399
400#[must_use]
401fn full_buffer_patch(buffer: &Buffer) -> WebPatchRun {
402    let cols = buffer.width();
403    let rows = buffer.height();
404    let total = usize::from(cols) * usize::from(rows);
405    let mut cells = Vec::with_capacity(total);
406    for y in 0..rows {
407        for x in 0..cols {
408            cells.push(cell_to_patch(buffer.get_unchecked(x, y)));
409        }
410    }
411    WebPatchRun { offset: 0, cells }
412}
413
414#[must_use]
415fn diff_to_patches(buffer: &Buffer, diff: &BufferDiff) -> Vec<WebPatchRun> {
416    if diff.is_empty() {
417        return Vec::new();
418    }
419    let width = buffer.width();
420    let height = buffer.height();
421    let cols = u32::from(width);
422    // Heuristic: most sparse diffs produce one patch per ~8 dirty cells.
423    let est_patches = diff.len().div_ceil(8).max(1);
424    let mut patches = Vec::with_capacity(est_patches);
425    let mut span_start: u32 = 0;
426    let mut span_cells: Vec<WebPatchCell> = Vec::with_capacity(diff.len());
427    let mut prev_offset: u32 = 0;
428    let mut has_span = false;
429
430    for &(x, y) in diff.changes() {
431        // Safety: diffs can become stale across resize; fall back to a full patch.
432        if x >= width || y >= height {
433            return vec![full_buffer_patch(buffer)];
434        }
435        let offset = u32::from(y) * cols + u32::from(x);
436        if !has_span {
437            span_start = offset;
438            prev_offset = offset;
439            has_span = true;
440            span_cells.push(cell_to_patch(buffer.get_unchecked(x, y)));
441            continue;
442        }
443        if offset == prev_offset {
444            continue;
445        }
446        if offset == prev_offset + 1 {
447            span_cells.push(cell_to_patch(buffer.get_unchecked(x, y)));
448        } else {
449            patches.push(WebPatchRun {
450                offset: span_start,
451                cells: std::mem::take(&mut span_cells),
452            });
453            span_start = offset;
454            span_cells.push(cell_to_patch(buffer.get_unchecked(x, y)));
455        }
456        prev_offset = offset;
457    }
458    if !span_cells.is_empty() {
459        patches.push(WebPatchRun {
460            offset: span_start,
461            cells: span_cells,
462        });
463    }
464    patches
465}
466
467#[must_use]
468fn build_patch_runs(
469    buffer: &Buffer,
470    diff: Option<&BufferDiff>,
471    full_repaint_hint: bool,
472) -> Vec<WebPatchRun> {
473    if full_repaint_hint {
474        return vec![full_buffer_patch(buffer)];
475    }
476    match diff {
477        Some(dirty) => diff_to_patches(buffer, dirty),
478        None => vec![full_buffer_patch(buffer)],
479    }
480}
481
482#[must_use]
483fn patch_batch_stats(patches: &[WebPatchRun]) -> WebPatchStats {
484    let dirty_cells_u64 = patches
485        .iter()
486        .map(|patch| patch.cells.len() as u64)
487        .sum::<u64>();
488    let dirty_cells = dirty_cells_u64.min(u64::from(u32::MAX)) as u32;
489    let patch_count = patches.len().min(u32::MAX as usize) as u32;
490    let bytes_uploaded = dirty_cells_u64.saturating_mul(WEB_PATCH_CELL_BYTES);
491    WebPatchStats {
492        dirty_cells,
493        patch_count,
494        bytes_uploaded,
495    }
496}
497
498#[must_use]
499fn patch_batch_hash(patches: &[WebPatchRun]) -> String {
500    let mut hash = FNV64_OFFSET_BASIS;
501    let patch_count = u64::try_from(patches.len()).unwrap_or(u64::MAX);
502    hash = fnv1a64_extend(hash, &patch_count.to_le_bytes());
503
504    // Pre-allocate a 16-byte buffer for batching cell fields into a single
505    // fnv1a64_extend call (4× fewer function calls per cell).
506    let mut cell_bytes = [0u8; 16];
507    for patch in patches {
508        let cell_count = u64::try_from(patch.cells.len()).unwrap_or(u64::MAX);
509        hash = fnv1a64_extend(hash, &patch.offset.to_le_bytes());
510        hash = fnv1a64_extend(hash, &cell_count.to_le_bytes());
511        for cell in &patch.cells {
512            cell_bytes[0..4].copy_from_slice(&cell.bg.to_le_bytes());
513            cell_bytes[4..8].copy_from_slice(&cell.fg.to_le_bytes());
514            cell_bytes[8..12].copy_from_slice(&cell.glyph.to_le_bytes());
515            cell_bytes[12..16].copy_from_slice(&cell.attrs.to_le_bytes());
516            hash = fnv1a64_extend(hash, &cell_bytes);
517        }
518    }
519
520    format!("{PATCH_HASH_ALGO}:{hash:016x}")
521}
522
523/// A minimal, host-driven WASM backend.
524///
525/// This backend is intended to be driven by a JS host:
526/// - push events via [`Self::events_mut`]
527/// - advance time via [`Self::clock_mut`]
528/// - read rendered buffers via [`Self::presenter_mut`]
529#[derive(Debug, Clone)]
530pub struct WebBackend {
531    clock: DeterministicClock,
532    events: WebEventSource,
533    presenter: WebPresenter,
534}
535
536impl WebBackend {
537    /// Create a backend with an initial size.
538    #[must_use]
539    pub fn new(width: u16, height: u16) -> Self {
540        Self {
541            clock: DeterministicClock::new(),
542            events: WebEventSource::new(width, height),
543            presenter: WebPresenter::new(),
544        }
545    }
546
547    /// Mutably access the clock.
548    pub fn clock_mut(&mut self) -> &mut DeterministicClock {
549        &mut self.clock
550    }
551
552    /// Mutably access the event source.
553    pub fn events_mut(&mut self) -> &mut WebEventSource {
554        &mut self.events
555    }
556
557    /// Mutably access the presenter.
558    pub fn presenter_mut(&mut self) -> &mut WebPresenter {
559        &mut self.presenter
560    }
561}
562
563impl Backend for WebBackend {
564    type Error = WebBackendError;
565
566    type Clock = DeterministicClock;
567    type Events = WebEventSource;
568    type Presenter = WebPresenter;
569
570    fn clock(&self) -> &Self::Clock {
571        &self.clock
572    }
573
574    fn events(&mut self) -> &mut Self::Events {
575        &mut self.events
576    }
577
578    fn presenter(&mut self) -> &mut Self::Presenter {
579        &mut self.presenter
580    }
581}
582
583#[cfg(test)]
584mod tests {
585    use super::*;
586    use ftui_render::cell::Cell;
587
588    use pretty_assertions::assert_eq;
589
590    #[test]
591    fn deterministic_clock_advances_monotonically() {
592        let mut c = DeterministicClock::new();
593        assert_eq!(c.now_mono(), Duration::ZERO);
594
595        c.advance(Duration::from_millis(10));
596        assert_eq!(c.now_mono(), Duration::from_millis(10));
597
598        c.advance(Duration::from_millis(5));
599        assert_eq!(c.now_mono(), Duration::from_millis(15));
600
601        // Saturation: don't panic or wrap.
602        c.set(Duration::MAX);
603        c.advance(Duration::from_secs(1));
604        assert_eq!(c.now_mono(), Duration::MAX);
605    }
606
607    #[test]
608    fn web_event_source_fifo_queue() {
609        let mut ev = WebEventSource::new(80, 24);
610        assert_eq!(ev.size().unwrap(), (80, 24));
611        assert_eq!(ev.poll_event(Duration::from_millis(0)).unwrap(), false);
612
613        ev.push_event(Event::Tick);
614        ev.push_event(Event::Resize {
615            width: 100,
616            height: 40,
617        });
618
619        assert_eq!(ev.poll_event(Duration::from_millis(0)).unwrap(), true);
620        assert_eq!(ev.read_event().unwrap(), Some(Event::Tick));
621        assert_eq!(
622            ev.read_event().unwrap(),
623            Some(Event::Resize {
624                width: 100,
625                height: 40,
626            })
627        );
628        assert_eq!(ev.read_event().unwrap(), None);
629    }
630
631    #[test]
632    fn presenter_captures_logs_and_last_buffer() {
633        let mut p = WebPresenter::new();
634        p.write_log("hello").unwrap();
635        p.write_log("world").unwrap();
636
637        let buf = Buffer::new(2, 2);
638        p.present_ui(&buf, None, true).unwrap();
639
640        let mut outputs = p.take_outputs();
641        assert_eq!(outputs.logs, vec!["hello", "world"]);
642        assert_eq!(outputs.last_full_repaint_hint, true);
643        assert_eq!(outputs.last_buffer.as_ref().unwrap().width(), 2);
644        assert_eq!(outputs.last_patches.len(), 1);
645        let stats = outputs.last_patch_stats.expect("stats should be present");
646        assert_eq!(stats.patch_count, 1);
647        assert_eq!(stats.dirty_cells, 4);
648        assert_eq!(stats.bytes_uploaded, 64);
649        let hash = outputs
650            .compute_patch_hash()
651            .expect("hash should be present");
652        assert!(hash.starts_with("fnv1a64:"));
653    }
654
655    #[test]
656    fn presenter_emits_incremental_patch_runs_from_diff() {
657        let mut presenter = WebPresenter::new();
658        let old = Buffer::new(6, 2);
659        presenter.present_ui(&old, None, true).unwrap();
660
661        let mut next = Buffer::new(6, 2);
662        next.set_raw(2, 0, Cell::from_char('A'));
663        next.set_raw(3, 0, Cell::from_char('B'));
664        next.set_raw(0, 1, Cell::from_char('C'));
665        let diff = BufferDiff::compute(&old, &next);
666        presenter.present_ui(&next, Some(&diff), false).unwrap();
667
668        let mut outputs = presenter.take_outputs();
669        assert_eq!(outputs.last_full_repaint_hint, false);
670        assert_eq!(outputs.last_patches.len(), 2);
671        assert_eq!(outputs.last_patches[0].offset, 2);
672        assert_eq!(outputs.last_patches[0].cells.len(), 2);
673        assert_eq!(outputs.last_patches[1].offset, 6);
674        assert_eq!(outputs.last_patches[1].cells.len(), 1);
675        let stats = outputs.last_patch_stats.expect("stats should be present");
676        assert_eq!(stats.patch_count, 2);
677        assert_eq!(stats.dirty_cells, 3);
678        assert_eq!(stats.bytes_uploaded, 48);
679        let hash = outputs
680            .compute_patch_hash()
681            .expect("hash should be present");
682        assert!(hash.starts_with("fnv1a64:"));
683    }
684
685    #[test]
686    fn stale_diff_falls_back_to_full_patch() {
687        let old = Buffer::new(4, 2);
688        let mut next = Buffer::new(4, 2);
689        next.set_raw(3, 1, Cell::from_char('X'));
690        let stale_diff = BufferDiff::compute(&old, &next);
691
692        let resized = Buffer::new(2, 1);
693        let patches = build_patch_runs(&resized, Some(&stale_diff), false);
694
695        assert_eq!(patches.len(), 1);
696        assert_eq!(patches[0].offset, 0);
697        assert_eq!(patches[0].cells.len(), 2);
698    }
699
700    #[test]
701    fn patch_batch_hash_is_deterministic() {
702        let patches = vec![
703            WebPatchRun {
704                offset: 2,
705                cells: vec![
706                    WebPatchCell {
707                        bg: 0x1122_3344,
708                        fg: 0x5566_7788,
709                        glyph: 'A' as u32,
710                        attrs: 0x0000_0001,
711                    },
712                    WebPatchCell {
713                        bg: 0x1122_3344,
714                        fg: 0x5566_7788,
715                        glyph: 'B' as u32,
716                        attrs: 0x0000_0002,
717                    },
718                ],
719            },
720            WebPatchRun {
721                offset: 10,
722                cells: vec![WebPatchCell {
723                    bg: 0xAABB_CCDD,
724                    fg: 0xDDEE_FF00,
725                    glyph: '中' as u32,
726                    attrs: 0x0000_0010,
727                }],
728            },
729        ];
730
731        let hash_a = patch_batch_hash(&patches);
732        let hash_b = patch_batch_hash(&patches);
733        assert_eq!(hash_a, hash_b);
734        assert!(hash_a.starts_with("fnv1a64:"));
735    }
736
737    #[test]
738    fn patch_batch_hash_changes_with_patch_payload() {
739        let baseline = vec![WebPatchRun {
740            offset: 4,
741            cells: vec![WebPatchCell {
742                bg: 0x0000_00FF,
743                fg: 0xFFFF_FFFF,
744                glyph: 'x' as u32,
745                attrs: 0x0000_0001,
746            }],
747        }];
748        let mut changed = baseline.clone();
749        changed[0].offset = 5;
750
751        let base_hash = patch_batch_hash(&baseline);
752        let changed_hash = patch_batch_hash(&changed);
753        assert_ne!(base_hash, changed_hash);
754
755        changed[0].offset = 4;
756        changed[0].cells[0].glyph = 'y' as u32;
757        let changed_glyph_hash = patch_batch_hash(&changed);
758        assert_ne!(base_hash, changed_glyph_hash);
759    }
760
761    #[test]
762    fn flatten_patches_u32_emits_row_major_cells_and_spans() {
763        let outputs = WebOutputs {
764            last_patches: vec![
765                WebPatchRun {
766                    offset: 2,
767                    cells: vec![
768                        WebPatchCell {
769                            bg: 10,
770                            fg: 11,
771                            glyph: 12,
772                            attrs: 13,
773                        },
774                        WebPatchCell {
775                            bg: 20,
776                            fg: 21,
777                            glyph: 22,
778                            attrs: 23,
779                        },
780                    ],
781                },
782                WebPatchRun {
783                    offset: 9,
784                    cells: vec![WebPatchCell {
785                        bg: 30,
786                        fg: 31,
787                        glyph: 32,
788                        attrs: 33,
789                    }],
790                },
791            ],
792            ..WebOutputs::default()
793        };
794
795        let flat = outputs.flatten_patches_u32();
796        assert_eq!(flat.spans, vec![2, 2, 9, 1]);
797        assert_eq!(
798            flat.cells,
799            vec![
800                10, 11, 12, 13, //
801                20, 21, 22, 23, //
802                30, 31, 32, 33
803            ]
804        );
805    }
806
807    #[test]
808    fn flatten_patches_u32_handles_empty_payload() {
809        let outputs = WebOutputs::default();
810        let flat = outputs.flatten_patches_u32();
811        assert!(flat.cells.is_empty());
812        assert!(flat.spans.is_empty());
813    }
814
815    // --- WebBackendError ---
816
817    #[test]
818    fn web_backend_error_display() {
819        let err = WebBackendError::Unsupported("test op");
820        assert_eq!(format!("{err}"), "unsupported: test op");
821    }
822
823    #[test]
824    fn web_backend_error_is_std_error() {
825        let err = WebBackendError::Unsupported("foo");
826        let _: &dyn std::error::Error = &err;
827    }
828
829    #[test]
830    fn web_backend_error_eq() {
831        assert_eq!(
832            WebBackendError::Unsupported("a"),
833            WebBackendError::Unsupported("a")
834        );
835        assert_ne!(
836            WebBackendError::Unsupported("a"),
837            WebBackendError::Unsupported("b")
838        );
839    }
840
841    // --- DeterministicClock ---
842
843    #[test]
844    fn clock_set_overrides_current() {
845        let mut c = DeterministicClock::new();
846        c.set(Duration::from_secs(42));
847        assert_eq!(c.now_mono(), Duration::from_secs(42));
848    }
849
850    #[test]
851    fn clock_default_is_zero() {
852        let c = DeterministicClock::default();
853        assert_eq!(c.now_mono(), Duration::ZERO);
854    }
855
856    #[test]
857    fn clock_clone() {
858        let mut c = DeterministicClock::new();
859        c.advance(Duration::from_millis(100));
860        let c2 = c.clone();
861        assert_eq!(c2.now_mono(), Duration::from_millis(100));
862    }
863
864    // --- WebEventSource ---
865
866    #[test]
867    fn event_source_set_size() {
868        let mut ev = WebEventSource::new(80, 24);
869        ev.set_size(120, 50);
870        assert_eq!(ev.size().unwrap(), (120, 50));
871    }
872
873    #[test]
874    fn event_source_drain_events() {
875        let mut ev = WebEventSource::new(80, 24);
876        ev.push_event(Event::Tick);
877        ev.push_event(Event::Tick);
878
879        let drained: Vec<_> = ev.drain_events().collect();
880        assert_eq!(drained.len(), 2);
881        assert_eq!(ev.poll_event(Duration::ZERO).unwrap(), false);
882    }
883
884    #[test]
885    fn event_source_features() {
886        let mut ev = WebEventSource::new(80, 24);
887        let f = BackendFeatures::default();
888        ev.set_features(f).unwrap();
889        let _ = ev.features(); // should not panic
890    }
891
892    #[test]
893    fn event_source_empty_read_returns_none() {
894        let mut ev = WebEventSource::new(80, 24);
895        assert_eq!(ev.read_event().unwrap(), None);
896    }
897
898    // --- WebPresenter ---
899
900    #[test]
901    fn presenter_default_is_new() {
902        let a = WebPresenter::new();
903        let b = WebPresenter::default();
904        assert!(a.outputs().logs.is_empty());
905        assert!(b.outputs().logs.is_empty());
906    }
907
908    #[test]
909    fn presenter_outputs_accessor() {
910        let mut p = WebPresenter::new();
911        p.write_log("test").unwrap();
912        assert_eq!(p.outputs().logs.len(), 1);
913    }
914
915    #[test]
916    fn presenter_outputs_mut() {
917        let mut p = WebPresenter::new();
918        p.outputs_mut().logs.push("manual".to_string());
919        assert_eq!(p.outputs().logs, vec!["manual"]);
920    }
921
922    #[test]
923    fn presenter_take_outputs_clears() {
924        let mut p = WebPresenter::new();
925        p.write_log("a").unwrap();
926        let taken = p.take_outputs();
927        assert_eq!(taken.logs, vec!["a"]);
928        assert!(p.outputs().logs.is_empty());
929    }
930
931    #[test]
932    fn presenter_capabilities_are_modern() {
933        let p = WebPresenter::new();
934        let caps = p.capabilities();
935        assert!(caps.true_color);
936    }
937
938    #[test]
939    fn presenter_present_ui_owned() {
940        let mut p = WebPresenter::new();
941        let buf = Buffer::new(3, 2);
942        p.present_ui_owned(buf, None, true);
943
944        let mut out = p.take_outputs();
945        assert!(out.last_full_repaint_hint);
946        assert_eq!(out.last_buffer.as_ref().unwrap().width(), 3);
947        assert_eq!(out.last_patches.len(), 1);
948        assert!(out.last_patch_stats.is_some());
949        assert!(out.compute_patch_hash().is_some());
950    }
951
952    // --- WebBackend ---
953
954    #[test]
955    fn web_backend_construction() {
956        let mut wb = WebBackend::new(80, 24);
957        assert_eq!(wb.events_mut().size().unwrap(), (80, 24));
958        assert_eq!(wb.clock_mut().now_mono(), Duration::ZERO);
959    }
960
961    #[test]
962    fn web_backend_implements_backend_trait() {
963        let mut wb = WebBackend::new(80, 24);
964        // Verify Backend trait methods
965        let _ = wb.clock();
966        let _ = wb.events();
967        let _ = wb.presenter();
968    }
969
970    // --- patch_batch_stats ---
971
972    #[test]
973    fn patch_batch_stats_empty() {
974        let stats = patch_batch_stats(&[]);
975        assert_eq!(stats.dirty_cells, 0);
976        assert_eq!(stats.patch_count, 0);
977        assert_eq!(stats.bytes_uploaded, 0);
978    }
979
980    #[test]
981    fn patch_batch_stats_counts_cells() {
982        let patches = vec![
983            WebPatchRun {
984                offset: 0,
985                cells: vec![
986                    WebPatchCell {
987                        bg: 0,
988                        fg: 0,
989                        glyph: 0,
990                        attrs: 0,
991                    };
992                    3
993                ],
994            },
995            WebPatchRun {
996                offset: 10,
997                cells: vec![WebPatchCell {
998                    bg: 0,
999                    fg: 0,
1000                    glyph: 0,
1001                    attrs: 0,
1002                }],
1003            },
1004        ];
1005        let stats = patch_batch_stats(&patches);
1006        assert_eq!(stats.dirty_cells, 4);
1007        assert_eq!(stats.patch_count, 2);
1008        assert_eq!(stats.bytes_uploaded, 64); // 4 cells * 16 bytes
1009    }
1010
1011    // --- patch_batch_hash ---
1012
1013    #[test]
1014    fn patch_hash_empty_is_deterministic() {
1015        let a = patch_batch_hash(&[]);
1016        let b = patch_batch_hash(&[]);
1017        assert_eq!(a, b);
1018        assert!(a.starts_with("fnv1a64:"));
1019    }
1020
1021    // --- build_patch_runs ---
1022
1023    #[test]
1024    fn build_patch_runs_full_repaint_hint() {
1025        let buf = Buffer::new(2, 2);
1026        let patches = build_patch_runs(&buf, None, true);
1027        assert_eq!(patches.len(), 1);
1028        assert_eq!(patches[0].offset, 0);
1029        assert_eq!(patches[0].cells.len(), 4);
1030    }
1031
1032    #[test]
1033    fn build_patch_runs_no_diff_triggers_full() {
1034        let buf = Buffer::new(3, 1);
1035        let patches = build_patch_runs(&buf, None, false);
1036        // None diff → full buffer patch
1037        assert_eq!(patches.len(), 1);
1038        assert_eq!(patches[0].cells.len(), 3);
1039    }
1040
1041    // --- WebOutputs default ---
1042
1043    #[test]
1044    fn web_outputs_default_is_empty() {
1045        let out = WebOutputs::default();
1046        assert!(out.logs.is_empty());
1047        assert!(out.last_buffer.is_none());
1048        assert!(out.last_patches.is_empty());
1049        assert!(out.last_patch_stats.is_none());
1050        assert!(out.last_patch_hash.is_none());
1051        assert!(!out.last_full_repaint_hint);
1052    }
1053
1054    // --- cell_to_patch ---
1055
1056    #[test]
1057    fn cell_to_patch_empty_cell() {
1058        let cell = Cell::default();
1059        let patch = cell_to_patch(&cell);
1060        // Empty cell should have glyph=0
1061        assert_eq!(patch.glyph, 0);
1062    }
1063
1064    #[test]
1065    fn cell_to_patch_ascii_char() {
1066        let cell = Cell::from_char('A');
1067        let patch = cell_to_patch(&cell);
1068        assert_eq!(patch.glyph, 'A' as u32);
1069    }
1070
1071    #[test]
1072    fn cell_to_patch_preserves_max_link_id() {
1073        use ftui_render::cell::{CellAttrs, StyleFlags};
1074
1075        let cell = Cell::from_char('L').with_attrs(CellAttrs::new(
1076            StyleFlags::UNDERLINE,
1077            CellAttrs::LINK_ID_MAX,
1078        ));
1079        let patch = cell_to_patch(&cell);
1080
1081        assert_eq!(patch.attrs >> 8, CellAttrs::LINK_ID_MAX);
1082    }
1083}