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