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}