Skip to main content

retroglyph_terminal/
lib.rs

1//! ANSI/SGR cell-diff renderer shared by retroglyph's terminal-family backends.
2//!
3//! [`TerminalRenderer`] converts [`Tile`] content into standard ANSI/CSI escape sequences (cursor
4//! movement, `SetForegroundColor`/`SetBackgroundColor`/SGR attributes, synchronized update markers)
5//! and writes them to any [`std::io::Write`] sink. It has no opinion about where those bytes end up
6//! or how input arrives; two crates plug it into a concrete environment:
7//!
8//! ```text
9//!                     +-----------------------+
10//!                     |     TerminalRenderer   |
11//!                     |  (this crate: Tile ->  |
12//!                     |   ANSI/SGR escape      |
13//!                     |   sequences)           |
14//!                     +-----------------------+
15//!                        ^                 ^
16//!                        |                 |
17//!            std::io::Write          std::io::Write
18//!               (String buffer)         (stdout)
19//!                        |                 |
20//!  +-----------------------------+   +-----------------------------+
21//!  | retroglyph-terminal-wasm    |   | retroglyph-crossterm        |
22//!  | pushed JS key/resize events |   | raw mode, alternate screen, |
23//!  | -> String pulled by JS each |   | kitty keyboard protocol,    |
24//!  | frame (xterm.js renders it) |   | crossterm::event polling    |
25//!  +-----------------------------+   +-----------------------------+
26//! ```
27//!
28//! - [`retroglyph-crossterm`](https://docs.rs/retroglyph-crossterm) drives a real TTY: raw mode,
29//!   alternate screen, the kitty keyboard protocol, and `crossterm::event` polling. It writes this
30//!   renderer's output straight to `stdout`.
31//! - [`retroglyph-terminal-wasm`](https://docs.rs/retroglyph-terminal-wasm) drives a browser
32//!   terminal emulator (e.g. xterm.js) from WASM: no TTY, no polling, output collected into a
33//!   `String` for JS to pull each frame, input pushed in from JS callbacks.
34//!
35//! # Features
36//!
37//! <!-- gen-features:start -->
38//! This crate has no default features; every feature below is optional and off unless enabled.
39//!
40//! ### `dev`
41//!
42//! ⚪ Optional.
43//!
44//! Forwards `retroglyph-core`'s `dev` feature, which forces development diagnostics on in a build
45//! that would otherwise compile them out (see [`retroglyph_core::dev`]).
46//!
47//! ### `egc`
48//!
49//! ⚪ Optional.
50//!
51//! Forwards to `retroglyph-core`'s `egc` feature.
52//!
53//! This crate has EGC-aware and non-EGC-aware code paths gated on the same flag name.
54//! <!-- gen-features:end -->
55//!
56//! # Why not part of `retroglyph-window`
57//!
58//! `retroglyph-window` splits input (winit event loop) from output (`Presenter`) because every
59//! windowed backend shares one runtime driver: the winit event loop. That split lets renderer
60//! crates avoid depending on winit's frequent major-version bumps.
61//!
62//! Crossterm and the wasm/xterm.js driver share no such runtime: crossterm owns a blocking poll
63//! loop against a real TTY, and the wasm driver is pushed into by JS with no polling loop at all.
64//! What they do share is the ANSI/SGR cell-diff renderer, so that is what lives in this crate.
65//!
66//! # `no_std`
67//!
68//! This crate always requires `std` (an `impl std::io::Write` sink), unlike `retroglyph-core`,
69//! which supports `no_std`.
70//!
71//! # RGB color fallback on 256-color terminals
72//!
73//! By default ([`ColorSupport::Truecolor`]), [`Color::Rgb`] tiles are written out verbatim as a
74//! 24-bit truecolor SGR sequence (`38;2;r;g;b` / `48;2;r;g;b`, one of the codes this crate's
75//! internal SGR-color writer emits), with no quantization. This mirrors `crossterm`'s own
76//! `SetForegroundColor`/`SetBackgroundColor` behavior (and that of most Rust terminal-UI crates):
77//! truecolor codes are written unconditionally, and it is left to the terminal emulator (or a
78//! multiplexer like `tmux`/`screen` sitting in between) to interpret or degrade them. In practice:
79//!
80//! - Terminals that advertise truecolor support (`$COLORTERM=truecolor` or `24bit`) render the
81//!   exact color.
82//! - Many terminals and multiplexers that only support the 256-color palette (`$TERM=*-256color`)
83//!   approximate the requested RGB to the nearest palette entry themselves, since terminal
84//!   implementations commonly downsample unrecognized-depth SGR sequences rather than drop them.
85//! - A minority of older/limited terminals may render truecolor sequences incorrectly (wrong color,
86//!   or no color at all) if they don't recognize the extended `;2;` SGR form.
87//!
88//! Callers that know the receiving terminal is more limited (or that `$NO_COLOR` is set) can set
89//! [`TerminalRenderer::with_color_support`]/[`TerminalRenderer::set_color_support`] to
90//! [`ColorSupport::Indexed256`], [`ColorSupport::Ansi16`], or [`ColorSupport::None`] instead:
91//! [`draw`](TerminalRenderer::draw) then quantizes every [`Color::Rgb`] tile through
92//! [`Color::to_indexed`]/[`Color::to_ansi`] (or forces [`Color::Default`]) before writing its SGR
93//! sequence, so the emitted bytes match what was actually requested rather than relying on the
94//! terminal to downsample. This crate does not auto-detect terminal capabilities itself (that
95//! belongs to a backend that actually has access to `$TERM`/`$NO_COLOR`, e.g.
96//! `retroglyph-crossterm`'s `CrosstermOptions`); [`ColorSupport::Truecolor`] remains the default.
97//!
98//! Callers that need a specific, correct color regardless of `ColorSupport` should use
99//! [`Color::Indexed`] or [`Color::Ansi`] explicitly instead of [`Color::Rgb`]; both are passed
100//! through untranslated (`38;5;n` / plain ANSI codes) at every `ColorSupport` level except
101//! [`ColorSupport::None`] (which forces [`Color::Default`] regardless of the requested color) and
102//! have no ambiguity across terminal color depths.
103
104#![doc(
105    html_logo_url = "https://raw.githubusercontent.com/crates-lurey-io/retroglyph/main/docs/public/assets/logo.svg"
106)]
107#![doc(
108    html_favicon_url = "https://raw.githubusercontent.com/crates-lurey-io/retroglyph/main/docs/public/assets/logo.svg"
109)]
110#![cfg_attr(docsrs, feature(doc_cfg))]
111
112// Compile the code blocks in this crate's own README as doctests so its quick start is
113// type-checked on every test run and cannot silently rot. The `cfg(doctest)` gate keeps this out
114// of the rendered crate documentation: see `retroglyph-crossterm`'s matching include for the
115// same pattern applied to the workspace root README.
116#[cfg(doctest)]
117#[doc = include_str!("../README.md")]
118struct ReadmeDoctests;
119
120use retroglyph_core::backend::CursorStyle;
121use retroglyph_core::backend::DrawCell;
122use retroglyph_core::color::Color;
123use retroglyph_core::grid::Pos;
124use retroglyph_core::tile::Tile;
125use std::io::{self, Write};
126
127/// How aggressively [`TerminalRenderer`] quantizes [`Color`] before emitting an SGR sequence.
128///
129/// See the crate-level "RGB color fallback on 256-color terminals" doc section for the full
130/// contract, and [`TerminalRenderer::with_color_support`]/[`TerminalRenderer::set_color_support`]
131/// to configure it.
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
133#[non_exhaustive]
134pub enum ColorSupport {
135    /// No quantization: [`Color::Rgb`] tiles are written out verbatim as a 24-bit truecolor SGR
136    /// sequence. The default, matching this renderer's historical behavior.
137    #[default]
138    Truecolor,
139    /// [`Color::Rgb`] tiles are quantized to the nearest of the 256 indexed-color palette entries
140    /// via [`Color::to_indexed`] before being written.
141    Indexed256,
142    /// [`Color::Rgb`] tiles are quantized to the nearest of the 16 standard ANSI colors via
143    /// [`Color::to_ansi`] before being written.
144    Ansi16,
145    /// Every [`Color`] is forced to [`Color::Default`]: no SGR color codes are emitted at all.
146    None,
147}
148
149impl ColorSupport {
150    /// Applies this degradation level to `color`, returning the [`Color`] that should actually be
151    /// written as an SGR sequence.
152    fn apply(self, color: Color) -> Color {
153        match self {
154            Self::Truecolor => color,
155            Self::Indexed256 => color.to_indexed(),
156            Self::Ansi16 => color.to_ansi(),
157            // `ColorSupport` is `#[non_exhaustive]`: an unrecognized future level degrades to
158            // the most conservative, always-safe choice (no color at all) rather than failing to
159            // compile or accidentally passing a color through unquantized.
160            _ => Color::Default,
161        }
162    }
163}
164
165/// Writes a [`Color`]'s SGR parameter list (no `\x1b[`/`m` wrapper) for `SetForegroundColor`
166/// (`38;...`) or `SetBackgroundColor` (`48;...`) to `out`.
167///
168/// `base` is `38` for foreground, `48` for background (the standard SGR prefix codes); `reset`
169/// is `39`/`49`, used for [`Color::Default`]. This is the shared parameter-building block behind
170/// both [`write_sgr_color`] (a single complete escape sequence) and the combined-fg/bg path in
171/// [`TerminalRenderer::draw`], which concatenates two calls' output with `;` into one sequence.
172fn write_sgr_params<W: Write>(out: &mut W, color: Color, base: u8, reset: u8) -> io::Result<()> {
173    match color {
174        Color::Ansi(ansi) => {
175            // Standard/bright ANSI codes are offsets from the SGR base:
176            // foreground 30-37/90-97, background 40-47/100-107. `base` here
177            // is 38/48 (the "extended color" introducer), so the plain ANSI
178            // path uses its own literal base instead.
179            let (plain_base, bright_base) = if base == 38 { (30, 90) } else { (40, 100) };
180            let index = ansi.to_index();
181            let code = if index < 8 {
182                plain_base + index
183            } else {
184                bright_base + (index - 8)
185            };
186            write!(out, "{code}")
187        }
188        Color::Indexed(index) => write!(out, "{base};5;{index}"),
189        // No quantization: passed straight through as a 24-bit truecolor SGR
190        // sequence. See the crate-level "RGB color fallback on 256-color
191        // terminals" doc section for the contract this leaves callers with.
192        Color::Rgb { r, g, b } => write!(out, "{base};2;{r};{g};{b}"),
193        // Covers `Color::Default` plus any future variant (`Color` is `#[non_exhaustive]`) this
194        // crate doesn't know how to resolve yet; both fall back to the reset code.
195        _ => write!(out, "{reset}"),
196    }
197}
198
199/// Converts a [`Color`] to a standard ANSI/CSI `SetForegroundColor` (`38;...`) or
200/// `SetBackgroundColor` (`48;...`) escape sequence, written to `out`.
201///
202/// `base` is `38` for foreground, `48` for background (the standard SGR prefix codes); `reset` is
203/// `39`/`49`, used for [`Color::Default`].
204fn write_sgr_color<W: Write>(out: &mut W, color: Color, base: u8, reset: u8) -> io::Result<()> {
205    write!(out, "\x1b[")?;
206    write_sgr_params(out, color, base, reset)?;
207    write!(out, "m")
208}
209
210/// A generic ANSI/SGR cell-diff renderer.
211///
212/// Converts [`Tile`] content into standard ANSI/CSI escape sequences and writes them to a
213/// caller-supplied [`std::io::Write`] sink `W`. Tracks cursor position and the last-emitted
214/// foreground/background/attribute state across calls to [`draw`](Self::draw) so it only emits
215/// the escape codes needed to move to changed cells and change state.
216///
217/// This type has no knowledge of *how* its output bytes reach a display (stdout, a `String`
218/// buffer for JS, a test harness) or *how* input arrives: it is a pure `Tile` stream -> ANSI
219/// bytes transform, reused by every terminal-family
220/// [`Backend`](retroglyph_core::backend::Backend) implementor.
221///
222/// # Examples
223///
224/// Driving the renderer over a `Vec<u8>` sink and asserting on the emitted ANSI bytes: no real
225/// terminal is needed, since `W` here is just an in-memory buffer.
226///
227/// ```
228/// use retroglyph_core::backend::DrawCell;
229/// use retroglyph_core::color::{AnsiColor, Color};
230/// use retroglyph_core::grid::Pos;
231/// use retroglyph_core::color::Style;
232/// use retroglyph_core::tile::Tile;
233/// use retroglyph_terminal::TerminalRenderer;
234///
235/// let mut renderer = TerminalRenderer::new(Vec::new());
236/// let tile = Tile::new('X', Style::new().fg(Color::Ansi(AnsiColor::Red)));
237/// renderer.draw(core::iter::once(DrawCell::new(Pos { x: 0, y: 0 }, &tile)))?;
238/// renderer.flush()?;
239///
240/// let out = String::from_utf8(renderer.into_writer()).expect("renderer only writes ASCII/UTF-8");
241/// // `\x1b[1;1H` moves the cursor to row 1, col 1 (1-indexed); `\x1b[31;49m` sets red
242/// // foreground with the default background.
243/// assert_eq!(out, "\x1b[1;1H\x1b[31;49mX");
244/// # Ok::<(), std::io::Error>(())
245/// ```
246#[derive(Debug)]
247pub struct TerminalRenderer<W> {
248    writer: W,
249    /// Reusable scratch buffer that a whole [`draw`](Self::draw) call is rendered into, so the
250    /// call ends with exactly one [`Write::write_all`] against `writer` instead of up to four
251    /// small `write!` calls per cell. Cleared (not reallocated) after each flush to `writer`, so
252    /// its capacity grows to fit the largest frame drawn and is then reused every call. See
253    /// retroglyph#271.
254    buf: Vec<u8>,
255    last_fg: Option<Color>,
256    last_bg: Option<Color>,
257    cursor: Option<Pos>,
258    plain: bool,
259    color_support: ColorSupport,
260}
261
262impl<W: Write> TerminalRenderer<W> {
263    /// Creates a new renderer writing to `writer`.
264    pub const fn new(writer: W) -> Self {
265        Self {
266            writer,
267            buf: Vec::new(),
268            last_fg: None,
269            last_bg: None,
270            cursor: None,
271            plain: false,
272            color_support: ColorSupport::Truecolor,
273        }
274    }
275
276    /// Creates a new renderer writing to `writer`, with plain-mode set explicitly.
277    ///
278    /// See [`set_plain_mode`](Self::set_plain_mode) for what plain mode changes; prefer
279    /// [`TerminalRenderer::auto`] when `W` implements [`std::io::IsTerminal`] and the mode should
280    /// be picked automatically instead of hardcoded.
281    pub const fn with_plain_mode(writer: W, plain: bool) -> Self {
282        Self {
283            writer,
284            buf: Vec::new(),
285            last_fg: None,
286            last_bg: None,
287            cursor: None,
288            plain,
289            color_support: ColorSupport::Truecolor,
290        }
291    }
292
293    /// Returns whether plain mode is enabled. See [`set_plain_mode`](Self::set_plain_mode).
294    pub const fn plain_mode(&self) -> bool {
295        self.plain
296    }
297
298    /// Returns the configured [`ColorSupport`] level. See
299    /// [`set_color_support`](Self::set_color_support).
300    pub const fn color_support(&self) -> ColorSupport {
301        self.color_support
302    }
303
304    /// Sets the [`ColorSupport`] level. See the crate-level "RGB color fallback on 256-color
305    /// terminals" doc section for the full contract.
306    pub const fn set_color_support(&mut self, color_support: ColorSupport) {
307        self.color_support = color_support;
308    }
309
310    /// This renderer with `color_support` set. See [`set_color_support`](Self::set_color_support).
311    #[must_use]
312    pub const fn with_color_support(mut self, color_support: ColorSupport) -> Self {
313        self.color_support = color_support;
314        self
315    }
316
317    /// Enables or disables plain mode.
318    ///
319    /// In plain mode, [`draw`](Self::draw) and the synchronized-update markers stop emitting
320    /// ANSI/CSI escape sequences (cursor moves, color/SGR codes, `\x1b[?2026h`/`l`) entirely.
321    /// Cell text is written as plain text instead, with row changes turned into `\n` and gaps
322    /// between non-adjacent cells on the same row padded with spaces, so a full-grid
323    /// [`draw`](Self::draw) call degrades to a readable ASCII rendering of that frame.
324    ///
325    /// This is modeled on Python's `blessed`, which does the same thing when its output stream
326    /// isn't a TTY: piping or redirecting output (`myapp > log.txt`) shouldn't leave a file full
327    /// of unreadable escape codes. Because this renderer only ever draws *changed* cells, repeated
328    /// [`draw`](Self::draw) calls in plain mode append each frame's diff as more plain text rather
329    /// than overwriting previous output in place: there is no cursor-addressable terminal to
330    /// overwrite when the sink is a file or pipe, so this is a lossy degradation intended for
331    /// logging/debugging, not for reproducing the exact interactive frame sequence.
332    pub const fn set_plain_mode(&mut self, plain: bool) {
333        self.plain = plain;
334    }
335
336    /// Returns a reference to the underlying writer.
337    pub const fn writer(&self) -> &W {
338        &self.writer
339    }
340
341    /// Returns a mutable reference to the underlying writer.
342    pub const fn writer_mut(&mut self) -> &mut W {
343        &mut self.writer
344    }
345
346    /// Consumes the renderer, returning the underlying writer.
347    pub fn into_writer(self) -> W {
348        self.writer
349    }
350
351    /// Resets tracked cursor/style state without touching the writer.
352    ///
353    /// Call this after an external clear (e.g. `\x1b[2J`) so the next [`draw`](Self::draw)
354    /// doesn't skip a `MoveTo`/color/attribute escape under the assumption the terminal is still
355    /// in the last-known state.
356    pub const fn reset_state(&mut self) {
357        self.last_fg = None;
358        self.last_bg = None;
359        self.cursor = None;
360    }
361
362    /// Resets only tracked cursor position, leaving tracked color/attribute state alone.
363    ///
364    /// Call this after a write that moves the real cursor without touching color (e.g.
365    /// [`move_cursor_to`](Self::move_cursor_to)): the next [`draw`](Self::draw) must still emit a
366    /// `MoveTo` for a changed cell that happens to match the now-stale tracked coordinates, but
367    /// its color/attribute escapes stay conditional on an actual style change, since the pen
368    /// itself never moved.
369    const fn reset_cursor_tracking(&mut self) {
370        self.cursor = None;
371    }
372
373    /// Begins a synchronized update (`\x1b[?2026h`).
374    ///
375    /// Terminals that support this hold rendering until the matching
376    /// [`end_synchronized_update`](Self::end_synchronized_update), avoiding visible tearing
377    /// mid-frame. Terminals that don't understand the sequence ignore it.
378    ///
379    /// A no-op in [plain mode](Self::set_plain_mode): synchronized updates are themselves a
380    /// control code with nothing to synchronize once cell output has already degraded to plain
381    /// text.
382    ///
383    /// # Errors
384    ///
385    /// Returns an error if the writer fails.
386    pub fn begin_synchronized_update(&mut self) -> io::Result<()> {
387        if self.plain {
388            return Ok(());
389        }
390        write!(self.writer, "\x1b[?2026h")
391    }
392
393    /// Ends a synchronized update (`\x1b[?2026l`). See
394    /// [`begin_synchronized_update`](Self::begin_synchronized_update).
395    ///
396    /// A no-op in [plain mode](Self::set_plain_mode); see
397    /// [`begin_synchronized_update`](Self::begin_synchronized_update).
398    ///
399    /// # Errors
400    ///
401    /// Returns an error if the writer fails.
402    pub fn end_synchronized_update(&mut self) -> io::Result<()> {
403        if self.plain {
404            return Ok(());
405        }
406        write!(self.writer, "\x1b[?2026l")
407    }
408
409    /// Draws changed cells, emitting only the escape sequences needed to move the cursor and
410    /// change color/attribute state versus what was last drawn.
411    ///
412    /// Mirrors [`Output::draw`](retroglyph_core::backend::Output::draw)'s contract: `content` is
413    /// a stream of `(Pos, &Tile, Option<&str>)` items to render, the last being the tile's full
414    /// grapheme text when it has one. As with `Output::draw`, that trailing `Option<&str>` is
415    /// only ever `Some` when this crate's `egc` feature is enabled; without `egc` it is always
416    /// `None`. Does not flush; call [`flush`](Self::flush) after.
417    ///
418    /// # Errors
419    ///
420    /// Returns an error if the writer fails.
421    pub fn draw<'a, I>(&mut self, content: I) -> io::Result<()>
422    where
423        I: Iterator<Item = DrawCell<'a>>,
424    {
425        // Dispatch once here to one of two disjoint code paths, rather than re-checking `plain`
426        // per cell. Both paths render into `self.buf` first and this writes it out with a single
427        // `Write::write_all`, rather than up to four small `write!` calls per cell (see
428        // retroglyph#271); this also means `writer` no longer needs to be pre-wrapped in a
429        // `BufWriter` for reasonable syscall behavior, though nothing stops a caller from still
430        // doing so.
431        if self.plain {
432            self.draw_plain(content)?;
433        } else {
434            self.draw_escape(content)?;
435        }
436        self.writer.write_all(&self.buf)?;
437        self.buf.clear();
438        Ok(())
439    }
440
441    /// Escape-mode half of [`draw`](Self::draw): full ANSI/CSI cursor-move and SGR color/attribute
442    /// diffing against tracked state, renders into `self.buf`. See [`draw`](Self::draw) for the
443    /// shared contract and [`draw_plain`](Self::draw_plain) for the plain-mode counterpart.
444    #[allow(clippy::similar_names)]
445    fn draw_escape<'a, I>(&mut self, content: I) -> io::Result<()>
446    where
447        I: Iterator<Item = DrawCell<'a>>,
448    {
449        for draw_cell in content {
450            let (pos, cell, extra) = (draw_cell.pos, draw_cell.tile, draw_cell.grapheme);
451            #[cfg(not(feature = "egc"))]
452            let _ = extra;
453
454            // Spacer cells are the right half of a wide character. The wide
455            // char itself already drew over this position, so skip it. `Grid::put_tile` sets
456            // this flag on every feature combination (not just `egc`; see its own doc comment),
457            // so this check isn't `egc`-gated either.
458            if cell
459                .flags()
460                .contains(retroglyph_core::tile::TileFlags::WIDE_CHAR_SPACER)
461            {
462                continue;
463            }
464
465            // Applied here (last-known-state comparisons and the SGR sequences below both use
466            // the degraded color), not on `Tile::style` itself: `ColorSupport` degrades what is
467            // *emitted*, not the tile's own requested color.
468            let fg = self.color_support.apply(cell.style().foreground());
469            let bg = self.color_support.apply(cell.style().background());
470
471            // Only emit a cursor move when the cursor isn't already at the
472            // right position (adjacent cells advance the cursor by printing).
473            let needs_move = self.cursor != Some(pos);
474            if needs_move {
475                // CSI row;col H is 1-indexed; saturate rather than wrap at u16::MAX.
476                write!(
477                    self.buf,
478                    "\x1b[{};{}H",
479                    pos.y.saturating_add(1),
480                    pos.x.saturating_add(1)
481                )?;
482            }
483
484            let fg_changed = self.last_fg != Some(fg);
485            let bg_changed = self.last_bg != Some(bg);
486
487            // When both channels change in the same cell transition, combine them into a
488            // single SGR sequence (`\x1b[38;...;48;...m`) instead of two: same visual
489            // effect, half the CSI-introducer/terminator overhead. Only one of the two
490            // actually changing still gets a single-channel sequence, so an unchanged
491            // channel is never re-emitted.
492            if fg_changed && bg_changed {
493                write!(self.buf, "\x1b[")?;
494                write_sgr_params(&mut self.buf, fg, 38, 39)?;
495                write!(self.buf, ";")?;
496                write_sgr_params(&mut self.buf, bg, 48, 49)?;
497                write!(self.buf, "m")?;
498                self.last_fg = Some(fg);
499                self.last_bg = Some(bg);
500            } else if fg_changed {
501                write_sgr_color(&mut self.buf, fg, 38, 39)?;
502                self.last_fg = Some(fg);
503            } else if bg_changed {
504                write_sgr_color(&mut self.buf, bg, 48, 49)?;
505                self.last_bg = Some(bg);
506            }
507
508            let cell_width = Self::write_glyph(&mut self.buf, cell, extra)?;
509
510            // After printing, the terminal cursor advances by the cell's
511            // display width. Track that so the next cell can skip the move.
512            self.cursor = Some(Pos {
513                x: pos.x.saturating_add(cell_width),
514                y: pos.y,
515            });
516        }
517        Ok(())
518    }
519
520    /// Plain-mode half of [`draw`](Self::draw): no escape sequences, cell text degraded to
521    /// readable ASCII (row changes become `\n`, gaps become spaces), renders into `self.buf`.
522    /// See [`draw`](Self::draw) for the shared contract, [`set_plain_mode`](Self::set_plain_mode)
523    /// for the full plain-mode contract, and [`draw_escape`](Self::draw_escape) for the
524    /// escape-mode counterpart.
525    fn draw_plain<'a, I>(&mut self, content: I) -> io::Result<()>
526    where
527        I: Iterator<Item = DrawCell<'a>>,
528    {
529        for draw_cell in content {
530            let (pos, cell, extra) = (draw_cell.pos, draw_cell.tile, draw_cell.grapheme);
531            #[cfg(not(feature = "egc"))]
532            let _ = extra;
533
534            // See `draw_escape`'s identical check above: `WIDE_CHAR_SPACER` is set regardless of
535            // `egc`, so skipping it here isn't `egc`-gated either.
536            if cell
537                .flags()
538                .contains(retroglyph_core::tile::TileFlags::WIDE_CHAR_SPACER)
539            {
540                continue;
541            }
542
543            // Row change: newline(s) instead of a cursor-move escape. Advancing rows emits one
544            // `\n` per skipped row so blank rows still show up as blank lines. A backward row, or
545            // (the `pos.x < cursor.x` guard below) a same-row cell arriving out of ascending-x
546            // order, both just start a fresh line: plain mode has no cursor-addressing escape
547            // codes to seek backward with, so there is no way to overwrite/insert at an
548            // already-passed column without corrupting what was already written. Starting a new
549            // line is the least-surprising degradation available: it reproduces the
550            // already-established backward-row behavior instead of silently misplacing the cell
551            // right after the previous one at the wrong column (see retroglyph#273).
552            let start_col = match self.cursor {
553                Some(c) if c.y == pos.y && pos.x >= c.x => c.x,
554                Some(c) if pos.y > c.y => {
555                    for _ in 0..(pos.y - c.y) {
556                        writeln!(self.buf)?;
557                    }
558                    0
559                }
560                Some(_) => {
561                    writeln!(self.buf)?;
562                    0
563                }
564                None => 0,
565            };
566            // Pad gaps between non-adjacent cells on the same row with spaces so columns
567            // still line up; a fresh row starts padding from column 0.
568            for _ in start_col..pos.x {
569                write!(self.buf, " ")?;
570            }
571
572            let cell_width = Self::write_glyph(&mut self.buf, cell, extra)?;
573
574            self.cursor = Some(Pos {
575                x: pos.x.saturating_add(cell_width),
576                y: pos.y,
577            });
578        }
579        Ok(())
580    }
581
582    /// Writes `cell`'s printable text (the full grapheme from `extra` when the `egc` feature
583    /// provides one, otherwise just the primary glyph) to `out`, returning its precomputed
584    /// display width ([`Tile::width`]) so the caller can advance the tracked cursor position.
585    /// Shared by [`draw_escape`](Self::draw_escape) and [`draw_plain`](Self::draw_plain).
586    fn write_glyph(out: &mut Vec<u8>, cell: &Tile, extra: Option<&str>) -> io::Result<u16> {
587        #[cfg(not(feature = "egc"))]
588        let _ = extra;
589        #[cfg(feature = "egc")]
590        {
591            // Print the full EGC if present; otherwise the primary glyph.
592            let mut glyph_buf = [0u8; 4];
593            let s: &str = match extra {
594                Some(extra) => extra,
595                None => cell.glyph().encode_utf8(&mut glyph_buf),
596            };
597            write!(out, "{s}")?;
598        }
599        #[cfg(not(feature = "egc"))]
600        {
601            write!(out, "{}", cell.glyph())?;
602        }
603        Ok(cell.width())
604    }
605
606    /// Flushes the underlying writer.
607    ///
608    /// # Errors
609    ///
610    /// Returns an error if the writer fails to flush.
611    pub fn flush(&mut self) -> io::Result<()> {
612        self.writer.flush()
613    }
614
615    /// Begins a synchronized update and draws `content`, without flushing.
616    ///
617    /// Combines [`begin_synchronized_update`](Self::begin_synchronized_update) and
618    /// [`draw`](Self::draw) into the single call every `Output::draw_layers` implementor in this
619    /// workspace needs: call [`end_frame`](Self::end_frame) afterward to close the synchronized
620    /// update and flush.
621    ///
622    /// # Errors
623    ///
624    /// Returns an error if the writer fails.
625    pub fn draw_frame<'a, I>(&mut self, content: I) -> io::Result<()>
626    where
627        I: Iterator<Item = DrawCell<'a>>,
628    {
629        self.begin_synchronized_update()?;
630        self.draw(content)
631    }
632
633    /// Ends a synchronized update and flushes the underlying writer.
634    ///
635    /// Combines [`end_synchronized_update`](Self::end_synchronized_update) and
636    /// [`flush`](Self::flush) into the single call every `Output::flush` implementor in this
637    /// workspace needs; pairs with [`draw_frame`](Self::draw_frame).
638    ///
639    /// # Errors
640    ///
641    /// Returns an error if the writer fails.
642    pub fn end_frame(&mut self) -> io::Result<()> {
643        self.end_synchronized_update()?;
644        self.flush()
645    }
646
647    /// Erases the whole screen and resets tracked state.
648    ///
649    /// Emits a full SGR reset (`\x1b[0m`) before the erase (`\x1b[2J`): most terminals implement
650    /// erase-display via background color erase (BCE), painting erased cells with whatever
651    /// background is currently active in the pen rather than the terminal's true default, so a
652    /// colored cell drawn just before this call would otherwise leave a stale tint across the
653    /// whole screen. Also homes the cursor (`\x1b[H`) and, after writing, flushes the underlying
654    /// writer and calls [`reset_state`](Self::reset_state): the terminal-side state (cursor
655    /// position, last color/attrs) is now stale versus what's actually on screen, so the next
656    /// [`draw`](Self::draw) must re-emit full escape sequences instead of skipping them under the
657    /// assumption the terminal is still in the last-known state.
658    ///
659    /// # Errors
660    ///
661    /// Returns an error if the writer fails to write or flush.
662    pub fn clear_screen(&mut self) -> io::Result<()> {
663        write!(self.writer, "\x1b[0m\x1b[2J\x1b[H")?;
664        self.writer.flush()?;
665        self.reset_state();
666        Ok(())
667    }
668
669    /// Moves the cursor to `position` (CUP, `CSI row;col H`, 1-indexed), without flushing.
670    ///
671    /// The real cursor is now wherever `position` says, not wherever the last drawn glyph left
672    /// it, so this also resets tracked cursor position: otherwise the next [`draw`](Self::draw)
673    /// could skip a move for a changed cell that happens to match the now-stale tracked
674    /// coordinates. Color/attribute tracking is untouched: a bare cursor move doesn't change
675    /// what's in the pen, so an unchanged style still skips its escape on the next draw.
676    ///
677    /// # Errors
678    ///
679    /// Returns an error if the writer fails.
680    pub fn move_cursor_to(&mut self, position: Pos) -> io::Result<()> {
681        write!(
682            self.writer,
683            "\x1b[{};{}H",
684            position.y.saturating_add(1),
685            position.x.saturating_add(1)
686        )?;
687        self.reset_cursor_tracking();
688        Ok(())
689    }
690
691    /// Shows or hides the cursor (DECTCEM, `CSI ?25 h`/`CSI ?25 l`), without flushing.
692    ///
693    /// # Errors
694    ///
695    /// Returns an error if the writer fails.
696    pub fn set_cursor_visible(&mut self, visible: bool) -> io::Result<()> {
697        if visible {
698            write!(self.writer, "\x1b[?25h")
699        } else {
700            write!(self.writer, "\x1b[?25l")
701        }
702    }
703
704    /// Sets the cursor's shape (DECSCUSR, `CSI Ps SP q`), without flushing.
705    ///
706    /// # Errors
707    ///
708    /// Returns an error if the writer fails.
709    pub fn set_cursor_style(&mut self, style: CursorStyle) -> io::Result<()> {
710        // `CursorStyle` is `#[non_exhaustive]`: a future shape added upstream falls back to the
711        // terminal's own default (`Ps` 0) rather than failing to compile here.
712        let ps = match style {
713            CursorStyle::BlinkingBlock => 1,
714            CursorStyle::SteadyBlock => 2,
715            CursorStyle::BlinkingUnderline => 3,
716            CursorStyle::SteadyUnderline => 4,
717            CursorStyle::BlinkingBar => 5,
718            CursorStyle::SteadyBar => 6,
719            _ => 0,
720        };
721        write!(self.writer, "\x1b[{ps} q")
722    }
723}
724
725impl<W: Write + io::IsTerminal> TerminalRenderer<W> {
726    /// Creates a new renderer writing to `writer`, auto-detecting plain mode from whether
727    /// `writer` is a TTY.
728    ///
729    /// Equivalent to `TerminalRenderer::with_plain_mode(writer, !writer.is_terminal())`. `W`
730    /// must implement [`std::io::IsTerminal`] for this to be callable: `std::io::Stdout`,
731    /// `std::io::Stdin`, `std::io::Stderr`, `std::fs::File`, and their `*Lock` variants all do;
732    /// an in-memory sink like `Vec<u8>` does not, so use
733    /// [`with_plain_mode`](Self::with_plain_mode) directly for those.
734    ///
735    /// This mirrors how `retroglyph-crossterm` would typically wire up pipe-safe output: check
736    /// once at startup whether the real destination is an interactive terminal, and fall back to
737    /// plain text for everything else (files, pipes, `> log.txt` redirection, CI runners).
738    pub fn auto(writer: W) -> Self {
739        let plain = !writer.is_terminal();
740        Self::with_plain_mode(writer, plain)
741    }
742}
743
744#[cfg(test)]
745mod tests {
746    use super::*;
747    use retroglyph_core::color::AnsiColor;
748    use retroglyph_core::color::Style;
749    use retroglyph_core::grid::Pos;
750    use retroglyph_core::tile::Tile;
751
752    fn render_one(tile: &Tile) -> String {
753        render_one_at(Pos { x: 0, y: 0 }, tile)
754    }
755
756    fn render_one_at(pos: Pos, tile: &Tile) -> String {
757        let mut renderer = TerminalRenderer::new(Vec::new());
758        renderer
759            .draw(core::iter::once(DrawCell::new(pos, tile)))
760            .unwrap();
761        renderer.flush().unwrap();
762        String::from_utf8(renderer.into_writer()).unwrap()
763    }
764
765    #[test]
766    fn moves_cursor_with_1_indexed_csi() {
767        let tile = Tile::new('X', Style::default());
768        let out = render_one(&tile);
769        assert!(out.contains("\x1b[1;1H"), "output: {out:?}");
770        assert!(out.contains('X'));
771    }
772
773    #[test]
774    fn does_not_overflow_at_the_maximum_position() {
775        // retroglyph#729: the 1-based CUP computation (`pos.x + 1`/`pos.y + 1`) and the
776        // `cursor_x` advance (`pos.x + cell_width`) used plain `u16` arithmetic that overflowed at
777        // `u16::MAX`.
778        let tile = Tile::new('X', Style::default());
779        let out = render_one_at(
780            Pos {
781                x: u16::MAX,
782                y: u16::MAX,
783            },
784            &tile,
785        );
786        assert!(out.contains('X'));
787    }
788
789    #[test]
790    fn default_color_emits_reset_codes() {
791        let tile = Tile::new('X', Style::default());
792        let out = render_one(&tile);
793        // Both fg and bg are unset on the very first draw, so they're combined into a single
794        // SGR sequence rather than two separate `\x1b[39m\x1b[49m` escapes.
795        assert!(out.contains("\x1b[39;49m"), "output: {out:?}");
796    }
797
798    #[test]
799    fn ansi_color_maps_to_standard_sgr_range() {
800        let style = Style::new().fg(Color::Ansi(AnsiColor::Red));
801        let tile = Tile::new('X', style);
802        let out = render_one(&tile);
803        // Red is index 1 -> plain base 30 + 1 = 31. Background is still default (49), and both
804        // channels changed on this first draw, so they're combined into one sequence.
805        assert!(out.contains("\x1b[31;49m"), "output: {out:?}");
806    }
807
808    #[test]
809    fn bright_ansi_color_maps_to_bright_sgr_range() {
810        let style = Style::new().fg(Color::Ansi(AnsiColor::BrightRed));
811        let tile = Tile::new('X', style);
812        let out = render_one(&tile);
813        // BrightRed is index 9 -> bright base 90 + (9-8) = 91, combined with the default bg.
814        assert!(out.contains("\x1b[91;49m"), "output: {out:?}");
815    }
816
817    #[test]
818    fn rgb_color_uses_extended_sgr() {
819        let style = Style::new().fg(Color::rgb(1, 2, 3));
820        let tile = Tile::new('X', style);
821        let out = render_one(&tile);
822        assert!(out.contains("\x1b[38;2;1;2;3;49m"), "output: {out:?}");
823    }
824
825    #[test]
826    fn indexed_color_uses_extended_sgr() {
827        let style = Style::new().fg(Color::Indexed(200));
828        let tile = Tile::new('X', style);
829        let out = render_one(&tile);
830        assert!(out.contains("\x1b[38;5;200;49m"), "output: {out:?}");
831    }
832
833    #[test]
834    fn rgb_color_is_passed_through_without_quantization() {
835        // Regression test for the documented RGB fallback contract: an RGB
836        // value that doesn't land on any of the 256-color palette's exact
837        // entries (e.g. a 6x6x6 cube step or a grayscale ramp step) is still
838        // emitted verbatim as a 24-bit truecolor sequence, not snapped to the
839        // nearest indexed color.
840        let style = Style::new().fg(Color::rgb(91, 142, 217));
841        let tile = Tile::new('X', style);
842        let out = render_one(&tile);
843        assert!(out.contains("\x1b[38;2;91;142;217;49m"), "output: {out:?}");
844        assert!(
845            !out.contains("38;5;"),
846            "expected no indexed fallback, got: {out:?}"
847        );
848    }
849
850    fn render_one_with_color_support(tile: &Tile, color_support: ColorSupport) -> String {
851        let mut renderer = TerminalRenderer::new(Vec::new()).with_color_support(color_support);
852        renderer
853            .draw(core::iter::once(DrawCell::new(Pos { x: 0, y: 0 }, tile)))
854            .unwrap();
855        renderer.flush().unwrap();
856        String::from_utf8(renderer.into_writer()).unwrap()
857    }
858
859    #[test]
860    fn color_support_defaults_to_truecolor() {
861        let renderer = TerminalRenderer::new(Vec::new());
862        assert_eq!(renderer.color_support(), ColorSupport::Truecolor);
863    }
864
865    #[test]
866    fn with_color_support_sets_the_configured_level() {
867        let renderer = TerminalRenderer::new(Vec::new()).with_color_support(ColorSupport::Ansi16);
868        assert_eq!(renderer.color_support(), ColorSupport::Ansi16);
869    }
870
871    #[test]
872    fn color_support_truecolor_passes_rgb_through_unquantized() {
873        let style = Style::new().fg(Color::rgb(91, 142, 217));
874        let tile = Tile::new('X', style);
875        let out = render_one_with_color_support(&tile, ColorSupport::Truecolor);
876        assert!(out.contains("\x1b[38;2;91;142;217;49m"), "output: {out:?}");
877    }
878
879    #[test]
880    fn color_support_indexed256_quantizes_rgb_to_the_256_color_palette() {
881        let style = Style::new().fg(Color::rgb(1, 2, 3));
882        let tile = Tile::new('X', style);
883        let out = render_one_with_color_support(&tile, ColorSupport::Indexed256);
884        assert!(
885            out.contains("38;5;"),
886            "expected an indexed SGR sequence, got: {out:?}"
887        );
888        assert!(
889            !out.contains("38;2;"),
890            "expected no truecolor sequence, got: {out:?}"
891        );
892    }
893
894    #[test]
895    fn color_support_ansi16_quantizes_rgb_to_the_standard_ansi_range() {
896        let style = Style::new().fg(Color::rgb(255, 0, 0));
897        let tile = Tile::new('X', style);
898        let out = render_one_with_color_support(&tile, ColorSupport::Ansi16);
899        assert!(
900            !out.contains("38;2;") && !out.contains("38;5;"),
901            "expected a plain ANSI SGR code, got: {out:?}"
902        );
903        // Pure red quantizes to bright red (index 9 -> bright base 90 + 1 = 91).
904        assert!(out.contains("\x1b[91;49m"), "output: {out:?}");
905    }
906
907    #[test]
908    fn color_support_none_forces_every_color_to_default() {
909        let style = Style::new()
910            .fg(Color::rgb(91, 142, 217))
911            .bg(Color::Ansi(AnsiColor::Red));
912        let tile = Tile::new('X', style);
913        let out = render_one_with_color_support(&tile, ColorSupport::None);
914        assert!(out.contains("\x1b[39;49m"), "output: {out:?}");
915        assert!(
916            !out.contains("38;") && !out.contains("48;") && !out.contains("31;"),
917            "expected no color codes at all, got: {out:?}"
918        );
919    }
920
921    #[test]
922    fn combines_fg_and_bg_into_single_sequence_when_both_change() {
923        // Both channels change relative to the previous cell's state, so they should be
924        // coalesced into one `\x1b[38;...;48;...m` sequence instead of two separate escapes.
925        let old = Tile::new(
926            'A',
927            Style::new().fg(Color::rgb(1, 2, 3)).bg(Color::rgb(4, 5, 6)),
928        );
929        let new = Tile::new(
930            'B',
931            Style::new()
932                .fg(Color::rgb(10, 20, 30))
933                .bg(Color::rgb(40, 50, 60)),
934        );
935        let mut renderer = TerminalRenderer::new(Vec::new());
936        renderer
937            .draw(core::iter::once(DrawCell::new(Pos { x: 0, y: 0 }, &old)))
938            .unwrap();
939        renderer
940            .draw(core::iter::once(DrawCell::new(Pos { x: 0, y: 0 }, &new)))
941            .unwrap();
942        renderer.flush().unwrap();
943        let out = String::from_utf8(renderer.into_writer()).unwrap();
944        assert!(
945            out.contains("\x1b[38;2;10;20;30;48;2;40;50;60m"),
946            "output: {out:?}"
947        );
948        assert!(
949            !out.contains("\x1b[48;2;40;50;60m"),
950            "bg should not be emitted as a separate sequence, got: {out:?}"
951        );
952    }
953
954    #[test]
955    fn only_fg_change_emits_single_channel_sequence() {
956        // Background is unchanged between the two draws, so only the fg escape should be
957        // emitted: no combined sequence, and no redundant bg re-emission.
958        let bg = Color::rgb(4, 5, 6);
959        let old = Tile::new('A', Style::new().fg(Color::rgb(1, 2, 3)).bg(bg));
960        let new = Tile::new('B', Style::new().fg(Color::rgb(10, 20, 30)).bg(bg));
961        let mut renderer = TerminalRenderer::new(Vec::new());
962        renderer
963            .draw(core::iter::once(DrawCell::new(Pos { x: 0, y: 0 }, &old)))
964            .unwrap();
965        renderer
966            .draw(core::iter::once(DrawCell::new(Pos { x: 0, y: 0 }, &new)))
967            .unwrap();
968        renderer.flush().unwrap();
969        let out = String::from_utf8(renderer.into_writer()).unwrap();
970        // Only the second draw's fg escape should appear after the first draw's combined one;
971        // check the second draw doesn't re-emit a bg sequence.
972        let second_draw_start = out.rfind("\x1b[1;1H").unwrap();
973        let second_draw = &out[second_draw_start..];
974        assert!(
975            second_draw.contains("\x1b[38;2;10;20;30m"),
976            "output: {second_draw:?}"
977        );
978        assert!(
979            !second_draw.contains("48;2;4;5;6"),
980            "output: {second_draw:?}"
981        );
982    }
983
984    #[test]
985    fn adjacent_cells_skip_redundant_move() {
986        let tile_a = Tile::new('A', Style::default());
987        let tile_b = Tile::new('B', Style::default());
988        let mut renderer = TerminalRenderer::new(Vec::new());
989        renderer
990            .draw(
991                [
992                    DrawCell::new(Pos { x: 0, y: 0 }, &tile_a),
993                    DrawCell::new(Pos { x: 1, y: 0 }, &tile_b),
994                ]
995                .into_iter(),
996            )
997            .unwrap();
998        renderer.flush().unwrap();
999        let out = String::from_utf8(renderer.into_writer()).unwrap();
1000        // Only one cursor move: the second cell is adjacent to the first.
1001        assert_eq!(out.matches('H').count(), 1, "output: {out:?}");
1002    }
1003
1004    #[test]
1005    fn reset_state_clears_tracked_state() {
1006        let tile = Tile::new('X', Style::default());
1007        let mut renderer = TerminalRenderer::new(Vec::new());
1008        renderer
1009            .draw(core::iter::once(DrawCell::new(Pos { x: 0, y: 0 }, &tile)))
1010            .unwrap();
1011        renderer.reset_state();
1012        renderer
1013            .draw(core::iter::once(DrawCell::new(Pos { x: 0, y: 0 }, &tile)))
1014            .unwrap();
1015        renderer.flush().unwrap();
1016        let out = String::from_utf8(renderer.into_writer()).unwrap();
1017        // Without reset_state, the second draw call (same pos, same style)
1018        // would skip the move + color codes entirely.
1019        assert_eq!(out.matches("\x1b[1;1H").count(), 2, "output: {out:?}");
1020    }
1021
1022    #[test]
1023    fn synchronized_update_markers() {
1024        let mut renderer = TerminalRenderer::new(Vec::new());
1025        renderer.begin_synchronized_update().unwrap();
1026        renderer.end_synchronized_update().unwrap();
1027        renderer.flush().unwrap();
1028        let out = String::from_utf8(renderer.into_writer()).unwrap();
1029        assert_eq!(out, "\x1b[?2026h\x1b[?2026l");
1030    }
1031
1032    /// A multi-cell span's covered cells carry that span's text fallback, so a terminal must
1033    /// print all of them. Unlike `WIDE_CHAR_SPACER`, which both draw paths skip.
1034    #[test]
1035    fn span_covered_cells_are_printed_as_the_text_fallback() {
1036        use retroglyph_core::grid::Grid;
1037
1038        let mut grid = Grid::new(2, 2);
1039        grid.write_span(0, 0, 0, &["C=", "[]"], Style::default())
1040            .expect("2x2 span fits in a 2x2 grid");
1041        let tiles: Vec<(Pos, Tile)> = (0..2)
1042            .flat_map(|y| (0..2).map(move |x| (Pos { x, y }, x, y)))
1043            .map(|(pos, x, y)| (pos, *grid.tile(0, (x, y)).unwrap()))
1044            .collect();
1045
1046        for plain in [false, true] {
1047            let mut renderer = TerminalRenderer::with_plain_mode(Vec::new(), plain);
1048            renderer
1049                .draw(tiles.iter().map(|(pos, tile)| DrawCell::new(*pos, tile)))
1050                .unwrap();
1051            renderer.flush().unwrap();
1052            let out = String::from_utf8(renderer.into_writer()).unwrap();
1053            for glyph in ['C', '=', '[', ']'] {
1054                assert!(
1055                    out.contains(glyph),
1056                    "plain={plain}: span fallback glyph {glyph:?} missing from {out:?}"
1057                );
1058            }
1059        }
1060    }
1061
1062    #[test]
1063    fn plain_mode_strips_escape_codes() {
1064        let style = Style::new().fg(Color::Ansi(AnsiColor::Red));
1065        let tile = Tile::new('X', style);
1066        let mut renderer = TerminalRenderer::with_plain_mode(Vec::new(), true);
1067        renderer
1068            .draw(core::iter::once(DrawCell::new(Pos { x: 0, y: 0 }, &tile)))
1069            .unwrap();
1070        renderer.flush().unwrap();
1071        let out = String::from_utf8(renderer.into_writer()).unwrap();
1072        assert_eq!(out, "X");
1073    }
1074
1075    #[test]
1076    fn plain_mode_does_not_overflow_the_cursor_x_advance_at_the_maximum_column() {
1077        // retroglyph#729: `cursor_x = pos.x + cell_width` overflowed for a cell at `x: u16::MAX`.
1078        let tile = Tile::new('X', Style::default());
1079        let mut renderer = TerminalRenderer::with_plain_mode(Vec::new(), true);
1080        renderer
1081            .draw(core::iter::once(DrawCell::new(
1082                Pos { x: u16::MAX, y: 0 },
1083                &tile,
1084            )))
1085            .unwrap();
1086        renderer.flush().unwrap();
1087        let out = String::from_utf8(renderer.into_writer()).unwrap();
1088        assert!(out.ends_with('X'), "output: {out:?}");
1089    }
1090
1091    #[test]
1092    fn plain_mode_renders_full_grid_as_readable_ascii() {
1093        // A 2-row, gapped grid: row 0 has 'A' at col 0 and 'B' at col 2 (a
1094        // gap at col 1); row 1 has 'C' at col 0. Plain mode should turn this
1095        // into readable text: gaps become spaces, row changes become '\n'.
1096        let a = Tile::new('A', Style::default());
1097        let b = Tile::new('B', Style::default());
1098        let c = Tile::new('C', Style::default());
1099        let mut renderer = TerminalRenderer::with_plain_mode(Vec::new(), true);
1100        renderer
1101            .draw(
1102                [
1103                    DrawCell::new(Pos { x: 0, y: 0 }, &a),
1104                    DrawCell::new(Pos { x: 2, y: 0 }, &b),
1105                    DrawCell::new(Pos { x: 0, y: 1 }, &c),
1106                ]
1107                .into_iter(),
1108            )
1109            .unwrap();
1110        renderer.flush().unwrap();
1111        let out = String::from_utf8(renderer.into_writer()).unwrap();
1112        assert_eq!(out, "A B\nC");
1113        assert!(!out.contains('\x1b'), "output: {out:?}");
1114    }
1115
1116    #[test]
1117    fn plain_mode_skips_blank_rows() {
1118        let a = Tile::new('A', Style::default());
1119        let b = Tile::new('B', Style::default());
1120        let mut renderer = TerminalRenderer::with_plain_mode(Vec::new(), true);
1121        renderer
1122            .draw(
1123                [
1124                    DrawCell::new(Pos { x: 0, y: 0 }, &a),
1125                    DrawCell::new(Pos { x: 0, y: 2 }, &b),
1126                ]
1127                .into_iter(),
1128            )
1129            .unwrap();
1130        renderer.flush().unwrap();
1131        let out = String::from_utf8(renderer.into_writer()).unwrap();
1132        assert_eq!(out, "A\n\nB");
1133    }
1134
1135    #[test]
1136    fn plain_mode_handles_descending_x_on_same_row_without_corrupting_output() {
1137        // Regression test for retroglyph#273: two cells on the same row delivered out of
1138        // ascending-x order (x=5 then x=2) used to compute an empty `start_col..pos.x` padding
1139        // range (5..2), silently appending the second cell right after the first with no
1140        // separator, misplacing it. Plain mode has no cursor-addressing escapes to seek
1141        // backward with, so the fix starts a fresh line for the out-of-order cell instead
1142        // (the same fallback already used for backward-row repeats) rather than corrupting the
1143        // column alignment of the first line.
1144        let a = Tile::new('A', Style::default());
1145        let b = Tile::new('B', Style::default());
1146        let mut renderer = TerminalRenderer::with_plain_mode(Vec::new(), true);
1147        renderer
1148            .draw(
1149                [
1150                    DrawCell::new(Pos { x: 5, y: 0 }, &a),
1151                    DrawCell::new(Pos { x: 2, y: 0 }, &b),
1152                ]
1153                .into_iter(),
1154            )
1155            .unwrap();
1156        renderer.flush().unwrap();
1157        let out = String::from_utf8(renderer.into_writer()).unwrap();
1158        // First line: 5 spaces of padding then 'A'. Second line (fresh, since x went
1159        // backward): 2 spaces of padding then 'B'. Never "AB" or "A B" glued together on one
1160        // line at the wrong column.
1161        assert_eq!(out, "     A\n  B");
1162    }
1163
1164    #[test]
1165    fn plain_mode_suppresses_synchronized_update_markers() {
1166        let mut renderer = TerminalRenderer::with_plain_mode(Vec::new(), true);
1167        renderer.begin_synchronized_update().unwrap();
1168        renderer.end_synchronized_update().unwrap();
1169        renderer.flush().unwrap();
1170        let out = String::from_utf8(renderer.into_writer()).unwrap();
1171        assert_eq!(out, "");
1172    }
1173
1174    #[test]
1175    fn plain_mode_setter_and_getter_round_trip() {
1176        let mut renderer = TerminalRenderer::new(Vec::new());
1177        assert!(!renderer.plain_mode());
1178        renderer.set_plain_mode(true);
1179        assert!(renderer.plain_mode());
1180    }
1181
1182    #[test]
1183    fn auto_detects_plain_mode_from_non_terminal_writer() {
1184        // `Vec<u8>` isn't a TTY-capable writer, but `std::fs::File` implements
1185        // `IsTerminal`, and a regular file is never a terminal.
1186        let path = std::env::temp_dir().join(format!(
1187            "retroglyph-terminal-auto-plain-mode-test-{}-{:?}",
1188            std::process::id(),
1189            std::thread::current().id()
1190        ));
1191        let file = std::fs::File::create(&path).unwrap();
1192        let renderer = TerminalRenderer::auto(file);
1193        assert!(renderer.plain_mode());
1194        drop(renderer);
1195        let _ = std::fs::remove_file(&path);
1196    }
1197
1198    #[cfg(feature = "egc")]
1199    #[test]
1200    fn draw_prints_full_grapheme_when_provided() {
1201        // The tile's `glyph` is just the primary codepoint ('e'); the full
1202        // combining-mark cluster only reaches the renderer via the third
1203        // `draw` item, not the tile itself (see `Grid::grapheme`).
1204        let tile = Tile::new('e', Style::default());
1205        let mut renderer = TerminalRenderer::new(Vec::new());
1206        renderer
1207            .draw(core::iter::once(
1208                DrawCell::new(Pos { x: 0, y: 0 }, &tile).with_grapheme(Some("e\u{0301}")),
1209            ))
1210            .unwrap();
1211        renderer.flush().unwrap();
1212        let out = String::from_utf8(renderer.into_writer()).unwrap();
1213        assert!(out.contains("e\u{0301}"), "output: {out:?}");
1214    }
1215}