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