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