Skip to main content

retroglyph_terminal_wasm/
lib.rs

1//! A WASM/browser terminal backend, driven by pushed input and pulled ANSI output.
2//!
3//! [`TerminalWasm`] implements [`Backend`](retroglyph_core::backend::Backend) directly (like
4//! [`Headless`](retroglyph_core::backend::Headless)): there is no event loop
5//! here. A browser terminal emulator (e.g. xterm.js, this crate has no
6//! dependency on it and no opinion about which one is used) is driven from
7//! JS, which calls into this crate once per animation frame (or on demand)
8//! to pull freshly rendered ANSI bytes and push back any input it collected.
9//!
10//! # Usage from Rust
11//!
12//! ```
13//! use retroglyph_core::color::Style;
14//! use retroglyph_core::terminal::Terminal;
15//! use retroglyph_terminal_wasm::TerminalWasm;
16//!
17//! let backend = TerminalWasm::new(80, 24);
18//! let mut term = Terminal::new(backend);
19//! term.draw(|s| s.put((0, 0), '@', Style::default())).unwrap();
20//! let ansi = term.backend_mut().take_output();
21//! assert!(ansi.contains('@'));
22//! ```
23//!
24//! # Usage from JS (via `wasm-bindgen`)
25//!
26//! The `wasm32` build additionally exposes free functions
27//! (`wasm_terminal_new`, `wasm_terminal_resize`, `wasm_terminal_push_key`,
28//! `wasm_terminal_push_mouse`, `wasm_terminal_push_paste`,
29//! `wasm_terminal_take_output`, in this crate's `wasm` module, only
30//! compiled for `target_arch = "wasm32"`, so it won't appear in docs built
31//! natively) that operate on an opaque handle, since
32//! `retroglyph_core::event::Event` is not itself `wasm-bindgen`-compatible.
33//!
34//! The example below is a complete, working driver pairing this crate's
35//! `wasm32` build with [xterm.js](https://xtermjs.org/) (any other browser
36//! terminal emulator works the same way; this crate has no dependency on
37//! xterm.js specifically). It assumes a `wasm-pack`/`wasm-bindgen`-generated
38//! `./pkg.js` module built from a binary that re-exports this crate's `wasm`
39//! module, and an `xterm.js` `<script>` already loaded on the page (see
40//! [xterm.js's own quick start](https://xtermjs.org/docs/) for that half).
41//!
42//! It's a wiring template, not a full game: it plumbs input/output through
43//! this crate's generic handle-based FFI but calls no per-frame drawing
44//! logic of its own (that's the consumer's job, via their own Rust code
45//! holding the `Terminal<TerminalWasm>`; see "Usage from Rust" above).
46//!
47//! A game driving a real [`App`](retroglyph_core::app::App) usually wants the single-instance-per-page
48//! FFI [`app_entry!`] generates instead of hand-rolling a thread-local session over the
49//! handle-based functions shown here: `wasm_app_init`/`wasm_app_resize`/`wasm_app_push_key`/
50//! `wasm_app_push_mouse`/`wasm_app_push_paste`/`wasm_app_push_focus`/`wasm_app_tick`, with the
51//! [`Terminal::resize`](retroglyph_core::terminal::Terminal::resize)-plus-`Event::Resize` bookkeeping
52//! [`resize_terminal`] does and the backgrounded-tab delta clamp documented on [`app_entry!`]
53//! itself. See that macro's own doc comment for a complete example. The examples crate's
54//! WASM demo gallery (linked from the workspace README) uses an equivalent macro,
55//! `retroglyph_examples::wasm_entry!`, generated over its own private `Example` trait instead of
56//! `App` because that crate predates this one shipping a published equivalent.
57//!
58//! This file (kept in sync with the copy in `README.md` by a test) is
59//! `crates/terminal-wasm/js/xterm-driver.js` in the source tree:
60//!
61//! ```js
62#![doc = include_str!("../js/xterm-driver.js")]
63//! ```
64//!
65//! # Features
66//!
67//! <!-- gen-features:start -->
68//! This crate has no default features; every feature below is optional and off unless enabled.
69//!
70//! ### `dev`
71//!
72//! ⚪ Optional.
73//!
74//! Forwards `retroglyph-core`'s `dev` feature, which forces development diagnostics on in a build
75//! that would otherwise compile them out (see [`retroglyph_core::dev`]).
76//!
77//! ### `egc`
78//!
79//! ⚪ Optional.
80//!
81//! Forwards to `retroglyph-terminal`'s (and `retroglyph-core`'s) `egc` feature for
82//! grapheme-cluster-aware cell diffing.
83//! <!-- gen-features:end -->
84//!
85//! # ANSI sequences emitted
86//!
87//! [`TerminalWasm`] renders through [`retroglyph_terminal::TerminalRenderer`] (see that crate's
88//! docs for the full cell-diff renderer contract) and adds a handful of sequences of its own
89//! ([`clear`](Output::clear), [`set_cursor_visible`](Cursor::set_cursor_visible),
90//! [`set_cursor_style`](Cursor::set_cursor_style)). Every
91//! sequence below is standard ANSI X3.64 (ECMA-48) CSI (the subset xterm's own control-sequence
92//! reference calls plain "ANSI"/VT100-compatible), nothing proprietary or emulator-specific. The
93//! bytes are the same regardless of what emulator eventually reads them; this crate makes no
94//! attempt to detect or work around variance between implementations (see the quirks below for
95//! the two places that matters).
96//!
97//! | Sequence | Name | Emitted by | Meaning |
98//! | --- | --- | --- | --- |
99//! | `CSI Ps;Ps H` | CUP (Cursor Position) | [`draw`](Output::draw), for a non-adjacent cell; [`set_cursor_position`](Cursor::set_cursor_position) | move the cursor, 1-indexed `row;col`, always absolute |
100//! | `CSI 39 m` / `CSI 49 m` | SGR reset FG/BG | `draw`, for [`Color::Default`](retroglyph_core::color::Color::Default) | reset foreground/background to the emulator's default |
101//! | `CSI 3n m` / `CSI 4n m` (`30`-`37` / `40`-`47`) | SGR ANSI FG/BG | `draw`, for the standard 8 [`Color::Ansi`](retroglyph_core::color::Color::Ansi) values | set foreground/background to a standard ANSI color |
102//! | `CSI 9n m` / `CSI 10n m` (`90`-`97` / `100`-`107`) | SGR bright ANSI FG/BG | `draw`, for the bright 8 [`Color::Ansi`](retroglyph_core::color::Color::Ansi) values | set foreground/background to a bright ANSI color |
103//! | `CSI 38;5;n m` / `CSI 48;5;n m` | SGR indexed FG/BG | `draw`, for [`Color::Indexed`](retroglyph_core::color::Color::Indexed) | set foreground/background from the 256-color palette |
104//! | `CSI 38;2;r;g;b m` / `CSI 48;2;r;g;b m` | SGR truecolor FG/BG | `draw`, for [`Color::Rgb`](retroglyph_core::color::Color::Rgb) | set foreground/background to a 24-bit RGB color, unquantized |
105//! | `CSI ?2026 h` / `CSI ?2026 l` | DEC private mode 2026 (synchronized update) | every [`draw`](Output::draw)/[`flush`](Output::flush) pair | hold rendering until the matching end marker, avoiding tearing mid-frame |
106//! | `CSI ?25 h` / `CSI ?25 l` | DECTCEM (cursor visibility) | [`set_cursor_visible`](Cursor::set_cursor_visible) | show/hide the terminal cursor |
107//! | `CSI Ps SP q` | DECSCUSR (cursor shape) | [`set_cursor_style`](Cursor::set_cursor_style) | set the cursor's shape/blink behavior |
108//! | `CSI 2J` then `CSI H` | ED (erase display) + CUP home | [`clear`](Output::clear) | clear the screen, then move the cursor to `(1, 1)` |
109//!
110//! retroglyph does not model text attributes (bold, italic, underline, etc.; see
111//! [`retroglyph_core::color::Style`]'s docs for why), so no SGR attribute codes (`1`, `3`, `4`,
112//! ...) are ever emitted here; only the color and cursor/erase sequences above. Glyph bytes
113//! themselves (see [`take_output`](TerminalWasm::take_output)) are plain UTF-8, not an escape
114//! sequence.
115//!
116//! ## `TerminalRenderer` quirks to know before validating against a specific emulator
117//!
118//! - **Absolute positions only, never relative.** Every cursor move is a full CUP with both `row`
119//!   and `col`, even to step one cell right or down: there is no `CSI C`/`CSI B`
120//!   (cursor-relative) fallback.
121//!   [`TerminalRenderer::draw`](retroglyph_terminal::TerminalRenderer::draw) does skip the move
122//!   entirely when the cursor is already at the right cell from printing the previous glyph
123//!   (adjacent same-row cells), but it never emits a *relative* move to get there.
124//! - **No RGB-to-256/16-color quantization.** [`Color::Rgb`](retroglyph_core::color::Color::Rgb)
125//!   is always written as the 24-bit `38;2;...`/`48;2;...` form, even targeting an emulator that
126//!   only supports the 256-color or 16-color palette; downsampling (if any) is left entirely to
127//!   the receiving emulator. See `retroglyph-terminal`'s crate-level docs ("RGB color fallback on
128//!   256-color terminals") for the full rationale; use
129//!   [`Color::Indexed`](retroglyph_core::color::Color::Indexed) or
130//!   [`Color::Ansi`](retroglyph_core::color::Color::Ansi) instead when a specific emulator's color
131//!   depth is known ahead of time.
132//! - **`clear` always re-syncs tracked state.** [`clear`](Output::clear) additionally resets this
133//!   renderer's tracked cursor/color state, so the *next* `draw` call re-emits a full CUP and
134//!   color codes for every cell instead of (incorrectly) assuming the emulator remembers the old
135//!   state through the erase.
136//! - **`clear` resets SGR attributes before erasing.** [`clear`](Output::clear) emits `CSI 0 m`
137//!   ahead of `CSI 2J`, because most terminals implement erase-display via background color erase
138//!   (BCE) and paint the erased cells with whatever background is currently active in the pen, not
139//!   the emulator's true default. Without the reset, a cell colored by the last frame leaves its
140//!   tint across the whole screen after `clear`.
141//! - **Every `draw` is wrapped in its own synchronized-update pair.** [`draw`](Output::draw)
142//!   itself emits `CSI ?2026 h` before drawing, and the paired [`flush`](Output::flush) call
143//!   emits `CSI ?2026 l` after. An emulator that doesn't recognize DEC private mode 2026 ignores
144//!   both codes per the CSI spec's "unknown private mode" behavior, so this is safe to send
145//!   unconditionally.
146//!
147//! xterm's own control-sequence reference
148//! (<https://invisible-island.net/xterm/ctlseqs/ctlseqs.html>) and ECMA-48 (the formal standard
149//! behind "ANSI X3.64", <https://www.ecma-international.org/publications-and-standards/standards/ecma-48/>)
150//! are the normative references for every sequence in the table above. The synchronized-update
151//! mode (`2026`) isn't part of either: it follows the de facto convention specified at
152//! <https://gist.github.com/christianparpart/d8a62cc1ab659194337d73e399004036> and implemented by
153//! xterm.js, kitty, iTerm2, and others.
154
155#![cfg_attr(docsrs, feature(doc_cfg))]
156
157// Compile the code blocks in this crate's own README as doctests so its quick start is
158// type-checked on every test run and cannot silently rot. The `cfg(doctest)` gate keeps this out
159// of the rendered crate documentation: see `retroglyph-crossterm`'s matching include for the
160// same pattern applied to the workspace root README.
161#[cfg(doctest)]
162#[doc = include_str!("../README.md")]
163struct ReadmeDoctests;
164
165// `app_entry!`'s own doc comment (on the macro, not this private module) is what shows up in
166// rustdoc; `#[macro_export]` lifts it to the crate root regardless of this module's visibility,
167// the same pattern `examples/src/wasm_entry.rs` uses for its own private `Example`-based macros.
168mod app_entry;
169
170use retroglyph_core::backend::DrawCell;
171use retroglyph_core::backend::{Cursor, CursorStyle, Input, Output};
172use retroglyph_core::event::{Event, coalesces_with};
173use retroglyph_core::grid::HasSize;
174use retroglyph_core::grid::{Pos, Size};
175use retroglyph_core::terminal::Terminal;
176use retroglyph_terminal::TerminalRenderer;
177use std::collections::VecDeque;
178use std::io;
179use std::time::Duration;
180
181/// A `std::io::Write` sink backed by a `String` instead of a `Vec<u8>`.
182///
183/// [`TerminalRenderer`] only ever writes complete, already-valid-UTF-8 fragments into its writer:
184/// ASCII escape sequences from `write!`/`write_str` calls and glyphs sourced from `char`/`&str`
185/// (see [`TerminalWasm::take_output`]'s doc comment for the full argument). Writing straight into
186/// a `String`-backed sink instead of a `Vec<u8>` means [`take_output`](TerminalWasm::take_output)
187/// never has to re-validate those bytes as UTF-8 on drain, which a `Vec<u8>`-backed sink would
188/// require via `String::from_utf8` every frame: see retroglyph#288. `Write::write` still validates
189/// its input as UTF-8 (returning [`io::ErrorKind::InvalidData`] on failure) rather than trusting
190/// the caller, since the `std::io::Write` contract itself makes no UTF-8 guarantee about the
191/// bytes it's handed; only this crate's own, always-valid-UTF-8 call sites are relied upon to
192/// make that error path dead code in practice.
193#[derive(Debug, Default)]
194struct Utf8Sink(String);
195
196impl io::Write for Utf8Sink {
197    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
198        let s =
199            std::str::from_utf8(buf).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
200        self.0.push_str(s);
201        Ok(buf.len())
202    }
203
204    fn write_all(&mut self, buf: &[u8]) -> io::Result<()> {
205        self.write(buf).map(|_| ())
206    }
207
208    fn flush(&mut self) -> io::Result<()> {
209        Ok(())
210    }
211}
212
213/// A [`Backend`](retroglyph_core::backend::Backend) that renders into an in-memory ANSI byte buffer and accepts
214/// pushed input, for driving a browser terminal emulator from WASM.
215///
216/// Unlike [`retroglyph_crossterm::Crossterm`](https://docs.rs/retroglyph-crossterm),
217/// this backend:
218///
219/// - never queries a TTY for its size: call [`resize_terminal`] (or, if the input side doesn't
220///   matter for the caller, [`Terminal::resize`](retroglyph_core::terminal::Terminal::resize) directly)
221///   whenever the host reports a new size (e.g. from xterm.js's `fit` addon);
222/// - never polls for input: input only ever arrives via
223///   [`push_event`](Input::push_event), called from a `wasm-bindgen`
224///   entry point in response to a JS event;
225/// - buffers rendered ANSI bytes in memory rather than writing to a
226///   descriptor; call [`take_output`](Self::take_output) once per animation
227///   frame to drain them.
228pub struct TerminalWasm {
229    renderer: TerminalRenderer<Utf8Sink>,
230    size: Size,
231    event_queue: VecDeque<Event>,
232}
233
234/// The maximum number of events [`TerminalWasm::push_event`] will hold at once.
235///
236/// There is no OS-level backpressure here the way there is on native (crossterm's underlying
237/// input buffer naturally throttles a stalled reader): JS keeps calling the `wasm_terminal_push_*`
238/// entry points regardless of whether the Rust game loop is still draining
239/// [`poll_event`](Input::poll_event) every frame (e.g. a backgrounded tab throttling
240/// `requestAnimationFrame`, or the game loop itself stalling), so an unbounded queue can grow
241/// forever. `4096` is generously above any single-frame burst a real pointer/keyboard/paste stream
242/// should ever produce (a 250 Hz mouse plus a full frame of dropped rendering is still only a few
243/// hundred events), while staying small enough that hitting the cap and dropping the oldest event
244/// is a clearly abnormal, log-worthy condition rather than routine behavior.
245const EVENT_QUEUE_CAP: usize = 4096;
246
247impl TerminalWasm {
248    /// Creates a new backend with the given initial size in cells.
249    ///
250    /// # Examples
251    ///
252    /// The push-input/pull-ANSI cycle this backend is built around: push a synthetic key event
253    /// via [`Input::push_event`], draw a frame, then pull the rendered ANSI bytes back out with
254    /// [`take_output`](Self::take_output).
255    ///
256    /// ```
257    /// use retroglyph_core::backend::Input;
258    /// use retroglyph_core::event::{Event, KeyCode, KeyEvent, KeyModifiers};
259    /// use retroglyph_core::color::Style;
260    /// use retroglyph_core::terminal::Terminal;
261    /// use retroglyph_terminal_wasm::TerminalWasm;
262    ///
263    /// let mut backend = TerminalWasm::new(10, 3);
264    /// backend.push_event(Event::Key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)));
265    ///
266    /// let mut term = Terminal::new(backend);
267    /// assert_eq!(
268    ///     term.poll(std::time::Duration::ZERO),
269    ///     Some(Event::Key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)))
270    /// );
271    ///
272    /// term.draw(|s| s.put((0, 0), '@', Style::default()))?;
273    /// let ansi = term.backend_mut().take_output();
274    /// assert!(ansi.contains('@'), "output: {ansi:?}");
275    /// # Ok::<(), std::io::Error>(())
276    /// ```
277    #[must_use]
278    pub const fn new(width: u16, height: u16) -> Self {
279        Self {
280            renderer: TerminalRenderer::new(Utf8Sink(String::new())),
281            size: Size::new(width, height),
282            event_queue: VecDeque::new(),
283        }
284    }
285
286    /// Injects a synthetic input event into the queue, to be returned by the
287    /// next [`poll_event`](Input::poll_event) call.
288    ///
289    /// Called from JS (via the `wasm-bindgen` entry points below) or from
290    /// tests; not part of [`Backend`](retroglyph_core::backend::Backend) itself beyond the no-op default.
291    ///
292    /// Two mitigations guard against a stalled or throttled consumer (e.g. a backgrounded tab, or
293    /// a Rust game loop that's paused) while JS keeps forwarding input: there is no OS-level
294    /// backpressure here the way there is on native (crossterm's underlying input buffer
295    /// naturally throttles a stalled reader):
296    ///
297    /// - **Pointer-move/drag coalescing.** If `event`'s queue tail
298    ///   [`coalesces_with`](retroglyph_core::event::coalesces_with) it (both `Event::Mouse` with
299    ///   [`MouseEventKind::Moved`], or both with [`MouseEventKind::Drag`] carrying the same
300    ///   button), `event` replaces the tail in place instead of growing the queue: a consumer
301    ///   that's fallen behind only ever cares about the most recent pointer position, not every
302    ///   intermediate one. Any other event kind (including a `Down`/`Up`/scroll mouse event, or a
303    ///   `Drag` with a different button) always pushes normally, so this never reorders or merges
304    ///   anything but a `Moved` or same-button `Drag` run. The same rule is shared with the
305    ///   `retroglyph-window` backend and `Headless` (retroglyph#768).
306    /// - **Capacity cap.** Once the queue holds `EVENT_QUEUE_CAP` (4096) events, pushing another
307    ///   silently drops the *oldest* queued event (via `pop_front`) to make room. Oldest was
308    ///   chosen over dropping the new event so a consumer that's fallen behind and only pulls a
309    ///   few events per frame still gets its queue trending toward current/recent input as it
310    ///   catches up, rather than being stuck forever behind the exact backlog that triggered the
311    ///   cap. This is silent rather than logged: this crate's non-wasm32 API surface has no
312    ///   other fallible/observable operations and stays log-free (see the `wasm` module below for
313    ///   where this crate *does* use `log`, at the FFI boundary specifically), and a queue that's
314    ///   this far behind is already producing stale input for the consumer regardless.
315    ///
316    /// Crate-private: [`Input::push_event`] is the sole public way to push an event onto a
317    /// `TerminalWasm`, so there is only one way for external callers to do this.
318    pub(crate) fn push_event(&mut self, event: Event) {
319        if let Some(back) = self.event_queue.back_mut()
320            && coalesces_with(&event, back)
321        {
322            *back = event;
323            return;
324        }
325        if self.event_queue.len() >= EVENT_QUEUE_CAP {
326            self.event_queue.pop_front();
327        }
328        self.event_queue.push_back(event);
329    }
330
331    /// Drains and returns the ANSI bytes rendered since the last call, as a
332    /// UTF-8 string.
333    ///
334    /// Returns an empty string if nothing has been drawn since the last
335    /// call. Call this once per animation frame from JS and write the result
336    /// into the terminal emulator.
337    ///
338    /// This allocates a fresh `String` every call (the replacement buffer left behind is
339    /// pre-sized to the outgoing one's capacity, so a steady frame rate converges to one
340    /// right-sized allocation per frame instead of regrowing from empty each time; see
341    /// retroglyph#287). Callers that can reuse a long-lived, JS-side buffer across frames
342    /// should prefer [`take_output_into`](Self::take_output_into) instead, which never
343    /// allocates on the hot path.
344    #[must_use]
345    pub fn take_output(&mut self) -> String {
346        let sink = &mut self.renderer.writer_mut().0;
347        let replacement = String::with_capacity(sink.capacity());
348        std::mem::replace(sink, replacement)
349    }
350
351    /// Drains the ANSI bytes rendered since the last call into `buf`, clearing `buf` first.
352    ///
353    /// Equivalent to `*buf = self.take_output()` but reuses `buf`'s existing allocation instead
354    /// of returning a new `String` each call, and leaves this backend's own internal buffer
355    /// capacity untouched (just cleared) for the next frame, so neither side allocates once
356    /// `buf` has grown to its steady-state size. Intended for callers holding a long-lived
357    /// buffer across frames (e.g. a JS-side driver reusing the same `String` every animation
358    /// frame) instead of receiving a fresh allocation from [`take_output`](Self::take_output)
359    /// each time.
360    ///
361    /// # Examples
362    ///
363    /// ```
364    /// use retroglyph_core::color::Style;
365    /// use retroglyph_core::terminal::Terminal;
366    /// use retroglyph_terminal_wasm::TerminalWasm;
367    ///
368    /// let mut term = Terminal::new(TerminalWasm::new(10, 3));
369    /// term.draw(|s| s.put((0, 0), '@', Style::default()))?;
370    ///
371    /// let mut buf = String::from("stale contents");
372    /// term.backend_mut().take_output_into(&mut buf);
373    /// assert!(buf.contains('@'), "buf: {buf:?}");
374    /// assert!(!buf.contains("stale"));
375    /// # Ok::<(), std::io::Error>(())
376    /// ```
377    pub fn take_output_into(&mut self, buf: &mut String) {
378        buf.clear();
379        let sink = &mut self.renderer.writer_mut().0;
380        buf.push_str(sink);
381        sink.clear();
382    }
383}
384
385/// Resizes `term` to `(width, height)` cells, doing everything a correct resize needs in one call.
386///
387/// That's [`Terminal::resize`](retroglyph_core::terminal::Terminal::resize) (which itself resizes both grid
388/// buffers and calls [`Output::resize`] on the backend), plus queuing the matching
389/// [`Event::Resize`] so a driven [`App`](retroglyph_core::app::App) observes the new size through its
390/// own input handling too, exactly as it would from a native backend's real resize event.
391///
392/// Calling `term.resize(width, height)` directly (skipping this function) leaves nothing in the
393/// input queue: an app that reacts to `Event::Resize` rather than re-checking
394/// `term.backend().size()` every frame silently keeps its old layout. [`app_entry!`] already
395/// calls this on every `wasm_app_resize`; reach for it directly only when driving a
396/// `Terminal<TerminalWasm>` by hand instead of through that macro. See retroglyph#684.
397///
398/// # Examples
399///
400/// ```
401/// use retroglyph_core::terminal::Terminal;
402/// use retroglyph_core::backend::Output as _;
403/// use retroglyph_core::event::Event;
404/// use retroglyph_terminal_wasm::{TerminalWasm, resize_terminal};
405///
406/// let mut term = Terminal::new(TerminalWasm::new(10, 3));
407/// resize_terminal(&mut term, 20, 6);
408///
409/// assert_eq!(term.backend().size(), retroglyph_core::grid::Size::new(20, 6));
410/// assert_eq!(term.poll(std::time::Duration::ZERO), Some(Event::Resize(20, 6)));
411/// ```
412///
413/// [`app_entry!`]: crate::app_entry
414pub fn resize_terminal(term: &mut Terminal<TerminalWasm>, width: u16, height: u16) {
415    term.resize(width, height);
416    Input::push_event(term.backend_mut(), Event::Resize(width, height));
417}
418
419impl Output for TerminalWasm {
420    type Error = io::Error;
421
422    fn draw_layers<'a, I>(&mut self, content: I) -> Result<(), Self::Error>
423    where
424        I: Iterator<Item = DrawCell<'a>>,
425    {
426        // Silently drop cells positioned outside the grid, the same as `Headless` and
427        // `Software` already do: a caller-supplied `pos` is not trusted input, and this
428        // renderer (unlike those two) has no bounds check of its own to fall back on.
429        let size = self.size;
430        let content =
431            content.filter(move |cell| cell.pos.x < size.width() && cell.pos.y < size.height());
432        self.renderer.draw_frame(content)
433    }
434
435    fn flush(&mut self) -> Result<(), Self::Error> {
436        self.renderer.end_frame()
437    }
438
439    fn size(&self) -> Size {
440        self.size
441    }
442
443    fn resize(&mut self, size: Size) {
444        self.size = size;
445        // The host's terminal emulator clears/reflows on its own resize;
446        // forget our tracked cursor/color state so the next draw() re-emits
447        // full escape sequences instead of (incorrectly) skipping them.
448        self.renderer.reset_state();
449    }
450
451    fn clear(&mut self) -> Result<(), Self::Error> {
452        // Reset SGR attributes *before* erasing: most terminals implement "erase display" via
453        // background color erase (BCE), painting the erased cells with whatever background is
454        // currently active in the pen, not the terminal's true default. Left un-reset, a cell
455        // colored by the last frame (a themed panel, a highlighted tile) becomes the color the
456        // erase paints the whole screen with. This backend has no direct handle to the emulator,
457        // so both the reset and the "clear screen, cursor home" CSI sequence that follows it are
458        // just more ANSI bytes for JS to forward, same as every other draw call.
459        self.renderer.clear_screen()
460    }
461}
462
463impl Input for TerminalWasm {
464    fn poll_event(&mut self, _timeout: Duration) -> Option<Event> {
465        // Never blocks: there is no runtime loop to block in. `_timeout` is
466        // ignored, matching Headless's push-driven, non-blocking contract.
467        self.event_queue.pop_front()
468    }
469
470    /// The sole public way to push an event onto a `TerminalWasm`; forwards to the crate-private
471    /// inherent `push_event`.
472    fn push_event(&mut self, event: Event) {
473        Self::push_event(self, event);
474    }
475}
476
477impl Cursor for TerminalWasm {
478    fn set_cursor_visible(&mut self, visible: bool) {
479        let _ = self.renderer.set_cursor_visible(visible);
480    }
481
482    fn set_cursor_position(&mut self, position: Pos) {
483        let _ = self.renderer.move_cursor_to(position);
484    }
485
486    /// Writes the `DECSCUSR` cursor-shape escape.
487    ///
488    /// This is the standard VT100/ANSI (DEC private mode) cursor-shape sequence and is not one of
489    /// the areas where browser terminal emulators diverge: both xterm.js
490    /// (<https://xtermjs.org/docs/api/vtfeatures/>) and hterm/Terminalemulator
491    /// (<https://chromium.googlesource.com/apps/libapps/+/HEAD/hterm/docs/ControlSequences.md>)
492    /// implement `CSI Ps SP q` identically.
493    fn set_cursor_style(&mut self, style: CursorStyle) {
494        let _ = self.renderer.set_cursor_style(style);
495    }
496}
497
498/// Decodes a `(code, mods)` pair from JS into a [`retroglyph_core::event::KeyEvent`].
499///
500/// `retroglyph_core::event::KeyCode`/`KeyModifiers` are not `wasm-bindgen`
501/// FFI-safe types, so JS crosses the boundary with plain integers instead:
502///
503/// - `code`: for printable characters, the Unicode scalar value (as from
504///   `event.key.codePointAt(0)` for a single-character key); for named keys,
505///   one of [`key_codes`]'s constants (e.g. [`key_codes::LEFT`]).
506/// - `mods`: a bitmask matching [`retroglyph_core::event::KeyModifiers`]'s
507///   layout (`SHIFT = 1`, `CONTROL = 2`, `ALT = 4`, `SUPER = 8`). `SUPER` maps to the
508///   JS `metaKey` (Cmd on macOS, the Windows/Super key elsewhere).
509///
510/// # Examples
511///
512/// ```
513/// use retroglyph_core::event::KeyCode;
514/// use retroglyph_terminal_wasm::{decode_key_event, key_codes};
515///
516/// // A printable character: `code` is its Unicode scalar value.
517/// let key = decode_key_event(u32::from('a'), 0).unwrap();
518/// assert_eq!(key.code, KeyCode::Char('a'));
519///
520/// // A named key, with the Control modifier bit (0b010) set.
521/// let key = decode_key_event(key_codes::LEFT, 0b010).unwrap();
522/// assert_eq!(key.code, KeyCode::Left);
523/// assert!(key.modifiers.contains(retroglyph_core::event::KeyModifiers::CONTROL));
524///
525/// // A lone UTF-16 surrogate half is neither a named key nor a valid `char`.
526/// assert!(decode_key_event(0xD800, 0).is_none());
527/// ```
528#[must_use]
529pub fn decode_key_event(code: u32, mods: u8) -> Option<retroglyph_core::event::KeyEvent> {
530    use key_codes as kc;
531    use retroglyph_core::event::KeyCode;
532
533    let key_code = match code {
534        kc::BACKSPACE => KeyCode::Backspace,
535        kc::ENTER => KeyCode::Enter,
536        kc::LEFT => KeyCode::Left,
537        kc::RIGHT => KeyCode::Right,
538        kc::UP => KeyCode::Up,
539        kc::DOWN => KeyCode::Down,
540        kc::HOME => KeyCode::Home,
541        kc::END => KeyCode::End,
542        kc::PAGE_UP => KeyCode::PageUp,
543        kc::PAGE_DOWN => KeyCode::PageDown,
544        kc::TAB => KeyCode::Tab,
545        kc::BACKTAB => KeyCode::BackTab,
546        kc::DELETE => KeyCode::Delete,
547        kc::INSERT => KeyCode::Insert,
548        kc::ESCAPE => KeyCode::Escape,
549        kc::F1..=kc::F24 =>
550        {
551            #[allow(clippy::cast_possible_truncation)]
552            KeyCode::F((code - kc::F1 + 1) as u8)
553        }
554        _ => KeyCode::Char(char::from_u32(code)?),
555    };
556
557    Some(retroglyph_core::event::KeyEvent::new(
558        key_code,
559        decode_key_modifiers(mods),
560    ))
561}
562
563/// Decodes the shared `mods` bitmask used by [`decode_key_event`] and
564/// [`decode_mouse_event`] into a [`retroglyph_core::event::KeyModifiers`].
565///
566/// Bitmask layout: `SHIFT = 1`, `CONTROL = 2`, `ALT = 4`, `SUPER = 8`.
567#[must_use]
568const fn decode_key_modifiers(mods: u8) -> retroglyph_core::event::KeyModifiers {
569    retroglyph_core::event::KeyModifiers::from_bits_truncate(mods)
570}
571
572/// Decodes an `(x, y, action, button, mods)` tuple from JS into a
573/// [`retroglyph_core::event::MouseEvent`].
574///
575/// Same pattern as [`decode_key_event`]: `retroglyph_core::event::MouseEvent`
576/// (and its `MouseEventKind`/`MouseButton` fields) are not `wasm-bindgen`
577/// FFI-safe types, so JS crosses the boundary with plain integers instead:
578///
579/// - `x`, `y`: the cell-grid column/row the pointer is over, matching
580///   [`retroglyph_core::grid::Pos`]. JS is responsible for converting a raw
581///   pixel position (e.g. from a DOM `MouseEvent`) into cell coordinates
582///   using the terminal emulator's own cell size, the same way it already
583///   tracks `cols`/`rows` for the `wasm32`-only `wasm::wasm_terminal_resize`.
584///   This backend has no sub-cell precision to report, so the returned
585///   event's [`pixel_position`](retroglyph_core::event::MouseEvent::pixel_position)
586///   is always `None`: the same convention `retroglyph-crossterm` uses for
587///   its own character-mode backend.
588/// - `action`: one of [`mouse_actions`]'s constants (`DOWN`, `UP`, `MOVED`,
589///   `SCROLL_UP`, `SCROLL_DOWN`).
590/// - `button`: which button the event applies to, one of [`mouse_buttons`]'s
591///   constants (`LEFT`, `MIDDLE`, `RIGHT`), matching the DOM
592///   `MouseEvent.button` convention JS already has on hand. Only consulted
593///   when `action` is `DOWN` or `UP`; ignored otherwise.
594/// - `mods`: the same bitmask layout as [`decode_key_event`]'s `mods`
595///   (`SHIFT = 1`, `CONTROL = 2`, `ALT = 4`, `SUPER = 8`).
596///
597/// Returns `None` if `action` doesn't match a known [`mouse_actions`]
598/// constant, or if `action` is `DOWN`/`UP` and `button` doesn't match a known
599/// [`mouse_buttons`] constant.
600///
601/// # Examples
602///
603/// ```
604/// use retroglyph_core::event::{MouseButton, MouseEventKind};
605/// use retroglyph_terminal_wasm::{decode_mouse_event, mouse_actions, mouse_buttons};
606///
607/// let mouse = decode_mouse_event(3, 4, mouse_actions::DOWN, mouse_buttons::LEFT, 0).unwrap();
608/// assert_eq!(mouse.kind, MouseEventKind::Down(MouseButton::Left));
609/// assert_eq!(mouse.position, retroglyph_core::grid::Pos { x: 3, y: 4 });
610///
611/// // `button` is ignored for `MOVED`; an out-of-range value doesn't fail decoding.
612/// let mouse = decode_mouse_event(1, 1, mouse_actions::MOVED, 0xFF, 0).unwrap();
613/// assert_eq!(mouse.kind, MouseEventKind::Moved);
614///
615/// // An unknown `action` fails to decode.
616/// assert!(decode_mouse_event(0, 0, 0xFF, mouse_buttons::LEFT, 0).is_none());
617/// ```
618#[must_use]
619pub fn decode_mouse_event(
620    x: u16,
621    y: u16,
622    action: u8,
623    button: u8,
624    mods: u8,
625) -> Option<retroglyph_core::event::MouseEvent> {
626    use mouse_actions as ma;
627    use retroglyph_core::event::MouseEventKind;
628
629    let kind = match action {
630        ma::DOWN => MouseEventKind::Down(decode_mouse_button(button)?),
631        ma::UP => MouseEventKind::Up(decode_mouse_button(button)?),
632        ma::MOVED => MouseEventKind::Moved,
633        // Wire protocol has no scroll magnitude; synthesize a unit-magnitude `Scroll` matching
634        // the sign convention documented on `MouseEventKind::Scroll`.
635        ma::SCROLL_UP => MouseEventKind::Scroll { dx: 0.0, dy: 1.0 },
636        ma::SCROLL_DOWN => MouseEventKind::Scroll { dx: 0.0, dy: -1.0 },
637        _ => return None,
638    };
639
640    Some(retroglyph_core::event::MouseEvent::new(
641        kind,
642        Pos { x, y },
643        decode_key_modifiers(mods),
644    ))
645}
646
647const fn decode_mouse_button(button: u8) -> Option<retroglyph_core::event::MouseButton> {
648    use mouse_buttons as mb;
649    use retroglyph_core::event::MouseButton;
650
651    match button {
652        mb::LEFT => Some(MouseButton::Left),
653        mb::MIDDLE => Some(MouseButton::Middle),
654        mb::RIGHT => Some(MouseButton::Right),
655        _ => None,
656    }
657}
658
659/// `action` values for [`decode_mouse_event`].
660pub mod mouse_actions {
661    /// A mouse button was pressed. `button` selects which one.
662    pub const DOWN: u8 = 0;
663    /// A mouse button was released. `button` selects which one.
664    pub const UP: u8 = 1;
665    /// The mouse moved. `button` is ignored.
666    pub const MOVED: u8 = 2;
667    /// The mouse wheel scrolled up (away from the user). `button` is ignored.
668    pub const SCROLL_UP: u8 = 3;
669    /// The mouse wheel scrolled down (toward the user). `button` is ignored.
670    pub const SCROLL_DOWN: u8 = 4;
671}
672
673/// `button` values for [`decode_mouse_event`], matching the DOM
674/// `MouseEvent.button` convention (`0` = left, `1` = middle, `2` = right) so
675/// JS can forward its own `event.button` unchanged.
676pub mod mouse_buttons {
677    /// Left (primary) mouse button.
678    pub const LEFT: u8 = 0;
679    /// Middle (auxiliary) mouse button.
680    pub const MIDDLE: u8 = 1;
681    /// Right (secondary) mouse button.
682    pub const RIGHT: u8 = 2;
683}
684
685/// `code` values for [`decode_key_event`]'s named (non-printable) keys.
686///
687/// Values start at `0x0011_0000`, above the Unicode scalar value space
688/// (`0x0..=0x10FFFF`), so a single `u32` can carry either a codepoint or a
689/// named key without ambiguity.
690pub mod key_codes {
691    const BASE: u32 = 0x0011_0000;
692
693    /// Backspace.
694    pub const BACKSPACE: u32 = BASE;
695    /// Enter.
696    pub const ENTER: u32 = BASE + 1;
697    /// Left arrow.
698    pub const LEFT: u32 = BASE + 2;
699    /// Right arrow.
700    pub const RIGHT: u32 = BASE + 3;
701    /// Up arrow.
702    pub const UP: u32 = BASE + 4;
703    /// Down arrow.
704    pub const DOWN: u32 = BASE + 5;
705    /// Home.
706    pub const HOME: u32 = BASE + 6;
707    /// End.
708    pub const END: u32 = BASE + 7;
709    /// Page Up.
710    pub const PAGE_UP: u32 = BASE + 8;
711    /// Page Down.
712    pub const PAGE_DOWN: u32 = BASE + 9;
713    /// Tab.
714    pub const TAB: u32 = BASE + 10;
715    /// Backtab (Shift+Tab).
716    pub const BACKTAB: u32 = BASE + 11;
717    /// Delete.
718    pub const DELETE: u32 = BASE + 12;
719    /// Insert.
720    pub const INSERT: u32 = BASE + 13;
721    /// Escape.
722    pub const ESCAPE: u32 = BASE + 14;
723    /// F1. F2-F24 follow contiguously up to [`F24`].
724    pub const F1: u32 = BASE + 100;
725    /// F24, the last of the contiguous F1-F24 range.
726    pub const F24: u32 = F1 + 23;
727}
728
729/// The `wasm-bindgen`-exported FFI surface, compiled only for `wasm32`.
730///
731/// A thin, stateful wrapper around [`TerminalWasm`] keyed by opaque handles,
732/// since `wasm-bindgen` cannot export a type generic enough to hand a
733/// `TerminalWasm` (or a `Terminal<TerminalWasm>`) directly to arbitrary game
734/// code the way `retroglyph_examples::rg_run!` does for the software
735/// backend. Each example that wants a WASM/xterm.js demo drives its own
736/// per-example `#[wasm_bindgen]` entry point (see
737/// `crates/examples/src/util/mod.rs`'s `rg_run!` for the software
738/// equivalent); this module only owns the terminal instance registry and
739/// event decoding that every such entry point would otherwise duplicate.
740///
741/// Every `wasm_terminal_*` function taking a `handle` logs a warning (via the `log` crate) and
742/// otherwise does nothing if `handle` is unknown, e.g. because the terminal was already freed via
743/// [`wasm_terminal_free`].
744#[cfg(target_arch = "wasm32")]
745pub mod wasm {
746    use super::{TerminalWasm, decode_key_event, decode_mouse_event};
747    use std::cell::RefCell;
748    use std::collections::HashMap;
749    use wasm_bindgen::prelude::wasm_bindgen;
750
751    thread_local! {
752        static INSTANCES: RefCell<HashMap<u32, TerminalWasm>> = RefCell::new(HashMap::new());
753        static NEXT_HANDLE: RefCell<u32> = const { RefCell::new(1) };
754    }
755
756    /// Creates a new [`TerminalWasm`] of the given size and returns an opaque
757    /// handle for use with the other `wasm_terminal_*` functions.
758    #[wasm_bindgen]
759    #[must_use]
760    pub fn wasm_terminal_new(width: u16, height: u16) -> u32 {
761        let handle = NEXT_HANDLE.with_borrow_mut(|next| {
762            let handle = *next;
763            *next += 1;
764            handle
765        });
766        INSTANCES.with_borrow_mut(|instances| {
767            instances.insert(handle, TerminalWasm::new(width, height));
768        });
769        handle
770    }
771
772    /// Destroys the [`TerminalWasm`] identified by `handle`, freeing its
773    /// memory. Further calls with `handle` are no-ops.
774    #[wasm_bindgen]
775    pub fn wasm_terminal_free(handle: u32) {
776        INSTANCES.with_borrow_mut(|instances| {
777            instances.remove(&handle);
778        });
779    }
780
781    /// Reports a new size (in cells) for the terminal identified by
782    /// `handle`, e.g. after xterm.js's `fit` addon recomputes `cols`/`rows`.
783    #[wasm_bindgen]
784    pub fn wasm_terminal_resize(handle: u32, width: u16, height: u16) {
785        use retroglyph_core::backend::Output;
786        use retroglyph_core::grid::Size;
787        INSTANCES.with_borrow_mut(|instances| {
788            if let Some(term) = instances.get_mut(&handle) {
789                term.resize(Size::new(width, height));
790            } else {
791                log::warn!("wasm_terminal_resize: unknown handle {handle}");
792            }
793        });
794    }
795
796    /// Pushes a key event into the terminal identified by `handle`. See
797    /// [`decode_key_event`] for the `code`/`mods` encoding.
798    ///
799    /// Silently ignores codes that don't decode to a known key (e.g. a lone
800    /// Unicode combining mark with no assigned scalar meaning here).
801    #[wasm_bindgen]
802    pub fn wasm_terminal_push_key(handle: u32, code: u32, mods: u8) {
803        use retroglyph_core::event::Event;
804        let Some(key_event) = decode_key_event(code, mods) else {
805            return;
806        };
807        INSTANCES.with_borrow_mut(|instances| {
808            if let Some(term) = instances.get_mut(&handle) {
809                term.push_event(Event::Key(key_event));
810            } else {
811                log::warn!("wasm_terminal_push_key: unknown handle {handle}");
812            }
813        });
814    }
815
816    /// Pushes a mouse event into the terminal identified by `handle`. See
817    /// [`decode_mouse_event`] for the `x`/`y`/`action`/`button`/`mods`
818    /// encoding.
819    ///
820    /// Silently ignores an `action`/`button` combination that doesn't decode
821    /// to a known mouse event.
822    #[wasm_bindgen]
823    pub fn wasm_terminal_push_mouse(handle: u32, x: u16, y: u16, action: u8, button: u8, mods: u8) {
824        use retroglyph_core::event::Event;
825        let Some(mouse_event) = decode_mouse_event(x, y, action, button, mods) else {
826            return;
827        };
828        INSTANCES.with_borrow_mut(|instances| {
829            if let Some(term) = instances.get_mut(&handle) {
830                term.push_event(Event::Mouse(mouse_event));
831            } else {
832                log::warn!("wasm_terminal_push_mouse: unknown handle {handle}");
833            }
834        });
835    }
836
837    /// Pushes pasted text into the terminal identified by `handle`, delivered
838    /// as a single [`retroglyph_core::event::Event::Paste`] rather than
839    /// synthesized key events.
840    ///
841    /// Unlike [`wasm_terminal_push_key`], this takes a plain JS string
842    /// directly: `String` is already `wasm-bindgen`-FFI-safe, so there's no
843    /// `decode_*` step to pair with it, and no risk of a paste of `N`
844    /// characters being misread as `N` individual keystrokes (which would let
845    /// pasted text trigger single-key game commands one character at a
846    /// time).
847    ///
848    /// The driver is responsible for sourcing `text`: e.g. a native browser
849    /// `paste` event's `event.clipboardData.getData('text/plain')`, read
850    /// synchronously and without an Async Clipboard API permission prompt.
851    /// This crate has no opinion on *how* JS obtains the text, only that it
852    /// arrives here as one call per paste.
853    #[wasm_bindgen]
854    pub fn wasm_terminal_push_paste(handle: u32, text: String) {
855        use retroglyph_core::event::Event;
856        INSTANCES.with_borrow_mut(|instances| {
857            if let Some(term) = instances.get_mut(&handle) {
858                term.push_event(Event::Paste(text));
859            } else {
860                log::warn!("wasm_terminal_push_paste: unknown handle {handle}");
861            }
862        });
863    }
864
865    /// Pushes a focus-change event into the terminal identified by `handle`.
866    ///
867    /// `focused: true` delivers [`Event::FocusGained`](retroglyph_core::event::Event::FocusGained),
868    /// `focused: false` delivers
869    /// [`Event::FocusLost`](retroglyph_core::event::Event::FocusLost). Mirrors the crossterm
870    /// backend's `EnableFocusChange`-driven focus-event mapping, so a browser terminal element's
871    /// native `focus`/`blur` DOM events can drive the same "pause when unfocused" pattern.
872    #[wasm_bindgen]
873    pub fn wasm_terminal_push_focus(handle: u32, focused: bool) {
874        use retroglyph_core::event::Event;
875        let event = if focused {
876            Event::FocusGained
877        } else {
878            Event::FocusLost
879        };
880        INSTANCES.with_borrow_mut(|instances| {
881            if let Some(term) = instances.get_mut(&handle) {
882                term.push_event(event);
883            } else {
884                log::warn!("wasm_terminal_push_focus: unknown handle {handle}");
885            }
886        });
887    }
888
889    /// Drains and returns the ANSI bytes rendered since the last call for the
890    /// terminal identified by `handle`. Returns an empty string if `handle`
891    /// is unknown or nothing has been drawn since the last call.
892    #[wasm_bindgen]
893    #[must_use]
894    pub fn wasm_terminal_take_output(handle: u32) -> String {
895        INSTANCES.with_borrow_mut(|instances| {
896            let Some(term) = instances.get_mut(&handle) else {
897                log::warn!("wasm_terminal_take_output: unknown handle {handle}");
898                return String::new();
899            };
900            term.take_output()
901        })
902    }
903}
904
905#[cfg(test)]
906mod tests {
907    use super::*;
908    use retroglyph_core::color::Style;
909    use retroglyph_core::event::{Event, KeyCode, KeyModifiers};
910    use retroglyph_core::terminal::Terminal;
911
912    #[test]
913    fn renders_into_pullable_buffer() {
914        let backend = TerminalWasm::new(10, 3);
915        let mut term = Terminal::new(backend);
916        term.draw(|s| s.put((1, 1), 'H', Style::default())).unwrap();
917        let out = term.backend_mut().take_output();
918        assert!(out.contains('H'), "output: {out:?}");
919        // Draining again returns nothing new until the next present().
920        assert_eq!(term.backend_mut().take_output(), "");
921    }
922
923    #[test]
924    fn resize_terminal_resizes_grid_backend_and_queues_resize_event() {
925        // retroglyph#684: `term.resize(..)` alone (or just `Output::resize` on the backend)
926        // leaves nothing in the input queue, so an app that reacts to `Event::Resize` rather than
927        // re-checking `term.backend().size()` every frame would silently keep its old layout.
928        let mut term = Terminal::new(TerminalWasm::new(10, 3));
929        resize_terminal(&mut term, 20, 6);
930
931        assert_eq!(term.backend().size(), Size::new(20, 6));
932        assert_eq!(term.grid().width(), 20);
933        assert_eq!(term.grid().height(), 6);
934        assert_eq!(
935            term.poll(Duration::ZERO),
936            Some(Event::Resize(20, 6)),
937            "resize_terminal must queue the matching Event::Resize"
938        );
939    }
940
941    #[test]
942    fn take_output_retains_capacity_across_frames() {
943        // retroglyph#287: the replacement buffer left behind after a drain should be pre-sized
944        // to the outgoing buffer's capacity, not restart from zero every frame.
945        let backend = TerminalWasm::new(40, 12);
946        let mut term = Terminal::new(backend);
947        term.draw(|s| {
948            for y in 0..12 {
949                for x in 0..40 {
950                    s.put((x, y), 'X', Style::default());
951                }
952            }
953        })
954        .unwrap();
955        let first = term.backend_mut().take_output();
956        assert!(!first.is_empty());
957
958        // The internal sink left behind should already be sized to hold another frame of
959        // similar size without regrowing from an empty allocation.
960        let internal_capacity = term.backend_mut().renderer.writer().0.capacity();
961        assert!(
962            internal_capacity >= first.len(),
963            "expected the replacement buffer to retain the previous frame's capacity \
964             ({internal_capacity} < {})",
965            first.len()
966        );
967    }
968
969    #[test]
970    fn take_output_into_clears_and_fills_caller_buffer() {
971        let backend = TerminalWasm::new(10, 3);
972        let mut term = Terminal::new(backend);
973        term.draw(|s| s.put((1, 1), 'H', Style::default())).unwrap();
974
975        let mut buf = String::from("stale contents");
976        term.backend_mut().take_output_into(&mut buf);
977        assert!(buf.contains('H'), "buf: {buf:?}");
978        assert!(!buf.contains("stale"));
979
980        // Draining again with nothing new pending clears the caller's buffer to empty.
981        term.backend_mut().take_output_into(&mut buf);
982        assert_eq!(buf, "");
983    }
984
985    #[test]
986    fn clear_resets_sgr_attributes_before_erasing() {
987        // Regression test: `Output::clear` used to issue `CSI 2J` without resetting the SGR pen
988        // first. Terminals that implement erase-display via background color erase (BCE) paint
989        // erased cells with whatever background is currently active, not the terminal's true
990        // default, so a colored cell drawn just before a `clear()` left a stale tint across the
991        // whole screen. `clear` must emit a full SGR reset ahead of the erase so BCE always
992        // paints with the terminal's real default background. See retroglyph#715.
993        use retroglyph_core::color::Color;
994
995        let backend = TerminalWasm::new(10, 3);
996        let mut term = Terminal::new(backend);
997        let style = Style::default().bg(Color::Rgb { r: 200, g: 0, b: 0 });
998        term.draw(|s| s.put((0, 0), 'X', style)).unwrap();
999        let _ = term.backend_mut().take_output();
1000
1001        Output::clear(term.backend_mut()).unwrap();
1002
1003        let out = term.backend_mut().take_output();
1004        let clear_pos = out
1005            .rfind("\x1b[2J")
1006            .unwrap_or_else(|| panic!("clear() must emit CSI 2J, got: {out:?}"));
1007        let reset_pos = out
1008            .rfind("\x1b[0m")
1009            .unwrap_or_else(|| panic!("clear() must emit a full SGR reset, got: {out:?}"));
1010        assert!(
1011            reset_pos < clear_pos,
1012            "SGR reset must precede the erase so background color erase paints with the \
1013             terminal's true default, not the last frame's color; output: {out:?}"
1014        );
1015    }
1016
1017    #[test]
1018    fn push_event_then_poll_roundtrips() {
1019        let mut backend = TerminalWasm::new(10, 3);
1020        backend.push_event(Event::Key(retroglyph_core::event::KeyEvent::new(
1021            KeyCode::Left,
1022            KeyModifiers::NONE,
1023        )));
1024        assert_eq!(
1025            Input::poll_event(&mut backend, Duration::ZERO),
1026            Some(Event::Key(retroglyph_core::event::KeyEvent::new(
1027                KeyCode::Left,
1028                KeyModifiers::NONE
1029            )))
1030        );
1031        assert_eq!(Input::poll_event(&mut backend, Duration::ZERO), None);
1032    }
1033
1034    #[test]
1035    fn push_event_supports_paste_as_a_single_event() {
1036        // A paste is delivered as one `Event::Paste`, not one `Event::Key` per character; see
1037        // `wasm::wasm_terminal_push_paste`'s doc comment for why that distinction matters (pasted
1038        // text must not be misread as individual keystrokes triggering single-key commands).
1039        let mut backend = TerminalWasm::new(10, 3);
1040        backend.push_event(Event::Paste("hello, world".to_string()));
1041        assert_eq!(
1042            Input::poll_event(&mut backend, Duration::ZERO),
1043            Some(Event::Paste("hello, world".to_string()))
1044        );
1045        assert_eq!(Input::poll_event(&mut backend, Duration::ZERO), None);
1046    }
1047
1048    #[test]
1049    fn push_event_supports_focus_gained_and_lost() {
1050        let mut backend = TerminalWasm::new(10, 3);
1051        backend.push_event(Event::FocusGained);
1052        backend.push_event(Event::FocusLost);
1053        assert_eq!(
1054            Input::poll_event(&mut backend, Duration::ZERO),
1055            Some(Event::FocusGained)
1056        );
1057        assert_eq!(
1058            Input::poll_event(&mut backend, Duration::ZERO),
1059            Some(Event::FocusLost)
1060        );
1061        assert_eq!(Input::poll_event(&mut backend, Duration::ZERO), None);
1062    }
1063
1064    #[test]
1065    fn cursor_position_uses_1_indexed_cup_sequence() {
1066        // CSI row;col H (CUP), 1-indexed and absolute: verified against xterm.js and
1067        // hterm/Terminalemulator (see `TerminalRenderer::move_cursor_to`'s doc comment). Both use
1068        // the same format, so no dual-emission or 0-indexed fallback is needed.
1069        let mut backend = TerminalWasm::new(10, 3);
1070        Cursor::set_cursor_position(&mut backend, Pos { x: 0, y: 0 });
1071        assert_eq!(backend.take_output(), "\x1b[1;1H");
1072
1073        Cursor::set_cursor_position(&mut backend, Pos { x: 4, y: 2 });
1074        assert_eq!(backend.take_output(), "\x1b[3;5H");
1075    }
1076
1077    #[test]
1078    fn set_cursor_position_desyncs_the_renderers_tracked_cursor() {
1079        // Regression test for retroglyph#713: `set_cursor_position` wrote its CUP escape
1080        // straight into the writer without telling the shared `TerminalRenderer` the cursor had
1081        // moved, so `cursor_x`/`cursor_y` kept whatever position the last *drawn glyph* left them
1082        // at. A subsequent `draw()` whose first changed cell happened to match that stale tracked
1083        // position then skipped its own CUP entirely, painting wherever the real cursor was
1084        // actually left (by `set_cursor_position`) instead of the intended cell.
1085        use retroglyph_core::backend::DrawCell;
1086        use retroglyph_core::backend::Output;
1087
1088        let mut backend = TerminalWasm::new(10, 3);
1089        let tile_a = retroglyph_core::tile::Tile::new('A', Style::default());
1090        let tile_b = retroglyph_core::tile::Tile::new('B', Style::default());
1091
1092        // Drawing at (0, 0) leaves the renderer tracking the cursor at (1, 0), right after the
1093        // glyph it just wrote.
1094        Output::draw_layers(
1095            &mut backend,
1096            core::iter::once(DrawCell::new(Pos { x: 0, y: 0 }, &tile_a)),
1097        )
1098        .unwrap();
1099        Output::flush(&mut backend).unwrap();
1100
1101        // The app parks the caret elsewhere, e.g. a text field or status line, once per frame: a
1102        // common pattern that must not corrupt the next frame's diff.
1103        Cursor::set_cursor_position(&mut backend, Pos { x: 7, y: 3 });
1104        Output::flush(&mut backend).unwrap();
1105
1106        // The first (and only) changed cell in this frame is exactly (1, 0): the position
1107        // `set_cursor_position` desynced the tracked cursor from.
1108        Output::draw_layers(
1109            &mut backend,
1110            core::iter::once(DrawCell::new(Pos { x: 1, y: 0 }, &tile_b)),
1111        )
1112        .unwrap();
1113        Output::flush(&mut backend).unwrap();
1114
1115        let written = backend.take_output();
1116        let cup_1_0 = "\x1b[1;2H"; // 1-indexed CUP for (x=1, y=0)
1117        let b_pos = written
1118            .rfind('B')
1119            .unwrap_or_else(|| panic!("expected 'B' in output: {written:?}"));
1120        let cup_pos = written.rfind(cup_1_0).unwrap_or_else(|| {
1121            panic!("expected a CUP back to (1, 0) before drawing 'B', got: {written:?}")
1122        });
1123        assert!(
1124            cup_pos < b_pos,
1125            "CUP to (1, 0) must precede 'B': {written:?}"
1126        );
1127    }
1128
1129    #[test]
1130    fn set_cursor_style_writes_the_matching_decscusr_escape() {
1131        // retroglyph#767: `terminal-wasm` previously had no `Cursor::set_cursor_style`
1132        // implementation at all; it now delegates to the same `TerminalRenderer::set_cursor_style`
1133        // that `retroglyph-crossterm` uses, verified against the same DECSCUSR codes there.
1134        use retroglyph_core::backend::CursorStyle;
1135
1136        let mut backend = TerminalWasm::new(10, 3);
1137        Cursor::set_cursor_style(&mut backend, CursorStyle::BlinkingBar);
1138        assert_eq!(backend.take_output(), "\x1b[5 q");
1139    }
1140
1141    #[test]
1142    fn size_is_set_externally_not_queried() {
1143        let mut backend = TerminalWasm::new(80, 24);
1144        assert_eq!(backend.size(), Size::new(80, 24));
1145        backend.resize(Size::new(40, 12));
1146        assert_eq!(backend.size(), Size::new(40, 12));
1147    }
1148
1149    #[test]
1150    fn decode_key_event_maps_printable_char() {
1151        let key = decode_key_event(u32::from('a'), 0).unwrap();
1152        assert_eq!(key.code, KeyCode::Char('a'));
1153        assert!(key.modifiers.is_empty());
1154    }
1155
1156    #[test]
1157    fn decode_key_event_maps_named_keys() {
1158        let key = decode_key_event(key_codes::LEFT, 0b010).unwrap();
1159        assert_eq!(key.code, KeyCode::Left);
1160        assert!(key.modifiers.contains(KeyModifiers::CONTROL));
1161    }
1162
1163    #[test]
1164    fn decode_key_event_maps_super_modifier() {
1165        let key = decode_key_event(u32::from('x'), 0b1000).unwrap();
1166        assert_eq!(key.code, KeyCode::Char('x'));
1167        assert!(key.modifiers.contains(KeyModifiers::SUPER));
1168    }
1169
1170    #[test]
1171    fn decode_key_event_maps_super_combined_with_other_modifiers() {
1172        let key = decode_key_event(u32::from('x'), 0b1011).unwrap();
1173        assert!(key.modifiers.contains(KeyModifiers::SHIFT));
1174        assert!(key.modifiers.contains(KeyModifiers::CONTROL));
1175        assert!(key.modifiers.contains(KeyModifiers::SUPER));
1176        assert!(!key.modifiers.contains(KeyModifiers::ALT));
1177    }
1178
1179    #[test]
1180    fn decode_key_event_maps_function_keys() {
1181        let key = decode_key_event(key_codes::F1, 0).unwrap();
1182        assert_eq!(key.code, KeyCode::F(1));
1183
1184        let key = decode_key_event(key_codes::F1 + 11, 0).unwrap();
1185        assert_eq!(key.code, KeyCode::F(12));
1186    }
1187
1188    #[test]
1189    fn decode_key_event_maps_f24_the_last_of_the_contiguous_range() {
1190        let key = decode_key_event(key_codes::F24, 0).unwrap();
1191        assert_eq!(key.code, KeyCode::F(24));
1192    }
1193
1194    #[test]
1195    fn decode_key_event_one_past_f24_falls_through_to_char_decode_and_fails() {
1196        // `F24 + 1` is `BASE + 124`, past `char::MAX` (0x10FFFF): not a named key and not a
1197        // valid `char` either, so this must decode to `None`, not panic or wrap into a bogus
1198        // `KeyCode::F(25)`.
1199        assert!(decode_key_event(key_codes::F24 + 1, 0).is_none());
1200    }
1201
1202    #[test]
1203    fn decode_key_event_maps_every_named_key_constant() {
1204        use key_codes::{
1205            BACKSPACE, BACKTAB, DELETE, DOWN, END, ENTER, ESCAPE, HOME, INSERT, LEFT, PAGE_DOWN,
1206            PAGE_UP, RIGHT, TAB, UP,
1207        };
1208
1209        for &(code, expected) in &[
1210            (BACKSPACE, KeyCode::Backspace),
1211            (ENTER, KeyCode::Enter),
1212            (LEFT, KeyCode::Left),
1213            (RIGHT, KeyCode::Right),
1214            (UP, KeyCode::Up),
1215            (DOWN, KeyCode::Down),
1216            (HOME, KeyCode::Home),
1217            (END, KeyCode::End),
1218            (PAGE_UP, KeyCode::PageUp),
1219            (PAGE_DOWN, KeyCode::PageDown),
1220            (TAB, KeyCode::Tab),
1221            (BACKTAB, KeyCode::BackTab),
1222            (DELETE, KeyCode::Delete),
1223            (INSERT, KeyCode::Insert),
1224            (ESCAPE, KeyCode::Escape),
1225        ] {
1226            assert_eq!(decode_key_event(code, 0).unwrap().code, expected);
1227        }
1228    }
1229
1230    #[test]
1231    fn decode_key_event_accepts_the_null_char() {
1232        // 0x00 is a valid (if unusual) Unicode scalar value, and not a named key code.
1233        let key = decode_key_event(0x00, 0).unwrap();
1234        assert_eq!(key.code, KeyCode::Char('\0'));
1235    }
1236
1237    #[test]
1238    fn decode_key_event_rejects_lone_surrogate_half_code_points() {
1239        // 0xD800..=0xDFFF are lone UTF-16 surrogate halves: never a valid `char`, and outside
1240        // every named-key range, so this must decode to `None`, not panic.
1241        assert!(decode_key_event(0xD800, 0).is_none());
1242        assert!(decode_key_event(0xDFFF, 0).is_none());
1243    }
1244
1245    #[test]
1246    fn decode_key_event_rejects_out_of_range_code_points() {
1247        assert!(decode_key_event(u32::MAX, 0).is_none());
1248        // In the gap between the named control-key block (`BASE..=BASE+14`) and the F-key block
1249        // (`BASE+100..=BASE+123`): not a named key, and (like every named-key code, since
1250        // `BASE` (`0x0011_0000`) sits one past `char::MAX` (0x10FFFF)) not a valid `char`
1251        // either. `BASE` itself is *not* an out-of-range example: it's `KeyCode::Backspace`.
1252        assert!(decode_key_event(key_codes::ESCAPE + 1, 0).is_none());
1253    }
1254
1255    #[test]
1256    fn decode_mouse_event_maps_button_down() {
1257        use retroglyph_core::event::{MouseButton, MouseEventKind};
1258
1259        let mouse = decode_mouse_event(3, 4, mouse_actions::DOWN, mouse_buttons::LEFT, 0).unwrap();
1260        assert_eq!(mouse.kind, MouseEventKind::Down(MouseButton::Left));
1261        assert_eq!(mouse.position, Pos { x: 3, y: 4 });
1262        assert_eq!(mouse.pixel_position, None);
1263        assert!(mouse.modifiers.is_empty());
1264    }
1265
1266    #[test]
1267    fn decode_mouse_event_maps_button_up() {
1268        use retroglyph_core::event::{MouseButton, MouseEventKind};
1269
1270        let mouse = decode_mouse_event(0, 0, mouse_actions::UP, mouse_buttons::RIGHT, 0).unwrap();
1271        assert_eq!(mouse.kind, MouseEventKind::Up(MouseButton::Right));
1272    }
1273
1274    #[test]
1275    fn decode_mouse_event_maps_moved_ignoring_button() {
1276        use retroglyph_core::event::MouseEventKind;
1277
1278        // `button` is ignored for `MOVED`; an out-of-range value must not fail decoding.
1279        let mouse = decode_mouse_event(1, 1, mouse_actions::MOVED, 0xFF, 0).unwrap();
1280        assert_eq!(mouse.kind, MouseEventKind::Moved);
1281    }
1282
1283    #[test]
1284    fn decode_mouse_event_maps_scroll() {
1285        use retroglyph_core::event::MouseEventKind;
1286
1287        let up = decode_mouse_event(0, 0, mouse_actions::SCROLL_UP, 0, 0).unwrap();
1288        assert_eq!(up.kind, MouseEventKind::Scroll { dx: 0.0, dy: 1.0 });
1289
1290        let down = decode_mouse_event(0, 0, mouse_actions::SCROLL_DOWN, 0, 0).unwrap();
1291        assert_eq!(down.kind, MouseEventKind::Scroll { dx: 0.0, dy: -1.0 });
1292    }
1293
1294    #[test]
1295    fn decode_mouse_event_maps_modifiers() {
1296        let mouse =
1297            decode_mouse_event(0, 0, mouse_actions::DOWN, mouse_buttons::MIDDLE, 0b1101).unwrap();
1298        assert!(mouse.modifiers.contains(KeyModifiers::SHIFT));
1299        assert!(mouse.modifiers.contains(KeyModifiers::ALT));
1300        assert!(mouse.modifiers.contains(KeyModifiers::SUPER));
1301        assert!(!mouse.modifiers.contains(KeyModifiers::CONTROL));
1302    }
1303
1304    #[test]
1305    fn decode_mouse_event_rejects_unknown_action() {
1306        assert!(decode_mouse_event(0, 0, 0xFF, mouse_buttons::LEFT, 0).is_none());
1307    }
1308
1309    #[test]
1310    fn decode_mouse_event_rejects_unknown_button_for_down_and_up() {
1311        assert!(decode_mouse_event(0, 0, mouse_actions::DOWN, 0xFF, 0).is_none());
1312        assert!(decode_mouse_event(0, 0, mouse_actions::UP, 0xFF, 0).is_none());
1313    }
1314
1315    #[test]
1316    fn push_event_caps_queue_and_drops_oldest_under_burst() {
1317        // A burst well past `EVENT_QUEUE_CAP`, of non-coalescing events (alternating key codes,
1318        // so nothing here hits the `Moved` coalescing path) must never grow the queue past the
1319        // cap, and the oldest entries are the ones dropped: the earliest surviving key should
1320        // be from partway through the burst, not from the very start.
1321        let mut backend = TerminalWasm::new(10, 3);
1322        let total = EVENT_QUEUE_CAP + 500;
1323        for i in 0..total {
1324            #[allow(clippy::cast_possible_truncation)]
1325            let code = u32::from(b'a') + (i % 26) as u32;
1326            backend.push_event(Event::Key(retroglyph_core::event::KeyEvent::new(
1327                KeyCode::Char(char::from_u32(code).unwrap()),
1328                KeyModifiers::NONE,
1329            )));
1330        }
1331        assert_eq!(backend.event_queue.len(), EVENT_QUEUE_CAP);
1332
1333        // Drain and count: still exactly the cap's worth of events, none created out of thin air.
1334        let mut drained = 0usize;
1335        while Input::poll_event(&mut backend, Duration::ZERO).is_some() {
1336            drained += 1;
1337        }
1338        assert_eq!(drained, EVENT_QUEUE_CAP);
1339    }
1340
1341    #[test]
1342    fn push_event_coalesces_consecutive_moved_events() {
1343        use retroglyph_core::event::{MouseButton, MouseEvent, MouseEventKind};
1344
1345        let mut backend = TerminalWasm::new(80, 24);
1346        for x in 0..200u16 {
1347            backend.push_event(Event::Mouse(MouseEvent::new(
1348                MouseEventKind::Moved,
1349                Pos { x, y: 0 },
1350                KeyModifiers::NONE,
1351            )));
1352        }
1353
1354        // All 200 `Moved` pushes collapsed into a single queued event, holding only the latest
1355        // position.
1356        assert_eq!(backend.event_queue.len(), 1);
1357        assert_eq!(
1358            Input::poll_event(&mut backend, Duration::ZERO),
1359            Some(Event::Mouse(MouseEvent::new(
1360                MouseEventKind::Moved,
1361                Pos { x: 199, y: 0 },
1362                KeyModifiers::NONE,
1363            )))
1364        );
1365        assert_eq!(Input::poll_event(&mut backend, Duration::ZERO), None);
1366
1367        // A non-`Moved` mouse event breaks the coalescing run: it queues alongside, not merged
1368        // into, a preceding `Moved`.
1369        backend.push_event(Event::Mouse(MouseEvent::new(
1370            MouseEventKind::Moved,
1371            Pos { x: 1, y: 1 },
1372            KeyModifiers::NONE,
1373        )));
1374        backend.push_event(Event::Mouse(MouseEvent::new(
1375            MouseEventKind::Down(MouseButton::Left),
1376            Pos { x: 1, y: 1 },
1377            KeyModifiers::NONE,
1378        )));
1379        assert_eq!(backend.event_queue.len(), 2);
1380    }
1381
1382    #[test]
1383    fn push_event_never_drops_or_coalesces_non_moved_events_under_burst() {
1384        use retroglyph_core::event::{MouseButton, MouseEvent, MouseEventKind};
1385
1386        // A burst of distinct key presses, mouse clicks, and pastes (well under the cap, and
1387        // none of them `Moved`) must all survive untouched: no coalescing (they're not `Moved`)
1388        // and no dropping (the burst never reaches `EVENT_QUEUE_CAP`).
1389        let mut backend = TerminalWasm::new(80, 24);
1390        let mut expected = Vec::new();
1391
1392        for i in 0..50u16 {
1393            let key = Event::Key(retroglyph_core::event::KeyEvent::new(
1394                KeyCode::Char('a'),
1395                KeyModifiers::NONE,
1396            ));
1397            backend.push_event(key.clone());
1398            expected.push(key);
1399
1400            let click = Event::Mouse(MouseEvent::new(
1401                MouseEventKind::Down(MouseButton::Left),
1402                Pos { x: i, y: i },
1403                KeyModifiers::NONE,
1404            ));
1405            backend.push_event(click.clone());
1406            expected.push(click);
1407
1408            let paste = Event::Paste(format!("paste-{i}"));
1409            backend.push_event(paste.clone());
1410            expected.push(paste);
1411        }
1412
1413        assert_eq!(backend.event_queue.len(), expected.len());
1414        for expected_event in expected {
1415            assert_eq!(
1416                Input::poll_event(&mut backend, Duration::ZERO),
1417                Some(expected_event)
1418            );
1419        }
1420        assert_eq!(Input::poll_event(&mut backend, Duration::ZERO), None);
1421    }
1422
1423    // Guards against the JS driver example silently forking into two different (and eventually
1424    // contradictory) versions: one embedded in this crate's own doc comment (via `include_str!`
1425    // of `js/xterm-driver.js`, see the module doc above) and one hand-copied into README.md's
1426    // "Usage from JS" section, since crates.io/GitHub render README.md as plain markdown and
1427    // can't `include_str!` it the way rustdoc can. `js/xterm-driver.js` is the source of truth;
1428    // if this test fails, README.md's fenced ```js block fell out of sync with it.
1429    #[test]
1430    fn readme_js_example_matches_canonical_driver() {
1431        let canonical = include_str!("../js/xterm-driver.js");
1432        let readme = include_str!("../README.md");
1433
1434        let fence_start = readme
1435            .find("```js\n")
1436            .expect("README.md should have a ```js fenced code block")
1437            + "```js\n".len();
1438        let fence_end = readme[fence_start..]
1439            .find("```")
1440            .expect("README.md's ```js fence should be closed")
1441            + fence_start;
1442        let readme_js = &readme[fence_start..fence_end];
1443
1444        assert_eq!(
1445            readme_js, canonical,
1446            "README.md's JS driver example has drifted from js/xterm-driver.js: update \
1447             README.md's fenced ```js block to match the canonical file"
1448        );
1449    }
1450
1451    // ── Output/Cursor conformance (retroglyph#763) ──────────────────────────────────────────
1452
1453    /// [`Observable::snapshot`] hashes only the bytes appended since the previous call, per that
1454    /// trait's docs: `TerminalWasm`'s observable state is an append-only ANSI byte buffer, and
1455    /// [`TerminalWasm::take_output`] already drains exactly that (nothing new to build here).
1456    impl retroglyph_core::testing::conformance::Observable for TerminalWasm {
1457        fn snapshot(&mut self) -> u64 {
1458            retroglyph_core::testing::conformance::fnv1a(self.take_output().as_bytes())
1459        }
1460    }
1461
1462    #[test]
1463    fn satisfies_the_output_contract() {
1464        retroglyph_core::testing::conformance::assert_output_contract(|size| {
1465            TerminalWasm::new(size.width(), size.height())
1466        });
1467    }
1468
1469    #[test]
1470    fn satisfies_the_input_contract() {
1471        retroglyph_core::testing::conformance::assert_input_contract(|| TerminalWasm::new(10, 10));
1472    }
1473
1474    #[test]
1475    fn satisfies_the_cursor_contract() {
1476        retroglyph_core::testing::conformance::assert_cursor_contract(|size| {
1477            TerminalWasm::new(size.width(), size.height())
1478        });
1479    }
1480
1481    #[test]
1482    fn satisfies_the_cursor_style_contract() {
1483        retroglyph_core::testing::conformance::assert_cursor_style_contract(|size| {
1484            TerminalWasm::new(size.width(), size.height())
1485        });
1486    }
1487}
1488
1489/// Fuzzes [`decode_key_event`] over arbitrary `(code, mods)` pairs: no `(u32, u8)` input may
1490/// panic, produce invalid Unicode, or produce an out-of-bounds F-key index. See
1491/// `crates/core/src/grid.rs`'s `egc_proptests` module for the same pattern applied elsewhere in
1492/// the workspace.
1493///
1494/// Not compiled for `wasm32`: `proptest` pulls in `rand`/`getrandom`, and `getrandom` does not
1495/// build for `wasm32-unknown-unknown` without extra feature wiring that `just test-wasm`'s
1496/// `wasm-pack` build doesn't do (see the matching `proptest` dev-dependency's cfg gate in
1497/// `Cargo.toml`). `decode_key_event` itself has no wasm-specific behavior, so host-only coverage
1498/// is sufficient.
1499#[cfg(all(test, not(target_arch = "wasm32")))]
1500mod decode_key_event_proptests {
1501    use super::*;
1502    use proptest::prelude::*;
1503    use retroglyph_core::event::{KeyCode, KeyModifiers};
1504
1505    /// The contiguous `BASE..=BASE+14` block of named, non-`char`, non-F control keys
1506    /// ([`key_codes::BACKSPACE`] through [`key_codes::ESCAPE`]).
1507    fn is_named_control_key(code: u32) -> bool {
1508        (key_codes::BACKSPACE..=key_codes::ESCAPE).contains(&code)
1509    }
1510
1511    proptest! {
1512        /// Every `(code, mods)` pair must be explainable by exactly one of three cases (a
1513        /// named control key, the contiguous F1-F24 range, or a valid printable `char`) and
1514        /// must never panic getting there.
1515        #[test]
1516        fn never_panics_and_result_matches_one_of_three_cases(code: u32, mods: u8) {
1517            let result = decode_key_event(code, mods);
1518
1519            if is_named_control_key(code) {
1520                let key = result.expect("named control key code decoded to None");
1521                assert!(
1522                    !matches!(key.code, KeyCode::Char(_) | KeyCode::F(_)),
1523                    "named control key code {code:#x} decoded to {:?}",
1524                    key.code
1525                );
1526            } else if (key_codes::F1..=key_codes::F24).contains(&code) {
1527                let key = result.expect("F1..=F24 range code decoded to None");
1528                let KeyCode::F(n) = key.code else {
1529                    panic!("F-key range code {code:#x} decoded to {:?}, not KeyCode::F", key.code);
1530                };
1531                assert!((1..=24).contains(&n), "F-key index {n} out of the documented 1..=24 range");
1532                assert_eq!(u32::from(n), code - key_codes::F1 + 1);
1533            } else if let Some(c) = char::from_u32(code) {
1534                let key = result.expect("valid, non-named char code decoded to None");
1535                assert_eq!(key.code, KeyCode::Char(c));
1536            } else {
1537                // Not a named key, not an F-key, and not a valid Unicode scalar value (a lone
1538                // UTF-16 surrogate half, or anything past `char::MAX`): must decode to `None`,
1539                // not panic or fabricate a `KeyCode`.
1540                assert!(
1541                    result.is_none(),
1542                    "invalid Unicode code point {code:#x} decoded to {:?} instead of None",
1543                    result.map(|k| k.code)
1544                );
1545            }
1546        }
1547
1548        /// Modifier decoding is independent of `code`: every bit of `mods` maps to exactly one
1549        /// `KeyModifiers` flag, for every key that successfully decodes.
1550        #[test]
1551        fn modifiers_decode_independently_of_code(code: u32, mods: u8) {
1552            if let Some(key) = decode_key_event(code, mods) {
1553                assert_eq!(key.modifiers.contains(KeyModifiers::SHIFT), mods & 0b0001 != 0);
1554                assert_eq!(key.modifiers.contains(KeyModifiers::CONTROL), mods & 0b0010 != 0);
1555                assert_eq!(key.modifiers.contains(KeyModifiers::ALT), mods & 0b0100 != 0);
1556                assert_eq!(key.modifiers.contains(KeyModifiers::SUPER), mods & 0b1000 != 0);
1557            }
1558        }
1559    }
1560}