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