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}