Skip to main content

retroglyph_crossterm/
lib.rs

1//! [`Output`], [`Input`], and [`Cursor`] implementations that render to a real terminal via
2//! `crossterm`, bundled together as [`Backend`](retroglyph_core::backend::Backend).
3//!
4//! This crate owns the OS/TTY-specific parts: raw mode, the alternate
5//! screen, the kitty keyboard protocol, and `crossterm::event` polling.
6//! Cell-diffing and ANSI/SGR output are delegated to
7//! [`retroglyph_terminal::TerminalRenderer`].
8//!
9//! [`draw`](Output::draw), [`flush`](Output::flush), and
10//! [`clear`](Output::clear) propagate `std::io::Error` through this
11//! backend's [`Output::Error`] type. `resize`, `set_cursor_visible`, and
12//! `set_cursor_position` are infallible ([`Output::resize`] and the [`Cursor`] methods have no
13//! `Result` return), so I/O failures in those methods (e.g. a closed terminal or disconnected
14//! pipe) are discarded silently rather than surfaced.
15//!
16//! # Event polling and CPU cost
17//!
18//! [`poll_event`](Input::poll_event) wraps a single `crossterm::event::poll()` syscall per call.
19//! A zero timeout (as used by
20//! [`Terminal::drain_events`](retroglyph_core::terminal::Terminal::drain_events) to drain everything
21//! buffered without blocking) performs one non-blocking `crossterm::event::poll(Duration::ZERO)`
22//! syscall (`select`/`epoll` under the hood), not a busy spin inside `poll_event` itself: once
23//! the OS reports no data waiting, it returns `None` immediately rather than looping. The actual
24//! CPU cost lives one level up, in the caller's game loop: an uncapped loop that calls
25//! `drain_events()` every iteration with no frame limiter (no `sleep`, no vsync wait) will issue
26//! that non-blocking syscall as fast as the CPU allows, trading power/CPU usage for input
27//! latency.
28//!
29//! # Focus and lifecycle events
30//!
31//! With [`CrosstermOptions::focus_change`] enabled (the default), a terminal losing and regaining
32//! input focus is reported as [`Event::FocusLost`]/[`Event::FocusGained`]. This is the only
33//! lifecycle signal this backend currently has: unlike a windowed backend, there's no separate
34//! "suspended"/"paused" notion here, and this crate maps every focus change the same way
35//! regardless of the underlying reason (window manager focus switch, terminal minimized, or,
36//! notably on Wayland compositors, a terminal surface being hidden or unmapped without an
37//! accompanying resize).
38//!
39//! Terminal-side state (raw mode, the alternate screen, cursor position, last-written
40//! colors/attributes) is untouched by a focus change and is preserved across it: this backend
41//! does not react to [`Event::FocusLost`]/[`Event::FocusGained`] itself, so nothing is torn down
42//! or reinitialized. Rendering is not deferred automatically either: [`Output::draw`] and
43//! [`Output::flush`] keep writing escape sequences to stdout even while unfocused, since
44//! crossterm has no OS-level way to know whether that output is actually being presented while
45//! hidden. An app that wants to pause redraws while unfocused (e.g. to avoid wasted work on a
46//! backgrounded Wayland surface) should track [`Event::FocusLost`]/[`Event::FocusGained`] itself
47//! and skip its own draw calls in between.
48//!
49//! If `retroglyph-core` later adds a dedicated `Event::Suspended` (or similar) distinct from
50//! plain focus loss, this crate would need coordinated changes with `retroglyph-window` (which
51//! shares the `Event` enum) before mapping anything to it; no such variant exists today, so there
52//! is nothing for this backend to emit.
53//!
54//! # Tracing
55//!
56//! With the optional `tracing` feature enabled, [`Output::draw`], [`Output::flush`], and
57//! [`Input::poll_event`] are each wrapped in a `tracing` span (`debug` level for `draw`/`flush`,
58//! `trace` for `poll_event` since it's called every game-loop iteration by
59//! [`Terminal::drain_events`](retroglyph_core::terminal::Terminal::drain_events)), so a subscriber (e.g.
60//! `tracing-subscriber`'s fmt layer, or a flamegraph via `tracing-flame`) can show where render
61//! and input-polling time actually goes. The feature adds no code and no dependency when disabled.
62//!
63//! # Features
64//!
65//! <!-- gen-features:start -->
66//! This crate has no default features; every feature below is optional and off unless enabled.
67//!
68//! ### `dev`
69//!
70//! ⚪ Optional.
71//!
72//! Forwards `retroglyph-core`'s `dev` feature, which forces development diagnostics on in a build
73//! that would otherwise compile them out (see [`retroglyph_core::dev`]).
74//!
75//! ### `egc`
76//!
77//! ⚪ Optional.
78//!
79//! Forwards to `retroglyph-terminal`'s `egc` feature (which forwards to `retroglyph-core`'s),
80//! enabling grapheme-cluster-aware cell diffing.
81//!
82//! This crate has no code of its own gated on the flag; it exposes it so callers don't need to know
83//! which crate in the terminal family actually implements it.
84//!
85//! ### `tracing`
86//!
87//! ⚪ Optional.
88//!
89//! Instruments `draw`, `flush`, and `poll_event` with `tracing` spans for profiling render/input
90//! time.
91//!
92//! See where time is spent with any `tracing` subscriber (e.g. `tracing-subscriber`'s fmt layer, or
93//! a flamegraph via `tracing-flame`).
94//! <!-- gen-features:end -->
95//!
96//! # Content writer
97//!
98//! [`Crossterm`] is generic over its content writer: `Crossterm<W>`, defaulting to
99//! `BufWriter<Stdout>` to match this type's historical, stdout-only behavior. Use
100//! [`Crossterm::with_writer`] or [`CrosstermOptions::build_with_writer`] to render into a file,
101//! a pipe, or an in-memory buffer instead, e.g. to capture and assert on the emitted ANSI/SGR
102//! bytes in a test without a real TTY. Only the rendered cell content goes through `W`; raw
103//! mode, the alternate screen, and the other terminal-protocol negotiation always target the
104//! real process stdout regardless of `W`: see [`CrosstermOptions::build_with_writer`]'s docs
105//! for the exact split.
106
107#![doc(
108    html_logo_url = "https://raw.githubusercontent.com/crates-lurey-io/retroglyph/main/docs/public/assets/logo.svg"
109)]
110#![doc(
111    html_favicon_url = "https://raw.githubusercontent.com/crates-lurey-io/retroglyph/main/docs/public/assets/logo.svg"
112)]
113#![cfg_attr(docsrs, feature(doc_cfg))]
114
115// Compile the code blocks in this crate's own README as doctests so its quick start is
116// type-checked on every test run and cannot silently rot. The `cfg(doctest)` gate keeps this out
117// of the rendered crate documentation. The workspace root README's own quick start is doctested
118// from `retroglyph` (the facade crate) instead, since that's what it demonstrates now.
119#[cfg(doctest)]
120#[doc = include_str!("../README.md")]
121struct ReadmeDoctests;
122
123use core::time::Duration;
124use retroglyph_core::backend::DrawCell;
125use retroglyph_core::backend::{Cursor, CursorStyle, Input, Output};
126use retroglyph_core::event::Event;
127use retroglyph_core::grid::HasSize;
128use retroglyph_core::grid::{Pos, Size};
129use retroglyph_core::terminal::Terminal;
130use retroglyph_terminal::TerminalRenderer;
131use std::collections::VecDeque;
132use std::io::{BufWriter, IsTerminal, Stdout};
133
134// Orphan-rule note: `retroglyph_core` types and `crossterm` types are both
135// foreign to this crate now that the workspace is split, so `From`/`TryFrom`
136// impls between them are no longer legal (neither type is local). These are
137// plain conversion functions instead.
138
139/// Keyboard enhancement flags requested when the terminal supports the kitty
140/// keyboard protocol. `REPORT_EVENT_TYPES` is what upgrades us from press-only
141/// to press/repeat/release; `DISAMBIGUATE_ESCAPE_CODES` makes modified keys
142/// unambiguous and is also what unlocks `CapsLock`/`ScrollLock`/`NumLock`/
143/// `PrintScreen`/`Pause`/`Menu` reporting at all. `REPORT_ALL_KEYS_AS_ESCAPE_CODES` is what
144/// additionally unlocks a bare modifier press (`crossterm::event::KeyCode::Modifier`, mapped to
145/// [`retroglyph_core::event::KeyCode::Modifier`]) being reported as its own key event instead of
146/// being silently absorbed into the modifiers field of the next non-modifier key.
147fn keyboard_enhancement_flags() -> crossterm::event::KeyboardEnhancementFlags {
148    crossterm::event::KeyboardEnhancementFlags::REPORT_EVENT_TYPES
149        | crossterm::event::KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
150        | crossterm::event::KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES
151}
152
153/// Chooses a [`ColorSupport`](retroglyph_terminal::ColorSupport) level from the de facto
154/// `$NO_COLOR`/`$TERM` conventions, as a pure function of their values so it's testable without
155/// mutating real process environment variables.
156///
157/// - `no_color` any non-empty value (per <https://no-color.org>) forces
158///   [`ColorSupport::None`](retroglyph_terminal::ColorSupport::None).
159/// - `term` equal to `"dumb"` (the canonical "assume nothing" signal used by Emacs' shell mode,
160///   some CI systems, and similar) also forces `ColorSupport::None`. A bare `"dumb"` is never
161///   emitted by a terminal that actually supports color, so this carries no false-positive risk,
162///   unlike the heuristic described next.
163/// - Otherwise, [`ColorSupport::Truecolor`](retroglyph_terminal::ColorSupport::Truecolor):
164///   matches [`TerminalRenderer`](retroglyph_terminal::TerminalRenderer)'s own default and this
165///   crate's pre-existing behavior (always pass `Color::Rgb` through verbatim). Degrading below
166///   this is opt-in on one of the two positive signals above; their absence is not itself a
167///   signal of anything narrower.
168///
169/// This does **not** try to infer [`ColorSupport::Indexed256`] or
170/// [`ColorSupport::Ansi16`] from `$TERM`/`$COLORTERM` text (an earlier version of this function
171/// selected `Indexed256` for any `$TERM` containing `"256color"`, and `Truecolor` only for an
172/// explicit `$COLORTERM=truecolor`/`24bit`). Both heuristics look reasonable in isolation and
173/// both have the same failure mode: `xterm-256color` is the single most common `$TERM` value in
174/// existence, including on terminals that also fully support truecolor, and plenty of genuinely
175/// truecolor-capable environments never set `$COLORTERM` at all (this workspace's own PTY test
176/// harness is one: `portable-pty` sets `$TERM=xterm-256color` unconditionally for the child it
177/// spawns and never sets `$COLORTERM`, which silently downgraded every snapshot test's rendered
178/// colors and broke them all: retroglyph#585 CI). Requiring an unambiguous signal before
179/// degrading, rather than guessing from `$TERM`'s text, is what avoids repeating that. Both
180/// narrower levels remain fully available as an explicit choice via
181/// [`CrosstermOptions::color_support`](CrosstermOptions::color_support); they just aren't guessed
182/// at automatically.
183fn detect_color_support(
184    no_color: Option<&str>,
185    term: Option<&str>,
186) -> retroglyph_terminal::ColorSupport {
187    use retroglyph_terminal::ColorSupport;
188
189    if no_color.is_some_and(|value| !value.is_empty()) {
190        return ColorSupport::None;
191    }
192    if term == Some("dumb") {
193        return ColorSupport::None;
194    }
195    ColorSupport::Truecolor
196}
197
198/// Thin env-reading wrapper around [`detect_color_support`]; reads the real process environment.
199fn detect_color_support_from_env() -> retroglyph_terminal::ColorSupport {
200    detect_color_support(
201        std::env::var("NO_COLOR").ok().as_deref(),
202        std::env::var("TERM").ok().as_deref(),
203    )
204}
205
206// Tracks whether the currently-live `Crossterm` instance (there's normally at most one, since
207// each holds exclusive use of stdout/raw mode) actually entered the alternate screen / enabled
208// raw mode, so `restore_terminal` (shared by `Drop` and the process-wide panic hook, neither of
209// which has access to a specific instance's `CrosstermOptions`) only undoes what was actually
210// done. Unlike the other features (mouse capture, focus-change, bracketed paste, kitty protocol),
211// which are safe to unconditionally disable/pop even if never enabled (crossterm's own commands
212// are no-ops on a terminal that never received the matching enable sequence), unconditionally
213// emitting `LeaveAlternateScreen`/`disable_raw_mode()` when we never entered/enabled them could
214// corrupt a caller's already-cooked-mode terminal or emit a stray escape into their normal
215// scrollback buffer.
216static ALT_SCREEN_ACTIVE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
217static RAW_MODE_ACTIVE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
218
219// Tracks whether *some* `Crossterm` instance is currently live, independent of the two statics
220// above (which track what a *single* construction actually enabled, for `restore_terminal`'s own
221// bookkeeping). This is the fix for the hazard those two statics can't protect against on their
222// own: constructing a second `Crossterm` while a first is still alive both stomp the same
223// process-wide raw-mode/alternate-screen state, and dropping either one calls the shared
224// `restore_terminal()`, which would tear down state the other instance still believes is active
225// (e.g. dropping instance A disables raw mode and clears `RAW_MODE_ACTIVE`, while instance B is
226// still alive and now silently receives line-buffered, echoed input instead of raw key events).
227// Rather than trying to make concurrent instances safe (which would need real per-instance
228// state, not process-global statics, since stdout/raw-mode/the alternate screen are process-wide
229// OS resources with no instance-scoped handle), construction of a second live instance is
230// rejected outright: see [`InstanceGuard`].
231static INSTANCE_LIVE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
232
233/// RAII guard enforcing the "at most one live [`Crossterm`] instance per process" invariant.
234///
235/// [`InstanceGuard::acquire`] atomically claims [`INSTANCE_LIVE`] (returning an error if another
236/// instance already holds it) and stores the resulting guard as a field of [`Crossterm`], so it
237/// stays held for exactly as long as that `Crossterm` is alive. Dropping the guard (whether via
238/// the owning `Crossterm`'s normal `Drop`, or because a `?` inside
239/// [`Crossterm::build_from_options`] unwound out of the constructor after the guard was acquired
240/// but before construction finished) releases the flag, so a single failed construction attempt
241/// can never permanently wedge out all future construction.
242///
243/// This is independent of `restore_terminal()`/the panic hook: `restore_terminal()`
244/// only clears `ALT_SCREEN_ACTIVE`/`RAW_MODE_ACTIVE` (idempotent swaps that are safe to call any
245/// number of times, including from both the panic hook and the eventual `Drop`). Ordinary Rust
246/// destructor semantics already guarantee this guard's `Drop` runs during an unwind (the panic
247/// hook itself runs *before* unwinding starts, so it never needs to touch `INSTANCE_LIVE`), so
248/// there's no double-release or release-when-nothing-was-acquired hazard: exactly one `Drop` runs
249/// per successful `acquire()`, whether the instance is dropped normally or unwinds away after a
250/// panic.
251struct InstanceGuard {
252    // Always `true` for a live `InstanceGuard`; `acquire()` never constructs one otherwise. Kept
253    // as a field (rather than always releasing unconditionally in `Drop`) so the invariant "one
254    // release per successful acquire" is enforced by the type itself, not just by convention.
255    armed: bool,
256}
257
258impl InstanceGuard {
259    /// Attempts to claim the process-wide "a `Crossterm` instance is live" flag.
260    ///
261    /// Returns `Err` with [`std::io::ErrorKind::ResourceBusy`] if another instance already holds
262    /// it; the caller (`Crossterm::build_from_options`) is expected to propagate that error
263    /// straight out of the constructor via `?` without attempting any teardown, since nothing was
264    /// set up yet.
265    fn acquire() -> Result<Self, std::io::Error> {
266        use std::sync::atomic::Ordering;
267
268        INSTANCE_LIVE
269            .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
270            .map(|_| Self { armed: true })
271            .map_err(|_| {
272                std::io::Error::new(
273                    std::io::ErrorKind::ResourceBusy,
274                    "a Crossterm instance is already live in this process; only one may be \
275                     constructed at a time (drop the existing instance before constructing \
276                     another)",
277                )
278            })
279    }
280}
281
282impl Drop for InstanceGuard {
283    fn drop(&mut self) {
284        if self.armed {
285            INSTANCE_LIVE.store(false, std::sync::atomic::Ordering::Release);
286        }
287    }
288}
289
290/// Writes the escape sequence that's always safe to emit during terminal restore (popping the
291/// kitty keyboard enhancement flags, disabling bracketed paste/focus-change/mouse capture,
292/// resetting every SGR attribute, and showing the cursor) to `w`.
293///
294/// Split out of [`restore_terminal`] so these exact bytes can be asserted on directly against an
295/// in-memory `Vec<u8>` in tests, since `restore_terminal` itself always targets the real process
296/// stdout and so can't otherwise be observed from a unit test.
297///
298/// `SetAttribute(Attribute::Reset)` (`\x1b[0m`) clears every SGR attribute (colors, bold,
299/// underline, etc.) back to the terminal's own default. The SGR "pen" is terminal-global state,
300/// independent of which screen buffer is active, so without this the last color the app drew with
301/// (e.g. a tinted background) survives `LeaveAlternateScreen`/raw mode exit and leaks into the
302/// shell. Left unreset, that leftover pen state is also what many terminals use to paint newly
303/// erased/blank cells ("background color erase") the next time this app enters the alternate
304/// screen, so any per-frame blend against `Color::Default` compounds a little darker on every
305/// subsequent run instead of starting from a clean slate. Unlike `LeaveAlternateScreen`/raw mode,
306/// every command written here is always safe to emit (just an escape sequence, no visible glyph),
307/// so it doesn't need to be gated on whether this process actually entered/enabled those.
308fn write_restore_sequence<W: std::io::Write>(w: &mut W) -> std::io::Result<()> {
309    // Pop the keyboard enhancement flags pushed in `Crossterm::new`. Terminals
310    // that never understood the push ignore the pop just the same.
311    crossterm::execute!(w, crossterm::event::PopKeyboardEnhancementFlags)?;
312    crossterm::execute!(
313        w,
314        crossterm::event::DisableBracketedPaste,
315        crossterm::event::DisableFocusChange,
316        crossterm::event::DisableMouseCapture,
317        crossterm::style::SetAttribute(crossterm::style::Attribute::Reset),
318        crossterm::cursor::Show
319    )
320}
321
322/// Helper function to restore the terminal to its normal state.
323/// This is called during drops and emergency panic hooks.
324fn restore_terminal() {
325    use std::sync::atomic::Ordering;
326
327    let mut stdout = std::io::stdout();
328    let _ = write_restore_sequence(&mut stdout);
329    if ALT_SCREEN_ACTIVE.swap(false, Ordering::AcqRel) {
330        let _ = crossterm::execute!(stdout, crossterm::terminal::LeaveAlternateScreen);
331    }
332    if RAW_MODE_ACTIVE.swap(false, Ordering::AcqRel) {
333        let _ = crossterm::terminal::disable_raw_mode();
334    }
335}
336
337/// Options controlling which optional terminal protocol features
338/// [`Crossterm::with_options`] enables.
339///
340/// All features default to `true`; mouse capture, the kitty keyboard
341/// protocol, entering the alternate screen, and raw mode all match the
342/// unconditional behavior of [`Crossterm::new`] prior to this type's
343/// introduction. Use [`CrosstermOptions::mouse_capture`],
344/// [`CrosstermOptions::kitty_protocol`], [`CrosstermOptions::focus_change`],
345/// [`CrosstermOptions::bracketed_paste`], [`CrosstermOptions::alt_screen`], or
346/// [`CrosstermOptions::raw_mode`] to disable a feature entirely, e.g. when
347/// running on a terminal (or through a pipe/CI harness/`tmux`/SSH session)
348/// where the feature is unwanted.
349///
350/// This is also the type returned by [`Crossterm::builder`], the preferred
351/// entry point for constructing one of these: `Crossterm::builder()` reads
352/// better at a call site than `CrosstermOptions::new()` but the two are
353/// otherwise identical (`builder()` just calls `Self::new()`).
354///
355/// This crate does not attempt to auto-detect terminal
356/// capabilities (no `TERM` parsing, no `supports_keyboard_enhancement()`
357/// query): those queries can block for seconds on terminals that never
358/// respond. `CrosstermOptions` is the opt-out mechanism instead: callers who
359/// know their environment don't support a feature can disable it explicitly.
360///
361/// ```
362/// use retroglyph_crossterm::Crossterm;
363///
364/// let options = Crossterm::builder().mouse_capture(false).kitty_protocol(false);
365/// ```
366#[derive(Debug, Clone, Copy, PartialEq, Eq)]
367// Six independent, unrelated terminal protocol toggles, not a state machine in disguise: each
368// maps to one crossterm enable/disable command pair (or, for raw_mode/alt_screen, one
369// enable/leave pair) and is meaningful on its own.
370#[allow(clippy::struct_excessive_bools)]
371pub struct CrosstermOptions {
372    mouse_capture: bool,
373    kitty_protocol: bool,
374    focus_change: bool,
375    bracketed_paste: bool,
376    alt_screen: bool,
377    raw_mode: bool,
378    // `None` means auto-detect from `$NO_COLOR`/`$TERM` at build time (see
379    // `detect_color_support_from_env`); `Some` is an explicit caller override that skips
380    // detection entirely. Unlike the six booleans above, this can't default to a plain `bool`
381    // (or even a bare `ColorSupport`) without losing the ability to tell "caller explicitly
382    // wants `Truecolor`" apart from "caller didn't say, go detect it".
383    color_support: Option<retroglyph_terminal::ColorSupport>,
384}
385
386impl CrosstermOptions {
387    /// Creates a new set of options with every feature enabled.
388    #[must_use]
389    pub fn new() -> Self {
390        Self::default()
391    }
392
393    /// Sets whether to enable mouse capture (`crossterm::event::EnableMouseCapture`).
394    #[must_use]
395    pub const fn mouse_capture(mut self, enabled: bool) -> Self {
396        self.mouse_capture = enabled;
397        self
398    }
399
400    /// Sets whether to push the kitty keyboard protocol's enhancement flags
401    /// (`crossterm::event::PushKeyboardEnhancementFlags`).
402    #[must_use]
403    pub const fn kitty_protocol(mut self, enabled: bool) -> Self {
404        self.kitty_protocol = enabled;
405        self
406    }
407
408    /// Sets whether to report focus gained/lost as
409    /// [`Event::FocusGained`]/[`Event::FocusLost`]
410    /// (`crossterm::event::EnableFocusChange`).
411    ///
412    /// See the crate-level "Focus and lifecycle events" docs for the pause/resume contract this
413    /// implies (e.g. on Wayland, where a terminal can lose and regain focus independent of any
414    /// resize).
415    #[must_use]
416    pub const fn focus_change(mut self, enabled: bool) -> Self {
417        self.focus_change = enabled;
418        self
419    }
420
421    /// Sets whether to report bracketed paste as [`Event::Paste`]
422    /// (`crossterm::event::EnableBracketedPaste`).
423    #[must_use]
424    pub const fn bracketed_paste(mut self, enabled: bool) -> Self {
425        self.bracketed_paste = enabled;
426        self
427    }
428
429    /// Sets whether to enter the alternate screen
430    /// (`crossterm::terminal::EnterAlternateScreen`).
431    ///
432    /// Disabling this keeps rendering on the caller's normal scrollback buffer instead of
433    /// switching to a dedicated full-screen surface; on exit, [`Crossterm`] only leaves the
434    /// alternate screen (`LeaveAlternateScreen`) if it entered it, so disabling this doesn't
435    /// risk leaving the caller's real terminal buffer in an unexpected state.
436    #[must_use]
437    pub const fn alt_screen(mut self, enabled: bool) -> Self {
438        self.alt_screen = enabled;
439        self
440    }
441
442    /// Sets whether to enable raw mode (`crossterm::terminal::enable_raw_mode`).
443    ///
444    /// Disabling this leaves the terminal in cooked mode, so the OS/shell keep handling line
445    /// buffering, echo, and signal-generating keys (`Ctrl-C`, `Ctrl-Z`) itself instead of
446    /// forwarding every keystroke as an [`Event::Key`].
447    /// Restore only disables raw mode if this backend is the one that enabled it.
448    #[must_use]
449    pub const fn raw_mode(mut self, enabled: bool) -> Self {
450        self.raw_mode = enabled;
451        self
452    }
453
454    /// Overrides the [`ColorSupport`](retroglyph_terminal::ColorSupport) level, skipping this
455    /// crate's own `$NO_COLOR`/`$TERM` auto-detection (see
456    /// [`Crossterm::color_support`] for the auto-detected default and how it's chosen).
457    /// Use this when a caller knows the receiving terminal's actual color depth (or wants to
458    /// force it) rather than trusting the environment.
459    #[must_use]
460    pub const fn color_support(mut self, color_support: retroglyph_terminal::ColorSupport) -> Self {
461        self.color_support = Some(color_support);
462        self
463    }
464
465    /// Builds the [`Crossterm`] backend with these options, rendering to standard output.
466    ///
467    /// Equivalent to [`Crossterm::with_options`]; this is the terminal step of the
468    /// `Crossterm::builder().<options>().build()` chain started by [`Crossterm::builder`].
469    /// Use [`build_with_writer`](Self::build_with_writer) to render to a different sink (a
470    /// file, a pipe, an in-memory buffer for tests) instead of stdout.
471    ///
472    /// # Errors
473    ///
474    /// Returns an `std::io::Error` if raw mode or terminal commands fail. Also returns an
475    /// `std::io::Error` with [`std::io::ErrorKind::ResourceBusy`] if another [`Crossterm`]
476    /// instance is already live in this process: see the concurrency contract documented on
477    /// [`Crossterm`].
478    pub fn build(self) -> Result<Crossterm, std::io::Error> {
479        // Detect once, up front, whether the real process stdout is an interactive terminal so
480        // the resulting renderer degrades to pipe-safe plain text (see
481        // `TerminalRenderer::set_plain_mode`) when stdout is a file, a pipe, or otherwise
482        // redirected (`> log.txt`, CI runners, etc). This mirrors
483        // `TerminalRenderer::auto`, but is spelled out with an explicit `is_terminal()` check on
484        // the un-wrapped `Stdout` handle rather than a call to `auto` itself: `auto` requires
485        // `W: IsTerminal`, and the buffered `BufWriter<Stdout>` this backend renders through
486        // doesn't implement that trait (only the unbuffered `Stdout`/`File`/etc. do), so the
487        // check has to happen before wrapping in `BufWriter`.
488        let plain = !std::io::stdout().is_terminal();
489        Crossterm::build_from_options(self, BufWriter::new(std::io::stdout()), plain)
490    }
491
492    /// Builds the [`Crossterm`] backend with these options, rendering to `writer` instead of
493    /// stdout.
494    ///
495    /// `writer` only receives the rendered cell content ([`Output::draw`]/[`Output::flush`]
496    /// output, plus the runtime [`Output::clear`]/[`Cursor::set_cursor_visible`]/
497    /// [`Cursor::set_cursor_position`] escapes). Terminal-protocol setup/teardown (raw mode,
498    /// the alternate screen, the initial cursor hide, mouse capture, focus-change reporting,
499    /// bracketed paste, and the kitty keyboard protocol) always targets the real process
500    /// stdout regardless of `writer`, since those are properties of the actual controlling
501    /// terminal, not of an arbitrary byte sink. Callers rendering to a non-terminal `writer`
502    /// (a file, a pipe, an in-memory buffer) should disable the features they don't want
503    /// touching the real terminal via [`CrosstermOptions::raw_mode`],
504    /// [`CrosstermOptions::alt_screen`], [`CrosstermOptions::mouse_capture`], etc.
505    ///
506    /// # Errors
507    ///
508    /// Returns an `std::io::Error` if raw mode or terminal commands fail. Also returns an
509    /// `std::io::Error` with [`std::io::ErrorKind::ResourceBusy`] if another [`Crossterm`]
510    /// instance is already live in this process: see the concurrency contract documented on
511    /// [`Crossterm`].
512    pub fn build_with_writer<W: std::io::Write>(
513        self,
514        writer: W,
515    ) -> Result<Crossterm<W>, std::io::Error> {
516        // Unlike `build`, `writer` here is an arbitrary caller-supplied sink with no `IsTerminal`
517        // bound (a `Vec<u8>` in tests, for instance, doesn't implement it), so there's no way to
518        // auto-detect plain mode; default to `false` (ANSI/SGR escapes on), matching this
519        // method's historical behavior.
520        Crossterm::build_from_options(self, writer, false)
521    }
522}
523
524impl Default for CrosstermOptions {
525    /// Every feature enabled; matches [`Crossterm::new`]'s historical behavior. `color_support`
526    /// defaults to `None` (auto-detect from the environment at build time; see
527    /// [`CrosstermOptions::color_support`]).
528    fn default() -> Self {
529        Self {
530            mouse_capture: true,
531            kitty_protocol: true,
532            focus_change: true,
533            bracketed_paste: true,
534            alt_screen: true,
535            raw_mode: true,
536            color_support: None,
537        }
538    }
539}
540
541/// Builds a [`Crossterm`] backend rendering to stdout and drives `app` on it with
542/// [`retroglyph_core::app::run_on_with`], blocking until `app` returns
543/// [`Flow::Exit`](retroglyph_core::app::Flow::Exit).
544///
545/// The crossterm arm of [`Launch`](retroglyph_core::app::Launch): pairs with a windowed backend's
546/// `Windowed` (in `retroglyph-window`) to let both live in the same binary behind one shared
547/// [`App`](retroglyph_core::app::App) impl, without a cfg-dispatched `run()` silently picking a
548/// backend out from under an additive feature. See [`Launch`](retroglyph_core::app::Launch)'s own
549/// docs for why.
550///
551/// # Examples
552///
553/// ```no_run
554/// use retroglyph_core::app::{App, Flow, Frame, Launch, RunOptions};
555/// use retroglyph_core::backend::Backend;
556/// use retroglyph_core::terminal::Terminal;
557/// use retroglyph_crossterm::Crossterm;
558///
559/// struct MyGame;
560/// impl<B: Backend> App<B> for MyGame {
561///     fn update(&mut self, _term: &mut Terminal<B>, _frame: &Frame) -> Flow {
562///         Flow::Exit
563///     }
564/// }
565///
566/// // Requires a real controlling terminal, so this example is `no_run`.
567/// Crossterm::builder().launch(MyGame, RunOptions::default())?;
568/// # Ok::<(), std::io::Error>(())
569/// ```
570impl retroglyph_core::app::Launch for CrosstermOptions {
571    type Backend = Crossterm;
572    type Error = std::io::Error;
573
574    /// # Errors
575    ///
576    /// Same as [`CrosstermOptions::build`] if construction fails, or the same as
577    /// [`retroglyph_core::app::run_on_with`] if the loop's automatic `present()` call fails once
578    /// running.
579    fn launch<A>(self, app: A, options: retroglyph_core::app::RunOptions) -> Result<(), Self::Error>
580    where
581        A: retroglyph_core::app::App<Self::Backend> + 'static,
582    {
583        let backend = self.build()?;
584        retroglyph_core::app::run_on_with(Terminal::new(backend), app, options)
585    }
586}
587
588/// A terminal rendering backend powered by `crossterm`.
589///
590/// Generic over the content writer `W`: the sink that receives rendered cell output
591/// ([`Output::draw`]/[`Output::flush`], plus the runtime cursor/clear escapes). Defaults to
592/// `BufWriter<Stdout>`, matching this type's historical behavior; use
593/// [`Crossterm::with_writer`]/[`CrosstermOptions::build_with_writer`] to render to a file, a
594/// pipe, or an in-memory buffer instead (e.g. for tests that want to inspect the emitted ANSI
595/// bytes without a real TTY). See [`CrosstermOptions::build_with_writer`] for exactly which
596/// operations go through `W` versus the real terminal.
597///
598/// # Concurrency: only one live instance per process
599///
600/// Raw mode, the alternate screen, and the other terminal-protocol state this backend negotiates
601/// are process-wide OS resources (there's exactly one controlling terminal, one raw-mode flag,
602/// one alternate-screen buffer), not something a `Crossterm` instance owns exclusively the way a
603/// `File` owns a file descriptor. Because of that, at most one `Crossterm` (of any `W`) may be
604/// live at a time in a process: constructing a second one while a first is still alive ([`new`],
605/// [`with_options`], [`with_writer`], and every [`CrosstermOptions::build`]/
606/// [`CrosstermOptions::build_with_writer`] call) returns an `std::io::Error` with
607/// [`std::io::ErrorKind::ResourceBusy`] instead of proceeding: this is a documented error, not
608/// undefined behavior, and nothing is torn down or corrupted by the attempt. Sequential
609/// construct-drop-construct is fully supported: once the live instance is dropped, a new one can
610/// be constructed immediately.
611///
612/// [`new`]: Crossterm::new
613/// [`with_options`]: Crossterm::with_options
614/// [`with_writer`]: Crossterm::with_writer
615pub struct Crossterm<W: std::io::Write = BufWriter<Stdout>> {
616    renderer: TerminalRenderer<W>,
617    // Held for exactly the lifetime of this instance; releases the process-wide "an instance is
618    // live" flag when this value is dropped (see [`InstanceGuard`]). Never read after
619    // construction (it's kept purely for its `Drop` side effect), hence the leading
620    // underscore, which also suppresses the otherwise-applicable `dead_code` lint.
621    _instance_guard: InstanceGuard,
622    // Cached result of the last successful `crossterm::terminal::size()` query. Seeded once at
623    // construction and refreshed only when `poll_event` observes a `crossterm::event::Event::
624    // Resize`: the app already receives that event on every real terminal resize, so there's
625    // no need to re-query on every `Output::size()` call (a `TIOCGWINSZ` ioctl), which used to
626    // run once per frame. See retroglyph#279.
627    //
628    // `size()` itself never re-queries after construction (see retroglyph#279), so the only
629    // fallible query is the one-time seed in `build_from_options`; that's also the only place a
630    // hardcoded guess (80x24, not a "last known good" value, since none exists yet) is ever
631    // used. See retroglyph#281.
632    cached_size: Size,
633    // Events handed to `Input::push_event`, drained ahead of the real terminal by `poll_event`.
634    //
635    // Unlike the windowed backends, this one has its own event source, so nothing *has* to push
636    // into it, but the `Input::push_event` contract is "queue this for `poll_event`", and a
637    // silent no-op is a trap for the two callers that legitimately want it: a test harness
638    // injecting synthetic input, and a driver that drains the queue to intercept a key and hands
639    // the rest back (see `retroglyph-examples`' FPS overlay toggle). Both used to lose every
640    // event they pushed here.
641    pushed_events: VecDeque<Event>,
642    // The options this instance was built with, retained so `suspend`/`SuspendGuard::resume` can
643    // redo the exact enable sequence [`build_from_options`](Self::build_from_options) ran at
644    // construction (raw mode, the alternate screen, mouse capture, focus-change reporting,
645    // bracketed paste, the kitty keyboard protocol) rather than guessing which features were on.
646    options: CrosstermOptions,
647}
648
649impl Crossterm {
650    /// Creates a new `Crossterm` backend rendering to standard output.
651    ///
652    /// Enables raw mode, enters the alternate screen, hides the cursor, and
653    /// enables mouse capture, focus-change reporting, bracketed paste, and
654    /// the kitty keyboard protocol (all by default; see [`CrosstermOptions`]
655    /// to disable any of them). Registers a process-wide panic hook (once,
656    /// across all instances) that restores the terminal before the default
657    /// panic handler runs, so a panic mid-render doesn't leave the user's
658    /// shell in raw mode or the alternate screen.
659    ///
660    /// This is a thin wrapper over [`Crossterm::with_options`] with
661    /// [`CrosstermOptions::default()`].
662    ///
663    /// # Errors
664    ///
665    /// Same as [`CrosstermOptions::build`].
666    ///
667    /// # Examples
668    ///
669    /// ```no_run
670    /// use retroglyph_core::terminal::Terminal;
671    /// use retroglyph_crossterm::Crossterm;
672    ///
673    /// // Requires a real controlling terminal (raw mode, the alternate screen, and cursor
674    /// // hiding all target the actual process stdout), so this example is `no_run`.
675    /// let mut term = Terminal::new(Crossterm::new()?);
676    /// term.draw(|_surface| {})?;
677    /// # Ok::<(), std::io::Error>(())
678    /// ```
679    pub fn new() -> Result<Self, std::io::Error> {
680        Self::with_options(CrosstermOptions::default())
681    }
682
683    /// Starts building a `Crossterm` backend with explicit control over which optional
684    /// terminal protocol features are enabled.
685    ///
686    /// Equivalent to `CrosstermOptions::new()`; call [`CrosstermOptions::build`] (or
687    /// [`CrosstermOptions::build_with_writer`]) once the desired features are chosen. This is
688    /// the preferred entry point over `CrosstermOptions::new()` for readability at the call
689    /// site:
690    ///
691    /// ```
692    /// use retroglyph_crossterm::Crossterm;
693    ///
694    /// let options = Crossterm::builder()
695    ///     .mouse_capture(false)
696    ///     .kitty_protocol(false)
697    ///     .alt_screen(true)
698    ///     .raw_mode(true);
699    /// // let backend = options.build()?; // requires a real terminal
700    /// ```
701    #[must_use]
702    pub fn builder() -> CrosstermOptions {
703        CrosstermOptions::new()
704    }
705
706    /// Creates a new `Crossterm` backend rendering to standard output, with
707    /// explicit control over which optional protocol features are enabled.
708    ///
709    /// Hides the cursor unconditionally. Raw mode, entering the alternate screen, mouse
710    /// capture, focus-change reporting, bracketed paste, and the kitty keyboard protocol are
711    /// all enabled by default but can be disabled individually via `options`; see
712    /// [`CrosstermOptions`]. Registers a process-wide panic hook (once, across all instances)
713    /// that restores the terminal before the default panic handler runs, so a panic
714    /// mid-render doesn't leave the user's shell in raw mode or the alternate screen.
715    ///
716    /// This is a thin wrapper over [`CrosstermOptions::build`]; prefer
717    /// `Crossterm::builder().<options>().build()` at new call sites.
718    ///
719    /// # Errors
720    ///
721    /// Same as [`CrosstermOptions::build`].
722    pub fn with_options(options: CrosstermOptions) -> Result<Self, std::io::Error> {
723        options.build()
724    }
725}
726
727impl<W: std::io::Write> Crossterm<W> {
728    /// Creates a new `Crossterm` backend rendering to `writer` instead of standard output.
729    ///
730    /// Thin wrapper over [`CrosstermOptions::build_with_writer`] with
731    /// [`CrosstermOptions::default()`]; see that method for the exact contract of which
732    /// operations go through `writer` versus the real terminal.
733    ///
734    /// # Errors
735    ///
736    /// Same as [`CrosstermOptions::build`].
737    ///
738    /// # Examples
739    ///
740    /// Rendering into an in-memory buffer, to capture and assert on the emitted ANSI/SGR bytes
741    /// without a real TTY. Terminal-protocol setup (raw mode, the alternate screen, hiding the
742    /// cursor) still targets the real process stdout regardless of `writer` (see this method's
743    /// docs above), so this example is `no_run`: it requires an actual controlling terminal to
744    /// construct successfully, even though `writer` itself is just a `Vec<u8>`.
745    ///
746    /// ```no_run
747    /// use retroglyph_crossterm::Crossterm;
748    ///
749    /// let mut buffer: Vec<u8> = Vec::new();
750    /// let term = Crossterm::with_writer(&mut buffer)?;
751    /// drop(term);
752    /// assert!(buffer.is_empty());
753    /// # Ok::<(), std::io::Error>(())
754    /// ```
755    pub fn with_writer(writer: W) -> Result<Self, std::io::Error> {
756        CrosstermOptions::default().build_with_writer(writer)
757    }
758
759    /// Returns a reference to the content writer.
760    pub const fn writer(&self) -> &W {
761        self.renderer.writer()
762    }
763
764    /// Returns whether the underlying renderer is in plain (non-ANSI) mode.
765    ///
766    /// [`CrosstermOptions::build`] sets this automatically based on whether the real process
767    /// stdout is an interactive terminal (see that method's docs); [`CrosstermOptions::build_with_writer`]
768    /// always leaves it `false`, since an arbitrary writer's "is this a terminal" status can't be
769    /// determined generically. See
770    /// [`TerminalRenderer::set_plain_mode`](retroglyph_terminal::TerminalRenderer::set_plain_mode)
771    /// for what plain mode changes about rendering.
772    pub const fn plain_mode(&self) -> bool {
773        self.renderer.plain_mode()
774    }
775
776    /// Returns the configured [`ColorSupport`](retroglyph_terminal::ColorSupport) level.
777    ///
778    /// Set explicitly via [`CrosstermOptions::color_support`], or auto-detected from
779    /// `$NO_COLOR`/`$TERM` if not overridden; see that method's docs for the detection rules.
780    pub const fn color_support(&self) -> retroglyph_terminal::ColorSupport {
781        self.renderer.color_support()
782    }
783
784    /// Returns a mutable reference to the content writer.
785    pub const fn writer_mut(&mut self) -> &mut W {
786        self.renderer.writer_mut()
787    }
788
789    /// Updates the cached size field if `event` is a `crossterm::event::Event::Resize`; a
790    /// no-op for every other event kind.
791    ///
792    /// Split out of [`Input::poll_event`] so it can be exercised directly in tests without
793    /// requiring a real terminal event source. See retroglyph#279.
794    const fn refresh_cached_size_on_resize(&mut self, event: &crossterm::event::Event) {
795        if let crossterm::event::Event::Resize(width, height) = *event {
796            self.cached_size = Size::new(width, height);
797        }
798    }
799
800    fn build_from_options(
801        options: CrosstermOptions,
802        writer: W,
803        plain: bool,
804    ) -> Result<Self, std::io::Error> {
805        // Setup panic hook on first backend creation
806        static PANIC_HOOK: std::sync::Once = std::sync::Once::new();
807        PANIC_HOOK.call_once(|| {
808            let original_hook = std::panic::take_hook();
809            std::panic::set_hook(Box::new(move |panic_info| {
810                restore_terminal();
811                original_hook(panic_info);
812            }));
813        });
814
815        // Reject a second concurrent instance up front, before touching any terminal state.
816        // Held for the lifetime of the returned `Crossterm` (see `InstanceGuard`'s docs); if any
817        // `?` below returns early, this local binding drops immediately and releases the flag, so
818        // a failed construction attempt never permanently wedges out future construction.
819        let instance_guard = InstanceGuard::acquire()?;
820
821        enable_terminal_features(options)?;
822
823        // Seed the cached size once, up front, so `size()` never has to query on the
824        // per-frame path; kept fresh afterward by `poll_event` observing `Event::Resize` (see
825        // below). Fall back to 80x24 (not the historical, and non-conventional, 80x25) if
826        // this initial query fails; there's no better "last known good" size to fall back to
827        // yet, since none has been observed. See retroglyph#281.
828        let (width, height) = crossterm::terminal::size().unwrap_or((80, 24));
829
830        // Use the caller's explicit override if given; otherwise detect from
831        // `$NO_COLOR`/`$TERM`. Unlike `plain` above (which needs `writer`'s own
832        // `IsTerminal` status and so can't be detected for an arbitrary `build_with_writer`
833        // sink), these are process environment variables independent of `writer`, so detection
834        // applies the same way regardless of which `build*` method was called.
835        let color_support = options
836            .color_support
837            .unwrap_or_else(detect_color_support_from_env);
838
839        Ok(Self {
840            renderer: TerminalRenderer::with_plain_mode(writer, plain)
841                .with_color_support(color_support),
842            _instance_guard: instance_guard,
843            cached_size: Size::new(width, height),
844            pushed_events: VecDeque::new(),
845            options,
846        })
847    }
848}
849
850/// Enables the terminal-protocol features `options` selects, targeting the real process stdout.
851///
852/// Shared by [`Crossterm::build_from_options`] (initial construction) and
853/// [`SuspendGuard`]'s resume path (undoing a [`Crossterm::suspend`]), since both need to redo the
854/// exact same enable sequence against the same set of options.
855fn enable_terminal_features(options: CrosstermOptions) -> std::io::Result<()> {
856    use std::sync::atomic::Ordering;
857
858    if options.raw_mode {
859        crossterm::terminal::enable_raw_mode()?;
860        RAW_MODE_ACTIVE.store(true, Ordering::Release);
861    }
862
863    // Terminal-protocol setup always targets the real process stdout, independent of any content
864    // writer a `Crossterm<W>` may be rendering to: these are properties of the actual controlling
865    // terminal (raw mode, the alternate screen, mouse/focus/paste/kitty negotiation), not of the
866    // content sink a caller may have swapped in via `build_with_writer`. See that method's docs.
867    let mut stdout = std::io::stdout();
868
869    if options.alt_screen {
870        crossterm::execute!(stdout, crossterm::terminal::EnterAlternateScreen)?;
871        ALT_SCREEN_ACTIVE.store(true, Ordering::Release);
872    }
873
874    crossterm::execute!(stdout, crossterm::cursor::Hide)?;
875
876    if options.mouse_capture {
877        crossterm::execute!(stdout, crossterm::event::EnableMouseCapture)?;
878    }
879
880    if options.focus_change {
881        crossterm::execute!(stdout, crossterm::event::EnableFocusChange)?;
882    }
883
884    if options.bracketed_paste {
885        crossterm::execute!(stdout, crossterm::event::EnableBracketedPaste)?;
886    }
887
888    if options.kitty_protocol {
889        // Opt into the kitty keyboard protocol so we receive key repeat and
890        // release events. We push optimistically rather than gating on
891        // `supports_keyboard_enhancement()`: that query blocks for the
892        // terminal's response (seconds on terminals that never answer, e.g.
893        // pipes and CI), stalling startup. Terminals that don't implement the
894        // protocol silently ignore the CSI sequence, and we map whatever key
895        // events they do send. The matching pop happens on restore.
896        crossterm::execute!(
897            stdout,
898            crossterm::event::PushKeyboardEnhancementFlags(keyboard_enhancement_flags())
899        )?;
900    }
901
902    Ok(())
903}
904
905impl<W: std::io::Write> Drop for Crossterm<W> {
906    fn drop(&mut self) {
907        // Flush whatever `draw`/cursor calls left buffered in `renderer` *before* `restore_
908        // terminal` runs: `restore_terminal` writes straight to `std::io::stdout()` and knows
909        // nothing about this instance's writer. Without this, a `BufWriter` writer (the default)
910        // would only flush once this whole `drop` returns and Rust drops `renderer` for real,
911        // dumping any still-queued escape bytes onto the shell *after* the alternate screen has
912        // already been left. See retroglyph#716.
913        let _ = self.renderer.flush();
914        restore_terminal();
915    }
916}
917
918impl<W: std::io::Write> Crossterm<W> {
919    /// Temporarily hands the real terminal back to the OS/shell, for shelling out to `$EDITOR`,
920    /// a pager, or a debugger.
921    ///
922    /// Exits raw mode, leaves the alternate screen, and shows the cursor, only undoing whichever
923    /// of those this instance actually has active, using the same "only undo what was actually
924    /// done" bookkeeping this instance's `Drop` and the process-wide panic hook already share,
925    /// leaving the terminal in the state a normal shell command expects. Mouse capture,
926    /// focus-change reporting, bracketed paste, and the kitty keyboard protocol are also
927    /// disabled, matching what a normal process exit/panic already does.
928    ///
929    /// Returns a [`SuspendGuard`] borrowing `self`: while it's alive, no other `Crossterm` method
930    /// can be called (the borrow checker enforces this), and dropping the guard (or calling
931    /// [`SuspendGuard::resume`] explicitly) restores every option this instance was originally
932    /// built with and forces a full redraw on the next [`Output::draw`], since whatever ran while
933    /// suspended may have written arbitrary content to the real screen that this backend's diff
934    /// state doesn't know about.
935    ///
936    /// Does not handle `Ctrl+Z`/`SIGTSTP`: this is an explicit API for the common case (a key
937    /// binding that shells out), not a signal handler. An app that also wants to
938    /// suspend on `SIGTSTP` needs to install its own signal handler and call this method (and
939    /// [`SuspendGuard::resume`]) from it.
940    ///
941    /// # Errors
942    ///
943    /// Returns an `std::io::Error` if flushing the pending frame fails, or if any of the
944    /// terminal-restoring commands fail (e.g. a closed terminal or disconnected pipe).
945    pub fn suspend(&mut self) -> std::io::Result<SuspendGuard<'_, W>> {
946        // Flush before handing the terminal back: `restore_terminal` writes straight to
947        // `std::io::stdout()`, not `self.renderer`'s writer, so without this a still-buffered
948        // `draw`/cursor call's escape bytes (possibly including an unterminated synchronized-
949        // update start) would land on whatever program `suspend` hands the terminal to. See
950        // retroglyph#716.
951        self.renderer.flush()?;
952        restore_terminal();
953        Ok(SuspendGuard {
954            crossterm: self,
955            resumed: false,
956        })
957    }
958}
959
960/// RAII guard returned by [`Crossterm::suspend`]; see that method's docs for the full contract.
961///
962/// Borrows the suspended [`Crossterm`] mutably for its whole lifetime, so no other method on it
963/// can be called (accidentally drawing, polling, or moving the cursor) while the real terminal is
964/// handed back to the OS/shell.
965pub struct SuspendGuard<'a, W: std::io::Write> {
966    crossterm: &'a mut Crossterm<W>,
967    // Set once `resume`/`Drop` has actually run the restore sequence, so a caller who calls
968    // `resume()` explicitly doesn't pay for (or risk double-applying) a second restore when the
969    // guard is then dropped.
970    resumed: bool,
971}
972
973impl<W: std::io::Write> SuspendGuard<'_, W> {
974    /// Restores every option the suspended [`Crossterm`] was originally built with and forces a
975    /// full redraw on the next [`Output::draw`]. Equivalent to letting the guard drop, but lets a
976    /// caller observe the `std::io::Error` a dropped guard would otherwise discard.
977    ///
978    /// # Errors
979    ///
980    /// Returns an `std::io::Error` if any of the terminal-restoring commands fail (e.g. a closed
981    /// terminal or disconnected pipe).
982    pub fn resume(mut self) -> std::io::Result<()> {
983        self.resume_inner()
984    }
985
986    fn resume_inner(&mut self) -> std::io::Result<()> {
987        if self.resumed {
988            return Ok(());
989        }
990        self.resumed = true;
991        enable_terminal_features(self.crossterm.options)?;
992        // The shelled-out program may have written arbitrary content to the real screen; forget
993        // the tracked cursor/style state so the next `draw` re-emits full escape sequences
994        // instead of skipping them under the assumption the terminal is still in the last-known
995        // state (mirrors what `Output::clear` already does for the same reason).
996        self.crossterm.renderer.reset_state();
997        Ok(())
998    }
999}
1000
1001impl<W: std::io::Write> Drop for SuspendGuard<'_, W> {
1002    fn drop(&mut self) {
1003        let _ = self.resume_inner();
1004    }
1005}
1006
1007impl<W: std::io::Write> Output for Crossterm<W> {
1008    type Error = std::io::Error;
1009
1010    #[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip_all))]
1011    fn draw_layers<'a, I>(&mut self, content: I) -> Result<(), Self::Error>
1012    where
1013        I: Iterator<Item = DrawCell<'a>>,
1014    {
1015        // Silently drop cells positioned outside the grid, the same as `Headless` and
1016        // `Software` already do: a caller-supplied `pos` is not trusted input, and this
1017        // renderer (unlike those two) has no bounds check of its own to fall back on.
1018        let size = self.cached_size;
1019        let content =
1020            content.filter(move |cell| cell.pos.x < size.width() && cell.pos.y < size.height());
1021        // Begin synchronized update so the terminal holds rendering until
1022        // flush() sends the matching End marker.
1023        self.renderer.draw_frame(content)
1024    }
1025
1026    #[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip_all))]
1027    fn flush(&mut self) -> Result<(), Self::Error> {
1028        self.renderer.end_frame()
1029    }
1030
1031    fn size(&self) -> Size {
1032        // No syscall: just return the size cached at construction and kept fresh by
1033        // `poll_event` observing `Event::Resize`. See retroglyph#279.
1034        self.cached_size
1035    }
1036
1037    fn resize(&mut self, size: Size) {
1038        // Keep `cached_size` in sync with the caller's own idea of the terminal's dimensions,
1039        // the same as observing a real `crossterm::event::Event::Resize` in `poll_event` does
1040        // (see `cached_size`'s docs): without this, `size()` drifted permanently from the real
1041        // terminal size after any resize, since nothing else here ever wrote to it
1042        // (retroglyph#763).
1043        self.cached_size = size;
1044        let _ = self.clear();
1045    }
1046
1047    fn clear(&mut self) -> Result<(), Self::Error> {
1048        // Reset SGR attributes *before* erasing: most terminals implement "erase display" via
1049        // background color erase (BCE), painting the erased cells with whatever background is
1050        // currently active in the pen, not the terminal's true default. Left un-reset, a cell
1051        // colored by the last frame (a themed panel, a highlighted tile) becomes the color
1052        // `clear_screen` paints the whole screen with. That would be merely cosmetic for one
1053        // frame, except every cell here is a `resize()` call too (see `Output::resize` above):
1054        // `Terminal::resize` wipes `previous` to default tiles, so any `current` cell that's also
1055        // still at its default (e.g. anything the app hasn't drawn into the newly grown area yet)
1056        // never differs from `previous` and is never resent by the diff in `present()`. That
1057        // leaves the BCE-tinted patch on screen permanently: exactly the "gaps where the
1058        // background doesn't clear" symptom after a resize, since nothing ever draws over it
1059        // again.
1060        self.renderer.clear_screen()
1061    }
1062}
1063
1064impl<W: std::io::Write> Input for Crossterm<W> {
1065    /// Polls for the next input event, blocking up to `timeout`. See the crate-level "Event
1066    /// polling and CPU cost" doc section for what a zero `timeout` costs and where the actual CPU
1067    /// cost of an uncapped game loop comes from.
1068    ///
1069    /// Backends and examples in this workspace that need a frame cap (e.g. software + WASM,
1070    /// gated on `requestAnimationFrame`) already throttle themselves upstream of this call; a
1071    /// crossterm-driven loop wanting the same tradeoff should add its own
1072    /// `std::thread::sleep`/tick budget around `drain_events()` rather than expecting this method
1073    /// to throttle on its behalf.
1074    #[cfg_attr(feature = "tracing", tracing::instrument(level = "trace", skip(self)))]
1075    fn poll_event(&mut self, timeout: Duration) -> Option<Event> {
1076        // Pushed events jump the queue: they were either injected by a test harness or handed
1077        // back by a driver that already read them off the real terminal, so in both cases they
1078        // are older than anything crossterm still has waiting.
1079        if let Some(event) = self.pushed_events.pop_front() {
1080            return Some(event);
1081        }
1082
1083        let start = std::time::Instant::now();
1084
1085        loop {
1086            // Cap the polling timeout to 1 hour to prevent system-call overflow of massive durations (like Duration::MAX).
1087            let poll_timeout = timeout
1088                .saturating_sub(start.elapsed())
1089                .min(Duration::from_secs(3600));
1090
1091            match crossterm::event::poll(poll_timeout) {
1092                Ok(true) => {
1093                    let Ok(event) = crossterm::event::read() else {
1094                        return None;
1095                    };
1096                    // Refresh the cached size in lockstep with the resize event the app
1097                    // itself is about to receive, so `size()` (no syscall) stays consistent
1098                    // with what already triggered this event. See retroglyph#279.
1099                    self.refresh_cached_size_on_resize(&event);
1100                    if let Some(mapped) = from_crossterm_event(event) {
1101                        return Some(mapped);
1102                    }
1103                    // An unmappable event was consumed. In non-blocking mode
1104                    // (timeout zero, used by drain_events), retry immediately
1105                    // so we don't stop draining with events still buffered.
1106                    if timeout.is_zero() {
1107                        continue;
1108                    }
1109                }
1110                Ok(false) => {
1111                    // Timeout elapsed on this poll chunk.
1112                }
1113                Err(_) => {
1114                    return None;
1115                }
1116            }
1117
1118            if start.elapsed() >= timeout {
1119                return None;
1120            }
1121        }
1122    }
1123
1124    /// Queues `event` ahead of the real terminal's own stream; the next
1125    /// [`poll_event`](Self::poll_event) returns it.
1126    ///
1127    /// See the `pushed_events` field comment for why this backend implements this at all, when it
1128    /// has a perfectly good event source of its own.
1129    ///
1130    /// Deliberately does not coalesce consecutive `Mouse(Moved)` pushes the way
1131    /// `retroglyph-window`'s `WindowBackend`, `retroglyph-software`'s `SoftwareRenderer`, and
1132    /// `Headless` do; this backend is intentionally exempt from
1133    /// `retroglyph_core::testing::conformance::assert_input_contract` (see that module's doc for
1134    /// the coverage table). That coalescing exists to bound queue growth against a
1135    /// device-polling-rate motion firehose (winit's `CursorMoved`, retroglyph#294/retroglyph#768);
1136    /// `pushed_events` has no such source feeding it, it only ever receives events a test harness
1137    /// or driver replays by hand, one at a time, so there is nothing to bound and coalescing would
1138    /// just be dead code here.
1139    fn push_event(&mut self, event: Event) {
1140        self.pushed_events.push_back(event);
1141    }
1142}
1143
1144impl<W: std::io::Write> Crossterm<W> {
1145    /// Sets the terminal window/tab title.
1146    ///
1147    /// Queues `crossterm::terminal::SetTitle` and flushes immediately (unlike
1148    /// [`Cursor::set_cursor_visible`]/[`Cursor::set_cursor_position`], this is not expected to be
1149    /// called every frame, so there is no deferred-flush benefit to chase). Not every terminal
1150    /// emulator honors this OSC sequence; on ones that don't, this is silently a no-op from the
1151    /// caller's perspective.
1152    ///
1153    /// # Errors
1154    ///
1155    /// Returns an `std::io::Error` if writing or flushing the escape sequence fails (e.g. a
1156    /// closed terminal or disconnected pipe).
1157    pub fn set_title(&mut self, title: &str) -> std::io::Result<()> {
1158        let writer = self.renderer.writer_mut();
1159        crossterm::queue!(writer, crossterm::terminal::SetTitle(title))?;
1160        writer.flush()
1161    }
1162
1163    /// Rings the terminal bell (writes the `BEL` control character, `\x07`).
1164    ///
1165    /// Crossterm has no dedicated `Command` type for this (unlike [`Self::set_title`]'s
1166    /// `SetTitle`), so this writes the raw byte directly. Whether the terminal actually makes a
1167    /// sound, flashes, or does nothing at all is entirely up to the terminal emulator/user
1168    /// configuration.
1169    ///
1170    /// # Errors
1171    ///
1172    /// Returns an `std::io::Error` if writing or flushing the byte fails (e.g. a closed terminal
1173    /// or disconnected pipe).
1174    pub fn ring_bell(&mut self) -> std::io::Result<()> {
1175        let writer = self.renderer.writer_mut();
1176        writer.write_all(b"\x07")?;
1177        writer.flush()
1178    }
1179}
1180
1181impl<W: std::io::Write> Cursor for Crossterm<W> {
1182    /// Queues the show/hide escape without flushing; the next [`Output::flush`] call drains it
1183    /// along with everything else. A caller that hides the cursor and moves it in the same frame
1184    /// (a common pattern right before a draw) would otherwise pay an extra flush per call on top
1185    /// of the normal draw/flush pair, with no observable benefit since nothing reads the terminal
1186    /// state in between.
1187    fn set_cursor_visible(&mut self, visible: bool) {
1188        let _ = self.renderer.set_cursor_visible(visible);
1189    }
1190
1191    /// Queues the cursor-move escape without flushing; see [`set_cursor_visible`](Self::set_cursor_visible)'s
1192    /// docs for why this is deferred to the next [`Output::flush`] instead of flushing here.
1193    fn set_cursor_position(&mut self, position: Pos) {
1194        let _ = self.renderer.move_cursor_to(position);
1195    }
1196
1197    /// Queues the `DECSCUSR` cursor-shape escape without flushing; see
1198    /// [`set_cursor_visible`](Self::set_cursor_visible)'s docs for why this is deferred to the
1199    /// next [`Output::flush`] instead of flushing here.
1200    fn set_cursor_style(&mut self, style: CursorStyle) {
1201        let _ = self.renderer.set_cursor_style(style);
1202    }
1203}
1204
1205const fn from_crossterm_key_code(
1206    code: crossterm::event::KeyCode,
1207) -> Option<retroglyph_core::event::KeyCode> {
1208    use crossterm::event::KeyCode as CK;
1209    use retroglyph_core::event::KeyCode as K;
1210    match code {
1211        CK::Char(c) => Some(K::Char(c)),
1212        CK::F(n) => Some(K::F(n)),
1213        CK::Backspace => Some(K::Backspace),
1214        CK::Enter => Some(K::Enter),
1215        CK::Left => Some(K::Left),
1216        CK::Right => Some(K::Right),
1217        CK::Up => Some(K::Up),
1218        CK::Down => Some(K::Down),
1219        CK::Home => Some(K::Home),
1220        CK::End => Some(K::End),
1221        CK::PageUp => Some(K::PageUp),
1222        CK::PageDown => Some(K::PageDown),
1223        CK::Tab => Some(K::Tab),
1224        CK::BackTab => Some(K::BackTab),
1225        CK::Delete => Some(K::Delete),
1226        CK::Insert => Some(K::Insert),
1227        CK::Esc => Some(K::Escape),
1228        CK::CapsLock => Some(K::CapsLock),
1229        CK::ScrollLock => Some(K::ScrollLock),
1230        CK::NumLock => Some(K::NumLock),
1231        CK::PrintScreen => Some(K::PrintScreen),
1232        CK::Pause => Some(K::Pause),
1233        CK::Menu => Some(K::Menu),
1234        _ => None,
1235    }
1236}
1237
1238/// Maps a bare modifier keypress reported under
1239/// [`crossterm::event::KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES`] to retroglyph's
1240/// flat [`ModifierKey`](retroglyph_core::event::ModifierKey) plus the side it came from.
1241///
1242/// Crossterm's `Hyper`/`Meta`/`IsoLevel3Shift`/`IsoLevel5Shift` variants have no retroglyph
1243/// equivalent and fall through to `None`, same as any other unmapped key.
1244const fn from_crossterm_modifier_key_code(
1245    code: crossterm::event::ModifierKeyCode,
1246) -> Option<(
1247    retroglyph_core::event::ModifierKey,
1248    retroglyph_core::event::KeyLocation,
1249)> {
1250    use crossterm::event::ModifierKeyCode as CM;
1251    use retroglyph_core::event::KeyLocation as L;
1252    use retroglyph_core::event::ModifierKey as M;
1253    match code {
1254        CM::LeftShift => Some((M::Shift, L::Left)),
1255        CM::RightShift => Some((M::Shift, L::Right)),
1256        CM::LeftControl => Some((M::Control, L::Left)),
1257        CM::RightControl => Some((M::Control, L::Right)),
1258        CM::LeftAlt => Some((M::Alt, L::Left)),
1259        CM::RightAlt => Some((M::Alt, L::Right)),
1260        CM::LeftSuper => Some((M::Super, L::Left)),
1261        CM::RightSuper => Some((M::Super, L::Right)),
1262        CM::LeftHyper
1263        | CM::RightHyper
1264        | CM::LeftMeta
1265        | CM::RightMeta
1266        | CM::IsoLevel3Shift
1267        | CM::IsoLevel5Shift => None,
1268    }
1269}
1270
1271const fn from_crossterm_key_kind(
1272    kind: crossterm::event::KeyEventKind,
1273) -> retroglyph_core::event::KeyEventKind {
1274    use crossterm::event::KeyEventKind as CK;
1275    use retroglyph_core::event::KeyEventKind as K;
1276    match kind {
1277        CK::Press => K::Press,
1278        CK::Repeat => K::Repeat,
1279        CK::Release => K::Release,
1280    }
1281}
1282
1283// `KeyEventState::KEYPAD` (set under the kitty keyboard protocol) is the only thing this
1284// state-flags-only mapping can report; it has no notion of a left/right pair for a single
1285// symbolic key on its own. `Left`/`Right` locations ARE reachable from this backend, but only via
1286// `crossterm::event::ModifierKeyCode`'s own Left/Right variants (see
1287// `from_crossterm_modifier_key_code`), which `from_crossterm_event` consults instead of this
1288// function for `KeyCode::Modifier` events.
1289const fn from_crossterm_key_state(
1290    state: crossterm::event::KeyEventState,
1291) -> retroglyph_core::event::KeyLocation {
1292    use retroglyph_core::event::KeyLocation as L;
1293    if state.contains(crossterm::event::KeyEventState::KEYPAD) {
1294        L::Numpad
1295    } else {
1296        L::Standard
1297    }
1298}
1299
1300const fn from_crossterm_key_modifiers(
1301    mods: crossterm::event::KeyModifiers,
1302) -> retroglyph_core::event::KeyModifiers {
1303    retroglyph_core::event::KeyModifiers::from_parts(
1304        mods.contains(crossterm::event::KeyModifiers::SHIFT),
1305        mods.contains(crossterm::event::KeyModifiers::CONTROL),
1306        mods.contains(crossterm::event::KeyModifiers::ALT),
1307        mods.contains(crossterm::event::KeyModifiers::SUPER),
1308    )
1309}
1310
1311const fn from_crossterm_mouse_button(
1312    btn: crossterm::event::MouseButton,
1313) -> retroglyph_core::event::MouseButton {
1314    use crossterm::event::MouseButton as CB;
1315    use retroglyph_core::event::MouseButton as B;
1316    match btn {
1317        CB::Left => B::Left,
1318        CB::Right => B::Right,
1319        CB::Middle => B::Middle,
1320    }
1321}
1322
1323// Every `crossterm::event::MouseEventKind` variant now has a retroglyph equivalent (unlike
1324// `from_crossterm_key_code`, which still has unmappable `KeyCode`s), so this is infallible.
1325//
1326// Crossterm's scroll variants are line-quantized with no magnitude of their own, so each is
1327// synthesized as a `Scroll{dx,dy}` of magnitude 1.0 in the matching sign direction (see
1328// `MouseEventKind::Scroll`'s docs for the sign convention this preserves).
1329const fn from_crossterm_mouse_event_kind(
1330    kind: crossterm::event::MouseEventKind,
1331) -> retroglyph_core::event::MouseEventKind {
1332    use crossterm::event::MouseEventKind as CM;
1333    use retroglyph_core::event::MouseEventKind as K;
1334    match kind {
1335        CM::Down(btn) => K::Down(from_crossterm_mouse_button(btn)),
1336        CM::Up(btn) => K::Up(from_crossterm_mouse_button(btn)),
1337        CM::Drag(btn) => K::Drag(from_crossterm_mouse_button(btn)),
1338        CM::Moved => K::Moved,
1339        CM::ScrollUp => K::Scroll { dx: 0.0, dy: 1.0 },
1340        CM::ScrollDown => K::Scroll { dx: 0.0, dy: -1.0 },
1341        CM::ScrollLeft => K::Scroll { dx: -1.0, dy: 0.0 },
1342        CM::ScrollRight => K::Scroll { dx: 1.0, dy: 0.0 },
1343    }
1344}
1345
1346const fn from_crossterm_mouse_event(
1347    m: crossterm::event::MouseEvent,
1348) -> retroglyph_core::event::MouseEvent {
1349    // Crossterm is a character-mode backend; it has no sub-cell resolution.
1350    retroglyph_core::event::MouseEvent::new(
1351        from_crossterm_mouse_event_kind(m.kind),
1352        Pos {
1353            x: m.column,
1354            y: m.row,
1355        },
1356        from_crossterm_key_modifiers(m.modifiers),
1357    )
1358}
1359
1360// Taking ownership matches the call site: `crossterm::event::read()` hands us
1361// a freshly-owned `Event` with nothing else holding a reference to it.
1362//
1363// `#[doc(hidden)] pub` (rather than private) solely so `benches/event_translation.rs` (a
1364// separate compiled crate, same restriction as an integration test) can call it directly to
1365// measure retroglyph#285's "event translation throughput" case; this is not a supported public
1366// API and can change or disappear without a semver-relevant changelog entry.
1367#[doc(hidden)]
1368// The single failure mode is "this event has no retroglyph equivalent", which `Option` expresses
1369// directly; the only caller (`poll_event`) already discards an unmappable event entirely (see the
1370// retry-on-unmappable-event loop above).
1371#[must_use]
1372#[allow(clippy::needless_pass_by_value)]
1373pub fn from_crossterm_event(event: crossterm::event::Event) -> Option<Event> {
1374    use crossterm::event::Event as CE;
1375    match event {
1376        CE::Key(k) => {
1377            // A bare modifier press takes a separate path: `ModifierKeyCode` carries the
1378            // left/right side itself, which overrides whatever `from_crossterm_key_state` would
1379            // otherwise report (it only ever detects `Numpad` from the `KEYPAD` state flag).
1380            if let crossterm::event::KeyCode::Modifier(mkc) = k.code {
1381                let (modifier, location) = from_crossterm_modifier_key_code(mkc)?;
1382                return Some(Event::Key(retroglyph_core::event::KeyEvent::with_location(
1383                    retroglyph_core::event::KeyCode::Modifier(modifier),
1384                    from_crossterm_key_modifiers(k.modifiers),
1385                    from_crossterm_key_kind(k.kind),
1386                    location,
1387                )));
1388            }
1389
1390            // With `DISAMBIGUATE_ESCAPE_CODES` enabled (see `keyboard_enhancement_flags`),
1391            // terminals that support the kitty keyboard protocol report Shift+Tab as `Tab` plus
1392            // a shift modifier (CSI-u always encodes Tab's base codepoint, never a separate
1393            // "backtab" one) rather than the legacy `ESC[Z` -> `KeyCode::BackTab` escape. Without
1394            // this, Shift+Tab is silently indistinguishable from plain Tab on any terminal that
1395            // negotiated the enhanced protocol (kitty, WezTerm, foot, Ghostty, recent Alacritty).
1396            let is_shift_tab = matches!(k.code, crossterm::event::KeyCode::Tab)
1397                && k.modifiers.contains(crossterm::event::KeyModifiers::SHIFT);
1398            let code = if is_shift_tab {
1399                retroglyph_core::event::KeyCode::BackTab
1400            } else {
1401                from_crossterm_key_code(k.code)?
1402            };
1403            Some(Event::Key(retroglyph_core::event::KeyEvent::with_location(
1404                code,
1405                from_crossterm_key_modifiers(k.modifiers),
1406                from_crossterm_key_kind(k.kind),
1407                from_crossterm_key_state(k.state),
1408            )))
1409        }
1410        CE::Mouse(m) => Some(Event::Mouse(from_crossterm_mouse_event(m))),
1411        CE::Resize(w, h) => Some(Event::Resize(w, h)),
1412        CE::Paste(text) => Some(Event::Paste(text)),
1413        CE::FocusGained => Some(Event::FocusGained),
1414        CE::FocusLost => Some(Event::FocusLost),
1415    }
1416}
1417
1418#[cfg(test)]
1419mod tests {
1420    use super::*;
1421    use retroglyph_core::tile::Tile;
1422
1423    // `cargo test` runs `#[test]` functions in this module across multiple threads by default.
1424    // Any test that actually constructs a `Crossterm`/acquires an `InstanceGuard` contends for
1425    // the same process-wide `INSTANCE_LIVE` flag, so without serializing them a legitimate
1426    // concurrent construction from an unrelated test in this file could spuriously trip the new
1427    // "second live instance" rejection. This lock (held only by tests that touch that shared
1428    // state) keeps those tests deterministic without disabling parallelism for the whole file.
1429    static TEST_GUARD_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1430
1431    /// The [`CrosstermOptions`] every headless test builds against: every TTY-only feature
1432    /// (raw mode, alt screen, mouse/focus/paste/kitty) disabled, so construction succeeds
1433    /// against a plain `Vec<u8>` writer without a real controlling terminal.
1434    fn headless_options() -> CrosstermOptions {
1435        Crossterm::builder()
1436            .raw_mode(false)
1437            .alt_screen(false)
1438            .mouse_capture(false)
1439            .focus_change(false)
1440            .bracketed_paste(false)
1441            .kitty_protocol(false)
1442    }
1443
1444    /// Pairs a held `TEST_GUARD_LOCK` guard with a [`headless_options`] instance built against a
1445    /// `Vec<u8>` writer, so the lock can never be acquired without the instance (or dropped
1446    /// early by binding the guard to `_`): see [`InstanceGuard`] for why only one live
1447    /// `Crossterm` may exist at a time, and `TEST_GUARD_LOCK` for why these headless tests
1448    /// serialize on top of that.
1449    fn headless_term() -> (std::sync::MutexGuard<'static, ()>, Crossterm<Vec<u8>>) {
1450        let lock = TEST_GUARD_LOCK
1451            .lock()
1452            .unwrap_or_else(std::sync::PoisonError::into_inner);
1453        let term = headless_options()
1454            .build_with_writer(Vec::new())
1455            .expect("building against a Vec<u8> writer with all TTY features disabled must not require a real terminal");
1456        (lock, term)
1457    }
1458
1459    fn key_code_of(ct_event: crossterm::event::Event) -> retroglyph_core::event::KeyCode {
1460        match from_crossterm_event(ct_event) {
1461            Some(Event::Key(key)) => key.code,
1462            other => panic!("expected Some(Event::Key(_)), got {other:?}"),
1463        }
1464    }
1465
1466    #[test]
1467    fn restore_sequence_resets_all_sgr_attributes() {
1468        // Regression test: `restore_terminal` (shared by `Drop`, the panic hook, and `suspend`)
1469        // used to leave the last frame's SGR colors/attributes active on exit. That leftover
1470        // "pen" state leaked into the shell, and into whatever a terminal uses to paint newly
1471        // erased cells the next time this process entered the alternate screen, so a background
1472        // that blended against `Color::Default` got a little darker on every subsequent launch
1473        // instead of starting from a clean slate. `write_restore_sequence` must always emit a
1474        // full SGR reset (`\x1b[0m`) so every restore leaves the terminal's pen at its own
1475        // default, regardless of whatever color/attribute state the app last drew with.
1476        let mut buf = Vec::new();
1477        write_restore_sequence(&mut buf).unwrap();
1478        let out = String::from_utf8(buf).unwrap();
1479        assert!(
1480            out.contains("\x1b[0m"),
1481            "restore sequence must reset all SGR attributes, got: {out:?}"
1482        );
1483    }
1484
1485    #[test]
1486    fn keypad_state_maps_to_numpad_location() {
1487        let ct_event =
1488            crossterm::event::Event::Key(crossterm::event::KeyEvent::new_with_kind_and_state(
1489                crossterm::event::KeyCode::Char('8'),
1490                crossterm::event::KeyModifiers::NONE,
1491                crossterm::event::KeyEventKind::Press,
1492                crossterm::event::KeyEventState::KEYPAD,
1493            ));
1494        let Some(Event::Key(key)) = from_crossterm_event(ct_event) else {
1495            panic!("expected Some(Event::Key(_))");
1496        };
1497        assert_eq!(key.location, retroglyph_core::event::KeyLocation::Numpad);
1498    }
1499
1500    #[test]
1501    fn no_keypad_state_maps_to_standard_location() {
1502        let ct_event = crossterm::event::Event::Key(crossterm::event::KeyEvent::new(
1503            crossterm::event::KeyCode::Char('8'),
1504            crossterm::event::KeyModifiers::NONE,
1505        ));
1506        let Some(Event::Key(key)) = from_crossterm_event(ct_event) else {
1507            panic!("expected Some(Event::Key(_))");
1508        };
1509        assert_eq!(key.location, retroglyph_core::event::KeyLocation::Standard);
1510    }
1511
1512    #[test]
1513    fn left_shift_modifier_key_maps_to_modifier_with_left_location() {
1514        use retroglyph_core::event::{KeyCode, KeyLocation, ModifierKey};
1515
1516        let ct_event = crossterm::event::Event::Key(crossterm::event::KeyEvent::new(
1517            crossterm::event::KeyCode::Modifier(crossterm::event::ModifierKeyCode::LeftShift),
1518            crossterm::event::KeyModifiers::SHIFT,
1519        ));
1520        let Some(Event::Key(key)) = from_crossterm_event(ct_event) else {
1521            panic!("expected Some(Event::Key(_))");
1522        };
1523        assert_eq!(key.code, KeyCode::Modifier(ModifierKey::Shift));
1524        assert_eq!(key.location, KeyLocation::Left);
1525    }
1526
1527    #[test]
1528    fn right_alt_modifier_key_maps_to_modifier_with_right_location() {
1529        use retroglyph_core::event::{KeyCode, KeyLocation, ModifierKey};
1530
1531        let ct_event = crossterm::event::Event::Key(crossterm::event::KeyEvent::new(
1532            crossterm::event::KeyCode::Modifier(crossterm::event::ModifierKeyCode::RightAlt),
1533            crossterm::event::KeyModifiers::ALT,
1534        ));
1535        let Some(Event::Key(key)) = from_crossterm_event(ct_event) else {
1536            panic!("expected Some(Event::Key(_))");
1537        };
1538        assert_eq!(key.code, KeyCode::Modifier(ModifierKey::Alt));
1539        assert_eq!(key.location, KeyLocation::Right);
1540    }
1541
1542    #[test]
1543    fn hyper_modifier_key_has_no_retroglyph_equivalent() {
1544        let ct_event = crossterm::event::Event::Key(crossterm::event::KeyEvent::new(
1545            crossterm::event::KeyCode::Modifier(crossterm::event::ModifierKeyCode::LeftHyper),
1546            crossterm::event::KeyModifiers::NONE,
1547        ));
1548        assert_eq!(from_crossterm_event(ct_event), None);
1549    }
1550
1551    #[test]
1552    fn caps_lock_maps_straight_through() {
1553        let ct_event = crossterm::event::Event::Key(crossterm::event::KeyEvent::new(
1554            crossterm::event::KeyCode::CapsLock,
1555            crossterm::event::KeyModifiers::NONE,
1556        ));
1557        assert_eq!(
1558            key_code_of(ct_event),
1559            retroglyph_core::event::KeyCode::CapsLock
1560        );
1561    }
1562
1563    #[test]
1564    fn crossterm_options_default_matches_historical_always_on_behavior() {
1565        // `Crossterm::new()` used to unconditionally enable raw mode, the alternate screen,
1566        // mouse capture, and push the kitty keyboard protocol; `CrosstermOptions::default()`
1567        // must preserve that behavior exactly so `Crossterm::new()` (which delegates to
1568        // `with_options(CrosstermOptions::default())`) stays backward compatible. Focus-change
1569        // and bracketed-paste reporting are later additions and default to enabled as well,
1570        // consistent with the other features.
1571        let options = CrosstermOptions::default();
1572        assert!(options.mouse_capture);
1573        assert!(options.kitty_protocol);
1574        assert!(options.focus_change);
1575        assert!(options.bracketed_paste);
1576        assert!(options.alt_screen);
1577        assert!(options.raw_mode);
1578    }
1579
1580    #[test]
1581    fn crossterm_builder_is_equivalent_to_options_new() {
1582        // `Crossterm::builder()` is the documented preferred entry point; it must produce the
1583        // same defaults as `CrosstermOptions::new()`/`::default()`.
1584        assert_eq!(Crossterm::builder(), CrosstermOptions::new());
1585    }
1586
1587    #[test]
1588    fn disabling_raw_mode_and_alt_screen_lets_build_succeed_without_a_tty() {
1589        // With both raw mode and the alternate screen opted out, `build()` no longer calls
1590        // `enable_raw_mode()`/`EnterAlternateScreen` (the two commands that fail outright
1591        // without a real controlling terminal), so construction can succeed even against a
1592        // fully redirected/piped stdout (as under `cargo test`). Skip the assertion (rather than
1593        // failing) on the rare environment where even the always-safe cursor-hide escape write
1594        // fails outright (e.g. a closed stdout), since that's not what this test is about.
1595        let _lock = TEST_GUARD_LOCK
1596            .lock()
1597            .unwrap_or_else(std::sync::PoisonError::into_inner);
1598        if let Ok(term) = Crossterm::builder()
1599            .raw_mode(false)
1600            .alt_screen(false)
1601            .build()
1602        {
1603            drop(term);
1604        }
1605    }
1606
1607    #[test]
1608    fn launch_builds_and_drives_the_app_to_flow_exit() {
1609        use retroglyph_core::app::Launch;
1610
1611        struct ExitImmediately;
1612        impl<B: retroglyph_core::backend::Backend> retroglyph_core::app::App<B> for ExitImmediately {
1613            fn update(
1614                &mut self,
1615                _term: &mut Terminal<B>,
1616                _frame: &retroglyph_core::app::Frame,
1617            ) -> retroglyph_core::app::Flow {
1618                retroglyph_core::app::Flow::Exit
1619            }
1620        }
1621
1622        // Same TTY-free combination the other `build`/`build_with_writer` tests use, so `Launch::
1623        // launch`'s call to `self.build()` (stdout, not `build_with_writer`) can still succeed
1624        // under `cargo test`'s redirected stdout.
1625        let _lock = TEST_GUARD_LOCK
1626            .lock()
1627            .unwrap_or_else(std::sync::PoisonError::into_inner);
1628
1629        let result = Crossterm::builder()
1630            .raw_mode(false)
1631            .alt_screen(false)
1632            .mouse_capture(false)
1633            .focus_change(false)
1634            .bracketed_paste(false)
1635            .kitty_protocol(false)
1636            .launch(ExitImmediately, retroglyph_core::app::RunOptions::default());
1637        // Skip (rather than fail) on the rare environment where even the always-safe cursor-hide
1638        // escape write fails outright (e.g. a closed stdout): see the `build()`-only test above
1639        // for why that's not what this test is about. Reaching past `launch` at all (rather than
1640        // panicking inside it) is the proof that `Launch::launch` both builds the backend and
1641        // drives `ExitImmediately` to `Flow::Exit` through `run_on_with`.
1642        if let Err(err) = result {
1643            let _ = err;
1644        }
1645    }
1646
1647    #[test]
1648    fn suspend_resume_forces_a_full_redraw() {
1649        // With every TTY-only feature disabled (the same combination other `build_with_writer`
1650        // tests use to run without a real terminal), `suspend`/resuming a dropped `SuspendGuard`
1651        // still exercise the shared restore/`enable_terminal_features` machinery: both only
1652        // touch process stdout via always-safe commands (cursor show/hide, disabling features
1653        // that were never enabled) when raw mode/the alternate screen are off, so this succeeds
1654        // under `cargo test`'s non-TTY stdout.
1655        let (_lock, mut term) = headless_term();
1656
1657        let tile = Tile::new('X', retroglyph_core::color::Style::default());
1658
1659        // Establish tracked cursor state at (0, 0): a second draw at the same position would
1660        // normally skip the `MoveTo` escape since the cursor is already tracked as being there.
1661        term.draw(core::iter::once(DrawCell::new(Pos { x: 0, y: 0 }, &tile)))
1662            .unwrap();
1663        term.flush().unwrap();
1664
1665        {
1666            let guard = term.suspend().expect("suspend must succeed without a real terminal once all TTY-only features are disabled");
1667            drop(guard);
1668        }
1669
1670        // `resume` (run here via `Drop`) must have called `reset_state`, so this draw at the same
1671        // position re-emits the cursor-move escape instead of skipping it.
1672        term.draw(core::iter::once(DrawCell::new(Pos { x: 0, y: 0 }, &tile)))
1673            .unwrap();
1674        term.flush().unwrap();
1675
1676        let written = String::from_utf8(term.writer().clone()).unwrap();
1677        assert_eq!(
1678            written.matches("\x1b[1;1H").count(),
1679            2,
1680            "expected the cursor-move escape to be re-emitted after resume: {written:?}"
1681        );
1682    }
1683
1684    #[test]
1685    fn set_cursor_position_desyncs_the_renderers_tracked_cursor() {
1686        // Regression test for retroglyph#713: `set_cursor_position` queued its `MoveTo` escape
1687        // straight into the writer without telling the shared `TerminalRenderer` the cursor had
1688        // moved, so `cursor_x`/`cursor_y` kept whatever position the last *drawn glyph* left them
1689        // at. A subsequent `draw()` whose first changed cell happened to match that stale tracked
1690        // position then skipped its own `MoveTo` entirely, painting wherever the real cursor was
1691        // actually left (by `set_cursor_position`) instead of the intended cell.
1692        let (_lock, mut term) = headless_term();
1693
1694        let tile_a = Tile::new('A', retroglyph_core::color::Style::default());
1695        let tile_b = Tile::new('B', retroglyph_core::color::Style::default());
1696
1697        // Drawing at (0, 0) leaves the renderer tracking the cursor at (1, 0), right after the
1698        // glyph it just wrote.
1699        term.draw(core::iter::once(DrawCell::new(Pos { x: 0, y: 0 }, &tile_a)))
1700            .unwrap();
1701        term.flush().unwrap();
1702
1703        // The app parks the caret elsewhere, e.g. a text field or status line, once per frame: a
1704        // common pattern that must not corrupt the next frame's diff.
1705        Cursor::set_cursor_position(&mut term, Pos { x: 7, y: 3 });
1706        term.flush().unwrap();
1707
1708        // The first (and only) changed cell in this frame is exactly (1, 0): the position
1709        // `set_cursor_position` desynced the tracked cursor from.
1710        term.draw(core::iter::once(DrawCell::new(Pos { x: 1, y: 0 }, &tile_b)))
1711            .unwrap();
1712        term.flush().unwrap();
1713
1714        let written = String::from_utf8(term.writer().clone()).unwrap();
1715        let cup_1_0 = "\x1b[1;2H"; // 1-indexed CUP for (x=1, y=0)
1716        let b_pos = written
1717            .rfind('B')
1718            .unwrap_or_else(|| panic!("expected 'B' in output: {written:?}"));
1719        let cup_pos = written.rfind(cup_1_0).unwrap_or_else(|| {
1720            panic!("expected a CUP back to (1, 0) before drawing 'B', got: {written:?}")
1721        });
1722        assert!(
1723            cup_pos < b_pos,
1724            "CUP to (1, 0) must precede 'B': {written:?}"
1725        );
1726    }
1727
1728    #[test]
1729    fn suspend_resume_is_idempotent_when_resume_is_called_explicitly() {
1730        let (_lock, mut term) = headless_term();
1731
1732        let guard = term.suspend().expect(
1733            "suspend must succeed without a real terminal once all TTY-only features are disabled",
1734        );
1735        guard.resume().expect(
1736            "resume must succeed without a real terminal once all TTY-only features are disabled",
1737        );
1738        // The guard is consumed by `resume`, so there's no double-restore on drop to assert
1739        // against directly; this test's real assertion is that `resume()` itself returns `Ok`.
1740    }
1741
1742    #[test]
1743    fn restore_does_not_flush_pending_content_before_giving_the_terminal_back() {
1744        // Regression test for retroglyph#716: `suspend` (and `Crossterm::drop`, exercised the
1745        // same way here since both share `restore_terminal`) must flush whatever `draw` left
1746        // buffered in the renderer before handing control back, not after.
1747        let (_lock, mut term) = headless_term();
1748
1749        let tile = Tile::new('X', retroglyph_core::color::Style::default());
1750        // Drawn but deliberately *not* flushed: this is the buffered content `suspend` must not
1751        // strand behind the restore sequence.
1752        term.draw(core::iter::once(DrawCell::new(Pos { x: 0, y: 0 }, &tile)))
1753            .unwrap();
1754
1755        let guard = term.suspend().expect(
1756            "suspend must succeed without a real terminal once all TTY-only features are disabled",
1757        );
1758        // Once `suspend` has returned, the pending frame must already be visible in the writer,
1759        // not still sitting in `renderer`'s internal buffer waiting on a later flush.
1760        let written = String::from_utf8(guard.crossterm.writer().clone()).unwrap();
1761        assert!(
1762            written.contains('X'),
1763            "expected the drawn cell to have been flushed to the writer before suspend returned: {written:?}"
1764        );
1765        drop(guard);
1766    }
1767
1768    #[test]
1769    fn pushed_events_are_returned_by_poll_event_in_order() {
1770        use retroglyph_core::event::{KeyCode, KeyEvent, KeyModifiers};
1771
1772        let _lock = TEST_GUARD_LOCK
1773            .lock()
1774            .unwrap_or_else(std::sync::PoisonError::into_inner);
1775        let Ok(mut term) = headless_options().build_with_writer(Vec::new()) else {
1776            return;
1777        };
1778
1779        let first = Event::Key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
1780        let second = Event::Resize(10, 4);
1781        term.push_event(first.clone());
1782        term.push_event(second.clone());
1783
1784        assert_eq!(term.poll_event(Duration::ZERO), Some(first));
1785        assert_eq!(term.poll_event(Duration::ZERO), Some(second));
1786        // Drained: the next poll falls through to the real (empty, non-TTY) event source.
1787        assert_eq!(term.poll_event(Duration::ZERO), None);
1788    }
1789
1790    #[test]
1791    fn build_with_writer_renders_cell_content_into_a_custom_sink() {
1792        // The whole point of a generic content writer: draw/flush output lands in `writer`
1793        // (here a `Vec<u8>`) instead of stdout, with no real terminal required as long as the
1794        // real-terminal-only features (raw mode, alt screen, mouse/focus/paste/kitty) are
1795        // disabled, exactly the combination `CrosstermOptions::build_with_writer`'s docs
1796        // recommend for a non-TTY sink.
1797        let (_lock, mut term) = headless_term();
1798
1799        let tile = Tile::new('X', retroglyph_core::color::Style::default());
1800        term.draw(core::iter::once(DrawCell::new(Pos { x: 0, y: 0 }, &tile)))
1801            .unwrap();
1802        term.flush().unwrap();
1803
1804        let written = String::from_utf8(term.writer().clone()).unwrap();
1805        assert!(
1806            written.contains('X'),
1807            "expected drawn glyph in output: {written:?}"
1808        );
1809        assert!(
1810            !term.plain_mode(),
1811            "build_with_writer must not auto-detect plain mode for an arbitrary writer"
1812        );
1813    }
1814
1815    #[test]
1816    fn clear_resets_sgr_attributes_before_erasing() {
1817        // Regression test: `Output::clear` (also called by `Output::resize` on every terminal
1818        // resize) used to issue `Clear(ClearType::All)` without resetting the SGR pen first.
1819        // Terminals that implement erase-display via background color erase (BCE) paint erased
1820        // cells with whatever background is currently active, not the terminal's true default,
1821        // so a colored cell drawn just before a resize left a stale tint across the whole
1822        // screen, and since `Terminal::resize` (in `retroglyph-core`) wipes the diff's
1823        // `previous` grid to default tiles, nothing ever draws over that tint again in areas
1824        // that stay at their default value. `clear` must emit a full SGR reset ahead of the
1825        // erase so BCE always paints with the terminal's real default background.
1826        let (_lock, mut term) = headless_term();
1827
1828        // Draw a colored cell first so the SGR pen is left non-default, mirroring the last
1829        // frame drawn before a real resize.
1830        let style =
1831            retroglyph_core::color::Style::new().bg(retroglyph_core::color::Color::rgb(200, 0, 0));
1832        let tile = Tile::new('X', style);
1833        term.draw(core::iter::once(DrawCell::new(Pos { x: 0, y: 0 }, &tile)))
1834            .unwrap();
1835        term.flush().unwrap();
1836
1837        term.clear().unwrap();
1838
1839        let written = String::from_utf8(term.writer().clone()).unwrap();
1840        let clear_pos = written
1841            .rfind("\x1b[2J")
1842            .unwrap_or_else(|| panic!("clear() must emit Clear(ClearType::All), got: {written:?}"));
1843        let reset_pos = written
1844            .rfind("\x1b[0m")
1845            .unwrap_or_else(|| panic!("clear() must emit a full SGR reset, got: {written:?}"));
1846        assert!(
1847            reset_pos < clear_pos,
1848            "SGR reset must precede the erase so background color erase paints with the \
1849             terminal's true default, not the last frame's color; output: {written:?}"
1850        );
1851    }
1852
1853    #[test]
1854    fn with_writer_is_equivalent_to_default_options_build_with_writer() {
1855        // `Crossterm::with_writer` is the `CrosstermOptions::default()` shortcut, matching how
1856        // `Crossterm::new()` relates to `with_options(CrosstermOptions::default())`. Both fail
1857        // the same way without a real terminal (raw mode/alt screen left enabled), so just
1858        // assert they agree on success or failure rather than requiring either to succeed. Each
1859        // build is dropped before the next one starts (rather than held simultaneously) so the
1860        // comparison reflects real-terminal availability, not a spurious rejection from the new
1861        // "only one live instance" guard tripping on the still-live first instance.
1862        let _lock = TEST_GUARD_LOCK
1863            .lock()
1864            .unwrap_or_else(std::sync::PoisonError::into_inner);
1865        let via_shortcut = Crossterm::with_writer(Vec::<u8>::new());
1866        let via_shortcut_ok = via_shortcut.is_ok();
1867        drop(via_shortcut);
1868        let via_builder = CrosstermOptions::default().build_with_writer(Vec::<u8>::new());
1869        let via_builder_ok = via_builder.is_ok();
1870        drop(via_builder);
1871        assert_eq!(via_shortcut_ok, via_builder_ok);
1872    }
1873
1874    #[test]
1875    fn instance_guard_rejects_a_second_concurrent_acquire() {
1876        // Exercises the internal guard type directly: deterministic and doesn't require a real
1877        // TTY, unlike driving raw-mode/alt-screen terminal calls through a full `Crossterm`
1878        // construction would. The first `acquire()` claims the process-wide flag; a second
1879        // `acquire()` while the first guard is still alive must be rejected; once the first guard
1880        // is dropped, `acquire()` must succeed again.
1881        let _lock = TEST_GUARD_LOCK
1882            .lock()
1883            .unwrap_or_else(std::sync::PoisonError::into_inner);
1884
1885        let first = InstanceGuard::acquire().expect("first acquire must succeed");
1886
1887        match InstanceGuard::acquire() {
1888            Ok(_) => {
1889                panic!("a second concurrent acquire must be rejected while the first guard is live")
1890            }
1891            Err(err) => assert_eq!(err.kind(), std::io::ErrorKind::ResourceBusy),
1892        }
1893
1894        drop(first);
1895
1896        let third = InstanceGuard::acquire();
1897        assert!(
1898            third.is_ok(),
1899            "acquire must succeed again once the prior guard is dropped"
1900        );
1901    }
1902
1903    #[test]
1904    fn constructing_a_second_live_crossterm_is_rejected_until_the_first_is_dropped() {
1905        // End-to-end version of `instance_guard_rejects_a_second_concurrent_acquire`, through the
1906        // public `Crossterm` API rather than the internal guard type. All TTY-only features are
1907        // disabled so construction doesn't require a real terminal (see
1908        // `build_with_writer_renders_cell_content_into_a_custom_sink` above for why that
1909        // combination is safe under `cargo test`'s captured, non-TTY stdout).
1910        let _lock = TEST_GUARD_LOCK
1911            .lock()
1912            .unwrap_or_else(std::sync::PoisonError::into_inner);
1913
1914        let options = || {
1915            Crossterm::builder()
1916                .raw_mode(false)
1917                .alt_screen(false)
1918                .mouse_capture(false)
1919                .focus_change(false)
1920                .bracketed_paste(false)
1921                .kitty_protocol(false)
1922        };
1923
1924        let first = options()
1925            .build_with_writer(Vec::new())
1926            .expect("first construction must succeed without a TTY");
1927
1928        match options().build_with_writer(Vec::new()) {
1929            Ok(_) => panic!(
1930                "a second live Crossterm instance must be rejected while the first is still alive"
1931            ),
1932            Err(err) => assert_eq!(err.kind(), std::io::ErrorKind::ResourceBusy),
1933        }
1934
1935        drop(first);
1936
1937        let third = options().build_with_writer(Vec::new());
1938        assert!(
1939            third.is_ok(),
1940            "construction must succeed again once the first instance is dropped"
1941        );
1942    }
1943
1944    #[test]
1945    fn crossterm_options_can_opt_out_of_all_features() {
1946        // Compile-level/API-shape check: building a `CrosstermOptions` with all flags disabled
1947        // via the builder type-checks and round-trips its fields. Exercising the actual terminal
1948        // commands (`with_options`/`build` itself) requires a real TTY, which isn't available in
1949        // CI, so this is not a full integration test (see tests/non_tty.rs for the
1950        // non-TTY integration coverage that is possible without one).
1951        let options = CrosstermOptions::new()
1952            .mouse_capture(false)
1953            .kitty_protocol(false)
1954            .focus_change(false)
1955            .bracketed_paste(false)
1956            .alt_screen(false)
1957            .raw_mode(false);
1958        assert!(!options.mouse_capture);
1959        assert!(!options.kitty_protocol);
1960        assert!(!options.focus_change);
1961        assert!(!options.bracketed_paste);
1962        assert!(!options.alt_screen);
1963        assert!(!options.raw_mode);
1964    }
1965
1966    #[test]
1967    fn legacy_backtab_maps_straight_through() {
1968        // Terminals without the kitty protocol send the legacy `ESC[Z` escape, which crossterm
1969        // already decodes as `KeyCode::BackTab` with no shift modifier attached.
1970        let ct_event = crossterm::event::Event::Key(crossterm::event::KeyEvent::new(
1971            crossterm::event::KeyCode::BackTab,
1972            crossterm::event::KeyModifiers::NONE,
1973        ));
1974        assert_eq!(
1975            key_code_of(ct_event),
1976            retroglyph_core::event::KeyCode::BackTab
1977        );
1978    }
1979
1980    #[test]
1981    fn kitty_protocol_shift_tab_normalizes_to_backtab() {
1982        // Under DISAMBIGUATE_ESCAPE_CODES, kitty-protocol terminals report Shift+Tab as plain
1983        // `Tab` plus a shift modifier rather than a distinct backtab code: this is the case
1984        // `from_crossterm_event` has to normalize.
1985        let ct_event = crossterm::event::Event::Key(crossterm::event::KeyEvent::new(
1986            crossterm::event::KeyCode::Tab,
1987            crossterm::event::KeyModifiers::SHIFT,
1988        ));
1989        assert_eq!(
1990            key_code_of(ct_event),
1991            retroglyph_core::event::KeyCode::BackTab
1992        );
1993    }
1994
1995    #[test]
1996    fn plain_tab_is_unaffected() {
1997        let ct_event = crossterm::event::Event::Key(crossterm::event::KeyEvent::new(
1998            crossterm::event::KeyCode::Tab,
1999            crossterm::event::KeyModifiers::NONE,
2000        ));
2001        assert_eq!(key_code_of(ct_event), retroglyph_core::event::KeyCode::Tab);
2002    }
2003
2004    #[test]
2005    fn shift_modifier_on_non_tab_keys_is_unaffected() {
2006        let ct_event = crossterm::event::Event::Key(crossterm::event::KeyEvent::new(
2007            crossterm::event::KeyCode::Char('a'),
2008            crossterm::event::KeyModifiers::SHIFT,
2009        ));
2010        assert_eq!(
2011            key_code_of(ct_event),
2012            retroglyph_core::event::KeyCode::Char('a')
2013        );
2014    }
2015
2016    #[test]
2017    fn crossterm_super_modifier_is_mapped_on_key_events() {
2018        // Under DISAMBIGUATE_ESCAPE_CODES (the default kitty flag negotiated by
2019        // `Crossterm::builder()`), a compliant terminal reports the Super/Cmd bit, and
2020        // crossterm's own `parse_modifiers` already maps it to `KeyModifiers::SUPER`. This
2021        // translation layer must not drop it (retroglyph#714).
2022        let ct_event = crossterm::event::Event::Key(crossterm::event::KeyEvent::new(
2023            crossterm::event::KeyCode::Char('s'),
2024            crossterm::event::KeyModifiers::SUPER,
2025        ));
2026        let Some(Event::Key(key)) = from_crossterm_event(ct_event) else {
2027            panic!("expected a key event")
2028        };
2029        assert!(
2030            key.modifiers
2031                .contains(retroglyph_core::event::KeyModifiers::SUPER)
2032        );
2033        assert_eq!(key.code, retroglyph_core::event::KeyCode::Char('s'));
2034    }
2035
2036    #[test]
2037    fn crossterm_super_modifier_is_mapped_on_mouse_events() {
2038        // The same translation function backs mouse events, so the gap in
2039        // `from_crossterm_key_modifiers` affected Cmd-modified clicks identically (retroglyph#714).
2040        let ct_event = crossterm::event::Event::Mouse(crossterm::event::MouseEvent {
2041            kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
2042            column: 0,
2043            row: 0,
2044            modifiers: crossterm::event::KeyModifiers::SUPER,
2045        });
2046        let Some(Event::Mouse(mouse)) = from_crossterm_event(ct_event) else {
2047            panic!("expected a mouse event")
2048        };
2049        assert!(
2050            mouse
2051                .modifiers
2052                .contains(retroglyph_core::event::KeyModifiers::SUPER)
2053        );
2054    }
2055
2056    #[test]
2057    fn size_does_not_requery_after_construction() {
2058        // `size()` must return the field cached at construction rather than issuing a fresh
2059        // `crossterm::terminal::size()` syscall on every call (retroglyph#279). Overwrite the
2060        // cached field with a sentinel value no real terminal query would plausibly produce,
2061        // then confirm `size()` echoes that sentinel back instead of re-querying and returning
2062        // whatever the actual (non-TTY, under `cargo test`) terminal size happens to be.
2063        let (_lock, mut term) = headless_term();
2064
2065        let sentinel = Size::new(4321, 1234);
2066        term.cached_size = sentinel;
2067
2068        assert_eq!(
2069            term.size(),
2070            sentinel,
2071            "size() must return the cached field, not re-query the terminal"
2072        );
2073    }
2074
2075    #[test]
2076    fn resize_event_refreshes_the_cached_size() {
2077        // `poll_event` refreshes the cached size in lockstep with any `Event::Resize` it reads
2078        // (retroglyph#279). `refresh_cached_size_on_resize` is the extracted helper it
2079        // calls; exercising it directly avoids needing a real terminal event source to prove
2080        // the cache-update behavior.
2081        let (_lock, mut term) = headless_term();
2082
2083        term.refresh_cached_size_on_resize(&crossterm::event::Event::Resize(120, 40));
2084        assert_eq!(term.size(), Size::new(120, 40));
2085
2086        // Non-resize events must not disturb the cached size.
2087        term.refresh_cached_size_on_resize(&crossterm::event::Event::FocusGained);
2088        assert_eq!(term.size(), Size::new(120, 40));
2089    }
2090
2091    #[test]
2092    fn crossterm_paste_maps_to_retroglyph_paste() {
2093        let ct_event = crossterm::event::Event::Paste("pasted text".to_string());
2094        match from_crossterm_event(ct_event) {
2095            Some(Event::Paste(text)) => assert_eq!(text, "pasted text"),
2096            other => panic!("expected Some(Event::Paste(_)), got {other:?}"),
2097        }
2098    }
2099
2100    #[test]
2101    fn crossterm_focus_gained_maps_correctly() {
2102        let ct_event = crossterm::event::Event::FocusGained;
2103        assert!(matches!(
2104            from_crossterm_event(ct_event),
2105            Some(Event::FocusGained)
2106        ));
2107    }
2108
2109    #[test]
2110    fn crossterm_focus_lost_maps_correctly() {
2111        let ct_event = crossterm::event::Event::FocusLost;
2112        assert!(matches!(
2113            from_crossterm_event(ct_event),
2114            Some(Event::FocusLost)
2115        ));
2116    }
2117
2118    fn mouse_event_kind_of(
2119        kind: crossterm::event::MouseEventKind,
2120    ) -> retroglyph_core::event::MouseEventKind {
2121        from_crossterm_mouse_event_kind(kind)
2122    }
2123
2124    #[test]
2125    fn mouse_down_and_up_still_map_after_option_signature_change() {
2126        use retroglyph_core::event::{MouseButton as B, MouseEventKind as K};
2127
2128        assert_eq!(
2129            mouse_event_kind_of(crossterm::event::MouseEventKind::Down(
2130                crossterm::event::MouseButton::Left
2131            )),
2132            K::Down(B::Left)
2133        );
2134        assert_eq!(
2135            mouse_event_kind_of(crossterm::event::MouseEventKind::Up(
2136                crossterm::event::MouseButton::Right
2137            )),
2138            K::Up(B::Right)
2139        );
2140        assert_eq!(
2141            mouse_event_kind_of(crossterm::event::MouseEventKind::Moved),
2142            K::Moved
2143        );
2144        assert_eq!(
2145            mouse_event_kind_of(crossterm::event::MouseEventKind::ScrollUp),
2146            K::Scroll { dx: 0.0, dy: 1.0 }
2147        );
2148        assert_eq!(
2149            mouse_event_kind_of(crossterm::event::MouseEventKind::ScrollDown),
2150            K::Scroll { dx: 0.0, dy: -1.0 }
2151        );
2152    }
2153
2154    #[test]
2155    fn mouse_drag_preserves_which_button_is_held() {
2156        use retroglyph_core::event::{MouseButton as B, MouseEventKind as K};
2157
2158        assert_eq!(
2159            mouse_event_kind_of(crossterm::event::MouseEventKind::Drag(
2160                crossterm::event::MouseButton::Left
2161            )),
2162            K::Drag(B::Left)
2163        );
2164        assert_eq!(
2165            mouse_event_kind_of(crossterm::event::MouseEventKind::Drag(
2166                crossterm::event::MouseButton::Right
2167            )),
2168            K::Drag(B::Right)
2169        );
2170        assert_eq!(
2171            mouse_event_kind_of(crossterm::event::MouseEventKind::Drag(
2172                crossterm::event::MouseButton::Middle
2173            )),
2174            K::Drag(B::Middle)
2175        );
2176    }
2177
2178    #[test]
2179    fn mouse_horizontal_scroll_round_trips() {
2180        use retroglyph_core::event::MouseEventKind as K;
2181
2182        assert_eq!(
2183            mouse_event_kind_of(crossterm::event::MouseEventKind::ScrollLeft),
2184            K::Scroll { dx: -1.0, dy: 0.0 }
2185        );
2186        assert_eq!(
2187            mouse_event_kind_of(crossterm::event::MouseEventKind::ScrollRight),
2188            K::Scroll { dx: 1.0, dy: 0.0 }
2189        );
2190    }
2191
2192    #[test]
2193    fn detect_color_support_no_color_wins_over_everything_else() {
2194        use retroglyph_terminal::ColorSupport;
2195
2196        assert_eq!(
2197            detect_color_support(Some("1"), Some("dumb")),
2198            ColorSupport::None
2199        );
2200        // Any non-empty value counts, per https://no-color.org.
2201        assert_eq!(
2202            detect_color_support(Some("anything"), None),
2203            ColorSupport::None
2204        );
2205    }
2206
2207    #[test]
2208    fn detect_color_support_empty_no_color_does_not_count() {
2209        use retroglyph_terminal::ColorSupport;
2210
2211        assert_eq!(
2212            detect_color_support(Some(""), None),
2213            ColorSupport::Truecolor
2214        );
2215    }
2216
2217    #[test]
2218    fn detect_color_support_dumb_term_forces_none() {
2219        // The one $TERM value that's an unambiguous "assume nothing" signal: never emitted by
2220        // a terminal that actually supports color, unlike a "...256color" suffix (see the next
2221        // test).
2222        use retroglyph_terminal::ColorSupport;
2223
2224        assert_eq!(detect_color_support(None, Some("dumb")), ColorSupport::None);
2225    }
2226
2227    #[test]
2228    fn detect_color_support_falls_back_to_truecolor_with_no_signal() {
2229        // No `$NO_COLOR` and a `$TERM` that isn't the unambiguous "dumb" (or no `$TERM` at all,
2230        // as in a minimal CI/test harness): this must not be read as evidence of a limited
2231        // terminal, so it matches `TerminalRenderer`'s own `ColorSupport::default()` and this
2232        // crate's pre-existing always-truecolor behavior.
2233        use retroglyph_terminal::ColorSupport;
2234
2235        assert_eq!(detect_color_support(None, None), ColorSupport::Truecolor);
2236        assert_eq!(
2237            detect_color_support(None, Some("xterm")),
2238            ColorSupport::Truecolor
2239        );
2240        // retroglyph#585 CI: this workspace's own PTY test harness spawns every example with
2241        // exactly this $TERM and no $COLORTERM. A `$TERM`-text heuristic that read "256color" as
2242        // a limit (rather than the common, often truecolor-capable default it is) silently
2243        // downgraded every snapshot test's rendered colors and broke all of them at once.
2244        assert_eq!(
2245            detect_color_support(None, Some("xterm-256color")),
2246            ColorSupport::Truecolor
2247        );
2248    }
2249
2250    #[test]
2251    fn crossterm_options_color_support_override_is_used_verbatim() {
2252        use retroglyph_terminal::ColorSupport;
2253
2254        let _lock = TEST_GUARD_LOCK
2255            .lock()
2256            .unwrap_or_else(std::sync::PoisonError::into_inner);
2257        let term = headless_options()
2258            .color_support(ColorSupport::Indexed256)
2259            .build_with_writer(Vec::new())
2260            .expect("building against a Vec<u8> writer with all TTY features disabled must not require a real terminal");
2261
2262        assert_eq!(term.color_support(), ColorSupport::Indexed256);
2263    }
2264
2265    #[test]
2266    fn set_title_writes_the_osc_title_escape() {
2267        let (_lock, mut term) = headless_term();
2268
2269        term.set_title("my title").unwrap();
2270
2271        let written = String::from_utf8(term.writer().clone()).unwrap();
2272        assert_eq!(written, "\x1B]0;my title\x07");
2273    }
2274
2275    #[test]
2276    fn ring_bell_writes_the_bel_byte() {
2277        let (_lock, mut term) = headless_term();
2278
2279        term.ring_bell().unwrap();
2280
2281        assert_eq!(term.writer().as_slice(), b"\x07");
2282    }
2283
2284    #[test]
2285    fn set_cursor_style_queues_the_matching_decscusr_escape() {
2286        use retroglyph_core::backend::CursorStyle;
2287
2288        let (_lock, mut term) = headless_term();
2289
2290        term.set_cursor_style(CursorStyle::BlinkingBar);
2291
2292        assert_eq!(term.writer().as_slice(), b"\x1b[5 q");
2293    }
2294
2295    /// Wraps [`Crossterm`] so [`Observable::snapshot`] hashes only the bytes written since the
2296    /// previous call, per that trait's docs: this backend's observable state is an append-only
2297    /// escape-byte log, so "changed" means "appended", tracked here as a remembered offset into
2298    /// [`Crossterm::writer`] rather than by hashing the whole log every time.
2299    struct CrosstermObserver {
2300        term: Crossterm<Vec<u8>>,
2301        offset: usize,
2302    }
2303
2304    impl CrosstermObserver {
2305        fn new(size: Size) -> Self {
2306            let mut term = headless_options()
2307                .build_with_writer(Vec::new())
2308                .expect(
2309                    "building against a Vec<u8> writer with all TTY features disabled must not require a real terminal",
2310                );
2311            // `resize` (now that it actually updates `cached_size`, see retroglyph#763) seeds
2312            // the size the conformance harness asked for; its own escape bytes are folded into
2313            // the initial offset below rather than showing up in the first measured delta.
2314            term.resize(size);
2315            let offset = term.writer().len();
2316            Self { term, offset }
2317        }
2318    }
2319
2320    impl Output for CrosstermObserver {
2321        type Error = std::io::Error;
2322
2323        fn draw_layers<'a, I>(&mut self, content: I) -> Result<(), Self::Error>
2324        where
2325            I: Iterator<Item = DrawCell<'a>>,
2326        {
2327            self.term.draw(content)
2328        }
2329
2330        fn flush(&mut self) -> Result<(), Self::Error> {
2331            self.term.flush()
2332        }
2333
2334        fn size(&self) -> Size {
2335            Output::size(&self.term)
2336        }
2337
2338        fn clear(&mut self) -> Result<(), Self::Error> {
2339            Output::clear(&mut self.term)
2340        }
2341
2342        fn resize(&mut self, size: Size) {
2343            Output::resize(&mut self.term, size);
2344        }
2345    }
2346
2347    impl Cursor for CrosstermObserver {
2348        fn set_cursor_position(&mut self, position: Pos) {
2349            Cursor::set_cursor_position(&mut self.term, position);
2350        }
2351
2352        fn set_cursor_style(&mut self, style: CursorStyle) {
2353            Cursor::set_cursor_style(&mut self.term, style);
2354        }
2355    }
2356
2357    impl retroglyph_core::testing::conformance::Observable for CrosstermObserver {
2358        fn snapshot(&mut self) -> u64 {
2359            let bytes = self.term.writer();
2360            let delta = &bytes[self.offset..];
2361            let hash = retroglyph_core::testing::conformance::fnv1a(delta);
2362            self.offset = bytes.len();
2363            hash
2364        }
2365    }
2366
2367    #[test]
2368    fn satisfies_the_output_contract() {
2369        let _lock = TEST_GUARD_LOCK
2370            .lock()
2371            .unwrap_or_else(std::sync::PoisonError::into_inner);
2372        retroglyph_core::testing::conformance::assert_output_contract(CrosstermObserver::new);
2373    }
2374
2375    #[test]
2376    fn satisfies_the_cursor_contract() {
2377        let _lock = TEST_GUARD_LOCK
2378            .lock()
2379            .unwrap_or_else(std::sync::PoisonError::into_inner);
2380        retroglyph_core::testing::conformance::assert_cursor_contract(CrosstermObserver::new);
2381    }
2382
2383    #[test]
2384    fn satisfies_the_cursor_style_contract() {
2385        let _lock = TEST_GUARD_LOCK
2386            .lock()
2387            .unwrap_or_else(std::sync::PoisonError::into_inner);
2388        retroglyph_core::testing::conformance::assert_cursor_style_contract(CrosstermObserver::new);
2389    }
2390}