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