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