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