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