Skip to main content

rich_ext/
surface.rs

1//! Positioned drawing: render any rich renderable into a rectangle of the
2//! terminal, and write only the cells that changed.
3//!
4//! A [`Surface`] keeps a [`Screen`] of cells between frames. Each
5//! [`draw`](Surface::draw) hands a [`Drawing`] to a closure that renders
6//! into rectangles of it; the surface then writes what changed inside those
7//! rectangles and nothing else, so a clock ticking in one corner sends a few
8//! bytes rather than the whole screen. It is the drawing half of
9//! `rs-rich-intuituive`, which builds on the same [`Screen`] and [`Painter`],
10//! without the widget tree or the event loop: input stays yours.
11//!
12//! ```
13//! use rich::Text;
14//! use rich_ext::surface::{Rect, Surface};
15//!
16//! let mut surface = Surface::new(Vec::new(), 20, 2);
17//! surface
18//!     .draw(|s| {
19//!         s.render(Rect::new(0, 0, 20, 1), &Text::new("tick 1"));
20//!     })
21//!     .unwrap();
22//! surface.writer_mut().clear();
23//! surface
24//!     .draw(|s| {
25//!         s.render(Rect::new(0, 0, 20, 1), &Text::new("tick 2"));
26//!     })
27//!     .unwrap();
28//! // One cell changed: move there and write it.
29//! let out = String::from_utf8(surface.writer().clone()).unwrap();
30//! assert_eq!(out, "\x1b[1;6H\x1b[0m2");
31//! ```
32//!
33//! The surface fills the terminal on the alternate screen
34//! ([`Viewport::Fullscreen`], the default), or takes a fixed number of rows
35//! below the cursor and leaves the scrollback alone ([`Viewport::Inline`]).
36//! A [`resize`](Surface::resize) keeps what was drawn and writes everything
37//! once. Graphics (micro assets drawn with Kitty, iTerm2 or Sixel) come
38//! through a [`PlacementSource`], as they do for [`frame`](crate::frame)s.
39
40mod screen;
41
42use std::io::{self, Write};
43use std::sync::Arc;
44
45use rich::{Console, ConsoleOptions, Renderable, Segment};
46
47use crate::frame::{Change, Frame, Placement, PlacementSource, plan_graphics};
48
49pub(crate) use screen::char_width;
50pub use screen::{Cell, PaintStats, PaintStrategy, Painter, Rect, Screen, TextRun};
51
52/// Drawing straight into a [`Screen`]'s cells: rs-rich-ext's fast path.
53///
54/// rich renders to [`Segment`]s, each owning its text and a [`Style`]
55/// (whose colours own their names), and a [`Screen`] turns those back into
56/// cells. rich's path is the faithful one, and stays the reference: rich's
57/// public types are unchanged. A renderable that also implements `Draw`
58/// writes the same cells without building them: its own data goes into
59/// the screen's cells with each style interned once
60/// ([`Screen::write_runs`], [`Screen::put_rgb`], [`Screen::put`]).
61/// rs-rich-ext's charts implement it, and intuiTUIve draws with it by
62/// default.
63///
64/// rich's path is [`draw_rendered`]: render, then [`Screen::write_lines`].
65/// An implementation must give the same cells as it (the tests compare the
66/// two for every chart that has one).
67///
68/// ```
69/// use rich::Console;
70/// use rich_ext::chart::{Dataset, Chart};
71/// use rich_ext::surface::{Draw, Rect, Screen};
72///
73/// let console = Console::builder().width(30).build();
74/// let options = console.options().update_width(30).update_height(8);
75/// let chart = Chart::new([Dataset::new([(0.0, 0.0), (1.0, 1.0), (2.0, 0.5)])]);
76/// let rect = Rect::new(0, 0, 30, 8);
77///
78/// let mut fast = Screen::new(30, 8);
79/// chart.draw(&console, &options, &mut fast, rect);
80///
81/// let mut rich_path = Screen::new(30, 8);
82/// rich_path.write_lines(rect, &console.render_lines(&chart, &options, false));
83/// assert_eq!(fast.lines(), rich_path.lines());
84/// ```
85pub trait Draw: Renderable {
86    /// Draw into `rect` of `screen`, rendered with `options` (its width and
87    /// height are the rectangle's): the cells `screen.write_lines(rect,
88    /// &console.render_lines(self, options, false))` would give.
89    fn draw(&self, console: &Console, options: &ConsoleOptions, screen: &mut Screen, rect: Rect);
90}
91
92/// rich's path, the reference [`Draw`] is held to: `renderable` rendered
93/// with `options`, its lines written into `rect` of `screen`.
94pub fn draw_rendered(
95    console: &Console,
96    renderable: &dyn Renderable,
97    options: &ConsoleOptions,
98    screen: &mut Screen,
99    rect: Rect,
100) {
101    let lines = console.render_lines(renderable, options, false);
102    screen.write_lines(rect, &lines);
103}
104
105/// Begins a synchronized update (DEC private mode 2026): the terminal holds
106/// the screen until [`END_SYNCHRONIZED_UPDATE`].
107pub const BEGIN_SYNCHRONIZED_UPDATE: &str = "\x1b[?2026h";
108/// Ends a synchronized update: the terminal shows what was written since
109/// [`BEGIN_SYNCHRONIZED_UPDATE`] in one go.
110pub const END_SYNCHRONIZED_UPDATE: &str = "\x1b[?2026l";
111
112/// The visible stand-in for a terminal control, or `None` for any other
113/// character. One character for one, in the style of
114/// [`sanitize_terminal_controls`](crate::sanitize_terminal_controls): C0
115/// controls as their control pictures (`␛`, `␇`), DEL as `␡`, a tab as a
116/// space (it would move the cursor), and C1 controls (8-bit CSI and OSC
117/// among them) as `�`.
118pub fn control_picture(c: char) -> Option<char> {
119    match c {
120        '\t' => Some(' '),
121        '\u{1b}' => Some('␛'),
122        '\u{7f}' => Some('␡'),
123        '\0'..='\u{1f}' => char::from_u32(0x2400 + c as u32),
124        '\u{80}'..='\u{9f}' => Some('\u{fffd}'),
125        _ => None,
126    }
127}
128
129/// Make the terminal controls in `line`'s text visible, so nothing drawn
130/// can drive the terminal: styles are already [`Style`](rich::Style)s, and
131/// whatever is left in the text would otherwise reach it raw. Each control
132/// becomes one character (see [`control_picture`]), so character offsets
133/// into the text still hold. Control segments are left alone; frames skip
134/// them.
135pub fn sanitize_line(line: &mut [Segment]) {
136    for segment in line.iter_mut().filter(|segment| !segment.control) {
137        if segment.text.chars().any(|c| control_picture(c).is_some()) {
138            segment.text = segment
139                .text
140                .chars()
141                .map(|c| control_picture(c).unwrap_or(c))
142                .collect();
143        }
144    }
145}
146
147/// Where a [`Surface`] draws.
148#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
149pub enum Viewport {
150    /// The whole terminal, on the alternate screen: what was there before
151    /// comes back when the surface finishes.
152    #[default]
153    Fullscreen,
154    /// This many rows from the cursor's row down, scrolling the terminal if
155    /// they pass its bottom. The rows above (the shell's scrollback) are
156    /// left alone, and the last frame stays on screen when the surface
157    /// finishes, with the cursor below it.
158    Inline(u16),
159}
160
161/// A drawing handle on the terminal: a screen of cells kept between frames,
162/// the painter that writes the difference, and where it writes. See the
163/// [module docs](self).
164pub struct Surface<W: Write> {
165    writer: W,
166    console: Console,
167    screen: Screen,
168    painter: Painter,
169    viewport: Viewport,
170    synchronized: bool,
171    started: bool,
172    finished: bool,
173    cursor_shown: bool,
174    /// The next draw writes every cell.
175    full: bool,
176    graphics: Option<Arc<dyn PlacementSource>>,
177    placements: Vec<Placement>,
178    /// [`paint_strategy`](Surface::paint_strategy), when chosen.
179    strategy: Option<PaintStrategy>,
180    /// [`terminal_scroll`](Surface::terminal_scroll), when chosen.
181    terminal_scroll: Option<bool>,
182    /// `TMUX` was set when the surface was made.
183    under_tmux: bool,
184}
185
186impl Surface<io::Stdout> {
187    /// A surface on standard output, as large as the terminal (inline, as
188    /// wide as it), with the colours `Console::new()` detects.
189    pub fn stdout(viewport: Viewport) -> Surface<io::Stdout> {
190        let console = Console::new();
191        let size = console.size();
192        let width = clamp(size.width);
193        let height = match viewport {
194            Viewport::Fullscreen => clamp(size.height),
195            // No taller than the terminal: relative moves stop at its edges.
196            Viewport::Inline(rows) => rows.min(clamp(size.height)),
197        };
198        Surface::new(io::stdout(), width, height)
199            .viewport(viewport)
200            .console(console)
201    }
202}
203
204impl<W: Write> Surface<W> {
205    /// A full-screen surface of `width` by `height` cells writing to
206    /// `writer`, in truecolor. [`console`](Self::console) sets the colours
207    /// and how renderables are rendered. It paints with
208    /// [`PaintStrategy::Best`] unless [`paint_strategy`](Self::paint_strategy)
209    /// or `RICH_PAINT_STRATEGY` says otherwise.
210    pub fn new(writer: W, width: u16, height: u16) -> Surface<W> {
211        let console = Console::builder()
212            .width(width.max(1) as usize)
213            .height(height.max(1) as usize)
214            .force_terminal(true)
215            .color_system(Some(rich::ColorSystem::Truecolor))
216            .build();
217        let system = console.color_system();
218        Surface {
219            writer,
220            console,
221            screen: Screen::new(width, height),
222            painter: Painter::new(system),
223            viewport: Viewport::Fullscreen,
224            synchronized: false,
225            started: false,
226            finished: false,
227            cursor_shown: true,
228            full: true,
229            graphics: None,
230            placements: Vec::new(),
231            strategy: None,
232            terminal_scroll: None,
233            under_tmux: std::env::var_os("TMUX").is_some(),
234        }
235        .configured()
236    }
237
238    /// The painter set up as the surface asks: its strategy, and whether it
239    /// may scroll (not on Windows, nor under tmux, unless
240    /// [`terminal_scroll`](Self::terminal_scroll) says so; an inline
241    /// painter never does).
242    fn configured(mut self) -> Surface<W> {
243        self.painter.set_strategy(
244            self.strategy
245                .or_else(PaintStrategy::from_env)
246                .unwrap_or_default(),
247        );
248        // tmux redraws a scrolled region itself, so moving rows there saves
249        // little; as intuiTUIve's apps, the surface paints there instead.
250        let scroll = self
251            .terminal_scroll
252            .unwrap_or(!cfg!(windows) && !self.under_tmux);
253        self.painter.set_scroll(scroll);
254        self
255    }
256
257    /// How each frame's changes become bytes (see [`PaintStrategy`]):
258    /// [`Best`](PaintStrategy::Best) by default, or what the
259    /// `RICH_PAINT_STRATEGY` environment variable names (`cells`, `rows`,
260    /// `full`, `scroll` or `best`) when the surface does not choose. `best`
261    /// scrolls rows that moved and otherwise paints cells. Scrolling is off
262    /// on Windows, under tmux, inline and over graphics, and
263    /// [`terminal_scroll`](Self::terminal_scroll) overrides it. Every
264    /// strategy shows the same screen.
265    ///
266    /// ```
267    /// use rich::Text;
268    /// use rich_ext::surface::{PaintStrategy, Rect, Surface};
269    ///
270    /// let mut surface = Surface::new(Vec::new(), 10, 1).paint_strategy(PaintStrategy::Full);
271    /// surface
272    ///     .draw(|s| s.render(Rect::new(0, 0, 10, 1), &Text::new("hello")))
273    ///     .unwrap();
274    /// assert_eq!(surface.screen().plain()[0].trim_end(), "hello");
275    /// ```
276    #[must_use]
277    pub fn paint_strategy(mut self, strategy: PaintStrategy) -> Surface<W> {
278        self.strategy = Some(strategy);
279        self.configured()
280    }
281
282    /// Whether [`PaintStrategy::Scroll`] and [`PaintStrategy::Best`] may
283    /// move rows with the terminal's own scrolling. Unset, it is on, but
284    /// off on Windows (whose consoles have been seen to scroll the wrong
285    /// rows) and under tmux; set, it holds wherever the surface runs. An
286    /// inline surface never scrolls.
287    #[must_use]
288    pub fn terminal_scroll(mut self, on: bool) -> Surface<W> {
289        self.terminal_scroll = Some(on);
290        self.configured()
291    }
292
293    /// Draw inline or full screen. An inline viewport's row count replaces
294    /// the surface's height, up to that height (the terminal's).
295    #[must_use]
296    pub fn viewport(mut self, viewport: Viewport) -> Surface<W> {
297        self.viewport = viewport;
298        if let Viewport::Inline(rows) = viewport {
299            // The surface's height is the terminal's: a region taller than
300            // that would lose rows at its edges.
301            let area = self.screen.area();
302            self.screen = Screen::new(area.width, rows.min(area.height.max(1)));
303        }
304        let system = self.console.color_system();
305        self.painter = match viewport {
306            Viewport::Fullscreen => Painter::new(system),
307            Viewport::Inline(_) => Painter::new(system).inline(),
308        };
309        self.full = true;
310        self.configured()
311    }
312
313    /// Render with `console`: its colour system is the one written (`None`
314    /// writes no colour at all), and its theme and highlighting apply.
315    #[must_use]
316    pub fn console(mut self, console: Console) -> Surface<W> {
317        self.painter.set_color_system(console.color_system());
318        self.console = console;
319        self
320    }
321
322    /// Write each frame as one synchronized update (DEC private mode 2026),
323    /// which a terminal that has it shows whole and one that does not
324    /// ignores (off by default).
325    #[must_use]
326    pub fn synchronized(mut self, on: bool) -> Surface<W> {
327        self.synchronized = on;
328        self
329    }
330
331    /// Draw the graphics `source` places on the screen's cells, after the
332    /// cells, and redraw them when the cells under them are repainted.
333    #[must_use]
334    pub fn graphics(mut self, source: Option<Arc<dyn PlacementSource>>) -> Surface<W> {
335        self.graphics = source;
336        self
337    }
338
339    /// The whole surface, at `(0, 0)`.
340    pub fn area(&self) -> Rect {
341        self.screen.area()
342    }
343
344    /// What is on the surface now.
345    pub fn screen(&self) -> &Screen {
346        &self.screen
347    }
348
349    pub fn writer(&self) -> &W {
350        &self.writer
351    }
352
353    pub fn writer_mut(&mut self) -> &mut W {
354        &mut self.writer
355    }
356
357    /// The terminal changed size: keep what was drawn (cut to the new
358    /// size), and write everything on the next [`draw`](Self::draw). An
359    /// inline viewport keeps its row count, up to the terminal's height,
360    /// and takes the new width.
361    pub fn resize(&mut self, width: u16, height: u16) {
362        let height = match self.viewport {
363            Viewport::Fullscreen => height,
364            Viewport::Inline(rows) => rows.min(height.max(1)),
365        };
366        if (width, height) == (self.screen.area().width, self.screen.area().height) {
367            return;
368        }
369        let mut screen = Screen::new(width, height);
370        let old = self.screen.area();
371        screen.blit(&self.screen, old, 0, 0);
372        self.screen = screen;
373        self.full = true;
374    }
375
376    /// Blank the surface; the next [`draw`](Self::draw) writes everything.
377    pub fn clear(&mut self) {
378        let area = self.screen.area();
379        self.screen.clear(area);
380        self.full = true;
381    }
382
383    /// Draw a frame: `f` renders into rectangles of the [`Drawing`], then
384    /// what changed inside them is written and flushed. Cells nothing drew
385    /// keep what they showed. The cursor is hidden unless `f` placed it
386    /// with [`Drawing::set_cursor`].
387    pub fn draw(&mut self, f: impl FnOnce(&mut Drawing<'_>)) -> io::Result<()> {
388        let mut drawing = Drawing {
389            screen: &mut self.screen,
390            console: &self.console,
391            graphics: self.graphics.as_deref(),
392            damage: Vec::new(),
393            cursor: None,
394        };
395        f(&mut drawing);
396        let Drawing { damage, cursor, .. } = drawing;
397        let out = self.frame(damage, cursor);
398        self.writer.write_all(out.as_bytes())?;
399        self.writer.flush()
400    }
401
402    /// The bytes for one frame.
403    fn frame(&mut self, damage: Vec<Rect>, cursor: Option<(u16, u16)>) -> String {
404        let mut out = String::new();
405        if !self.started {
406            if self.viewport == Viewport::Fullscreen {
407                out.push_str("\x1b[?1049h");
408            }
409            self.started = true;
410        }
411        let full = std::mem::take(&mut self.full);
412        if full {
413            self.painter.invalidate();
414        }
415        let damage = if full {
416            vec![self.screen.area()]
417        } else {
418            damage
419        };
420        // No scroll may move a graphic on screen: the terminal would move
421        // it, and the graphics' plan would not know.
422        let fixed: Vec<Rect> = self
423            .placements
424            .iter()
425            .map(|p| Rect::new(p.column as u16, p.row as u16, p.cols as u16, p.rows as u16))
426            .collect();
427        self.painter.set_fixed(&fixed);
428        let painted = self.painter.paint(&self.screen, &damage);
429        let written = self.painter.written().to_vec();
430        if self.cursor_shown && !painted.is_empty() {
431            // Hidden while the frame is written, so it does not flicker
432            // across the screen.
433            out.push_str("\x1b[?25l");
434            self.cursor_shown = false;
435        }
436        out.push_str(&painted);
437        if self.graphics.is_some() {
438            out.push_str(&self.draw_graphics(&written));
439        }
440        let area = self.screen.area();
441        match cursor.filter(|&(x, y)| x < area.width && y < area.height) {
442            Some((x, y)) => {
443                out.push_str(&self.painter.move_to(x, y));
444                if !self.cursor_shown {
445                    out.push_str("\x1b[?25h");
446                    self.cursor_shown = true;
447                }
448            }
449            None => {
450                if self.cursor_shown {
451                    out.push_str("\x1b[?25l");
452                    self.cursor_shown = false;
453                }
454            }
455        }
456        // Styles no cell uses any more go, once they pile up.
457        self.painter.compact(&mut self.screen);
458        if self.synchronized && !out.is_empty() {
459            out = format!("{BEGIN_SYNCHRONIZED_UPDATE}{out}{END_SYNCHRONIZED_UPDATE}");
460        }
461        out
462    }
463
464    /// After the cells: the graphics that moved, went away or had their
465    /// cells repainted under them. `written` is what the painter actually
466    /// wrote: a damaged cell that did not change was not written, so a
467    /// graphic over it was not wiped.
468    fn draw_graphics(&mut self, written: &[Rect]) -> String {
469        let Some(source) = self.graphics.clone() else {
470            return String::new();
471        };
472        let frame = screen_frame(&self.screen);
473        let height = frame.height();
474        let next: Vec<Placement> = source
475            .placements(&frame)
476            .into_iter()
477            .filter(|p| p.row + p.rows <= height)
478            .collect();
479        let changes: Vec<Change> = written
480            .iter()
481            .flat_map(|rect| {
482                (rect.y..rect.bottom()).map(move |row| Change {
483                    row: row as usize,
484                    columns: rect.x as usize..rect.right() as usize,
485                })
486            })
487            .collect();
488        let plan = plan_graphics(&self.placements, &next, &changes);
489        let mut out = String::new();
490        let repaint: Vec<Rect> = plan
491            .repaint
492            .iter()
493            .map(|(row, columns)| {
494                Rect::new(
495                    columns.start as u16,
496                    *row as u16,
497                    (columns.end - columns.start) as u16,
498                    1,
499                )
500            })
501            .collect();
502        if !repaint.is_empty() {
503            out.push_str(&self.painter.repaint(&self.screen, &repaint));
504        }
505        for placement in &plan.draw {
506            out.push_str(
507                &self
508                    .painter
509                    .move_to(placement.column as u16, placement.row as u16),
510            );
511            out.push_str(&placement.graphic.draw(placement.frame));
512        }
513        self.placements = next;
514        out
515    }
516
517    /// Give the terminal back: the alternate screen is left (full screen),
518    /// or the cursor goes below the last frame (inline); styles are reset
519    /// and the cursor shown. Called by `Drop` if not before.
520    pub fn finish(&mut self) -> io::Result<()> {
521        if self.finished || !self.started {
522            self.finished = true;
523            return Ok(());
524        }
525        let mut out = String::new();
526        if let Some(source) = &self.graphics {
527            let retained = match self.viewport {
528                Viewport::Inline(_) => self.placements.clone(),
529                Viewport::Fullscreen => Vec::new(),
530            };
531            out.push_str(&source.release(&retained));
532        }
533        out.push_str(&self.painter.finish());
534        out.push_str("\x1b[0m\x1b[?25h");
535        if self.viewport == Viewport::Fullscreen {
536            out.push_str("\x1b[?1049l");
537        }
538        self.writer.write_all(out.as_bytes())?;
539        self.writer.flush()?;
540        // Only now: a failed write leaves `finish` (and `Drop`) to try again.
541        self.finished = true;
542        Ok(())
543    }
544}
545
546impl<W: Write> Drop for Surface<W> {
547    fn drop(&mut self) {
548        let _ = self.finish();
549    }
550}
551
552/// One frame being drawn: render into rectangles of the surface. See
553/// [`Surface::draw`].
554pub struct Drawing<'a> {
555    screen: &'a mut Screen,
556    console: &'a Console,
557    graphics: Option<&'a dyn PlacementSource>,
558    damage: Vec<Rect>,
559    cursor: Option<(u16, u16)>,
560}
561
562impl Drawing<'_> {
563    /// The whole surface, at `(0, 0)`.
564    pub fn area(&self) -> Rect {
565        self.screen.area()
566    }
567
568    /// The console renderables are rendered with.
569    pub fn console(&self) -> &Console {
570        self.console
571    }
572
573    /// Render `renderable` into `rect`, `rect.width` cells wide and at most
574    /// `rect.height` rows tall, replacing what was there: the cells it does
575    /// not reach are cleared.
576    pub fn render(&mut self, rect: Rect, renderable: &dyn Renderable) {
577        let options = self
578            .console
579            .options()
580            .update_width(rect.width.max(1) as usize)
581            .update_height(rect.height.max(1) as usize);
582        let lines = self.console.render_lines(renderable, &options, false);
583        self.write_lines(rect, lines);
584    }
585
586    /// [`render`](Self::render) through rs-rich-ext's fast path
587    /// ([`Draw`]): the same cells. With graphics to place, rich's path, which
588    /// they are found in.
589    pub fn draw(&mut self, rect: Rect, drawable: &dyn Draw) {
590        if self.graphics.is_some() {
591            return self.render(rect, drawable);
592        }
593        let options = self
594            .console
595            .options()
596            .update_width(rect.width.max(1) as usize)
597            .update_height(rect.height.max(1) as usize);
598        drawable.draw(self.console, &options, self.screen, rect);
599        self.damage.push(rect);
600    }
601
602    /// Write rendered `lines` into `rect`, replacing what was there (see
603    /// [`Screen::write_lines`]).
604    pub fn write_lines(&mut self, rect: Rect, lines: Vec<Vec<Segment>>) {
605        let lines: Vec<Vec<Segment>> = match self.graphics {
606            Some(source) => lines.into_iter().map(|l| source.prepare(l)).collect(),
607            None => lines,
608        };
609        self.screen.write_lines(rect, &lines);
610        self.damage.push(rect);
611    }
612
613    /// Clear `rect` to blank cells.
614    pub fn clear(&mut self, rect: Rect) {
615        self.screen.clear(rect);
616        self.damage.push(rect);
617    }
618
619    /// Show the cursor at `x`, `y` once the frame is written.
620    pub fn set_cursor(&mut self, x: u16, y: u16) {
621        self.cursor = Some((x, y));
622    }
623
624    /// The screen itself, for what rendering does not cover (laying a
625    /// style over cells with [`Screen::restyle`], copying with
626    /// [`Screen::blit`]). Everything is compared with what was sent.
627    pub fn screen_mut(&mut self) -> &mut Screen {
628        let area = self.screen.area();
629        self.damage.push(area);
630        self.screen
631    }
632}
633
634/// The screen as a frame, for finding the graphics placed on its cells.
635fn screen_frame(screen: &Screen) -> Frame {
636    let mut segments = Vec::new();
637    for line in screen.lines() {
638        segments.extend(line);
639        segments.push(Segment::line());
640    }
641    Frame::from_segments(&segments)
642}
643
644fn clamp(cells: usize) -> u16 {
645    cells.clamp(1, u16::MAX as usize) as u16
646}
647
648#[cfg(test)]
649mod tests {
650    use super::*;
651    use rich::Text;
652
653    fn output(surface: &mut Surface<Vec<u8>>) -> String {
654        String::from_utf8(std::mem::take(surface.writer_mut())).unwrap()
655    }
656
657    /// A surface painting with `strategy`, as if `TMUX` were set or not
658    /// (whatever the environment running the tests says).
659    fn painting(strategy: PaintStrategy, tmux: bool, width: u16, height: u16) -> Surface<Vec<u8>> {
660        let mut surface = Surface::new(Vec::new(), width, height);
661        surface.under_tmux = tmux;
662        surface.paint_strategy(strategy)
663    }
664
665    /// Whether `out` moves rows with the terminal's scrolling: SU or SD,
666    /// or DECSTBM margins.
667    fn scrolls(out: &str) -> bool {
668        let bytes = out.as_bytes();
669        bytes.iter().enumerate().any(|(i, &b)| {
670            if b != 0x1b || bytes.get(i + 1) != Some(&b'[') {
671                return false;
672            }
673            let rest = &bytes[i + 2..];
674            let digits = rest
675                .iter()
676                .take_while(|c| c.is_ascii_digit() || **c == b';')
677                .count();
678            matches!(rest.get(digits), Some(b'S' | b'T' | b'r'))
679        })
680    }
681
682    /// Where two vt100 screens differ, cell by cell: text, colours and
683    /// attributes.
684    fn differences(a: &vt100::Screen, b: &vt100::Screen, width: u16, height: u16) -> Vec<String> {
685        let mut found = Vec::new();
686        for row in 0..height {
687            for col in 0..width {
688                let (Some(x), Some(y)) = (a.cell(row, col), b.cell(row, col)) else {
689                    continue;
690                };
691                let look = |c: &vt100::Cell| {
692                    (
693                        // An erased cell and a written space look the same.
694                        match c.contents() {
695                            "" => " ".to_string(),
696                            text => text.to_string(),
697                        },
698                        c.fgcolor(),
699                        c.bgcolor(),
700                        c.bold(),
701                        c.italic(),
702                        c.underline(),
703                        c.inverse(),
704                    )
705                };
706                if look(x) != look(y) {
707                    found.push(format!("({col}, {row}): {:?} != {:?}", look(x), look(y)));
708                }
709            }
710        }
711        found
712    }
713
714    /// Draw each of `frames` with `best` and with `full`, replay both
715    /// through vt100 after every frame, and require the same screen.
716    /// Returns `best`'s bytes after the first frame.
717    fn best_matches_full(frames: &[Vec<String>], width: u16, height: u16, tmux: bool) -> String {
718        let mut best = painting(PaintStrategy::Best, tmux, width, height);
719        let mut full = painting(PaintStrategy::Full, tmux, width, height);
720        let mut best_term = vt100::Parser::new(height, width, 0);
721        let mut full_term = vt100::Parser::new(height, width, 0);
722        let mut later = String::new();
723        for (n, lines) in frames.iter().enumerate() {
724            for (is_best, surface, term) in [
725                (true, &mut best, &mut best_term),
726                (false, &mut full, &mut full_term),
727            ] {
728                surface
729                    .draw(|s| {
730                        for (y, line) in lines.iter().enumerate() {
731                            let text = Text::from_markup(line).unwrap();
732                            s.render(Rect::new(0, y as u16, width, 1), &text);
733                        }
734                    })
735                    .unwrap();
736                term.process(surface.writer());
737                if n > 0 && is_best {
738                    later.push_str(std::str::from_utf8(surface.writer()).unwrap());
739                }
740                surface.writer_mut().clear();
741            }
742            let wrong = differences(best_term.screen(), full_term.screen(), width, height);
743            assert!(wrong.is_empty(), "frame {n}: {wrong:?}");
744        }
745        later
746    }
747
748    /// A log that scrolls: each frame shows the rows one further on, in
749    /// colours that move with them.
750    fn scrolling_log(frames: usize, rows: usize) -> Vec<Vec<String>> {
751        const STYLES: [&str; 4] = ["red", "bold green", "italic", "on blue"];
752        (0..frames)
753            .map(|frame| {
754                (frame..frame + rows)
755                    .map(|i| format!("[{}]entry {i}[/] of the log", STYLES[i % STYLES.len()]))
756                    .collect()
757            })
758            .collect()
759    }
760
761    #[test]
762    fn surfaces_paint_with_best_by_default() {
763        if PaintStrategy::from_env().is_none() {
764            let surface = Surface::new(Vec::new(), 4, 1);
765            assert_eq!(surface.painter.strategy(), PaintStrategy::Best);
766        }
767        let surface = Surface::new(Vec::new(), 4, 1).paint_strategy(PaintStrategy::Rows);
768        assert_eq!(surface.painter.strategy(), PaintStrategy::Rows);
769        // A new viewport keeps the strategy chosen.
770        let surface = surface.viewport(Viewport::Inline(1));
771        assert_eq!(surface.painter.strategy(), PaintStrategy::Rows);
772    }
773
774    #[test]
775    fn best_replayed_equals_a_full_repaint_when_rows_scroll() {
776        let later = best_matches_full(&scrolling_log(8, 6), 30, 6, false);
777        if cfg!(windows) {
778            assert!(!scrolls(&later), "{later:?}");
779        } else {
780            assert!(scrolls(&later), "best should have scrolled: {later:?}");
781        }
782    }
783
784    #[test]
785    fn best_replayed_equals_a_full_repaint_when_nothing_scrolls() {
786        let frames: Vec<Vec<String>> = (0..6)
787            .map(|t| {
788                vec![
789                    "[bold]a clock[/]".to_string(),
790                    format!("tick [cyan]{t}[/] of [red]{}[/]", 10 - t),
791                    "#".repeat(t + 1),
792                ]
793            })
794            .collect();
795        let later = best_matches_full(&frames, 20, 3, false);
796        assert!(!scrolls(&later), "{later:?}");
797    }
798
799    #[test]
800    fn under_tmux_best_does_not_scroll() {
801        let later = best_matches_full(&scrolling_log(8, 6), 30, 6, true);
802        assert!(!scrolls(&later), "{later:?}");
803        // Unless the surface says it may.
804        let mut surface =
805            painting(PaintStrategy::Best, true, 30, 6).terminal_scroll(!cfg!(windows));
806        let mut out = String::new();
807        for (n, lines) in scrolling_log(4, 6).iter().enumerate() {
808            surface
809                .draw(|s| {
810                    for (y, line) in lines.iter().enumerate() {
811                        let text = Text::from_markup(line).unwrap();
812                        s.render(Rect::new(0, y as u16, 30, 1), &text);
813                    }
814                })
815                .unwrap();
816            let frame = output(&mut surface);
817            if n > 0 {
818                out.push_str(&frame);
819            }
820        }
821        assert_eq!(scrolls(&out), !cfg!(windows), "{out:?}");
822    }
823
824    #[test]
825    fn an_inline_surface_does_not_scroll() {
826        let mut surface = painting(PaintStrategy::Best, false, 30, 6)
827            .viewport(Viewport::Inline(6))
828            .terminal_scroll(true);
829        let mut out = String::new();
830        for (n, lines) in scrolling_log(4, 6).iter().enumerate() {
831            surface
832                .draw(|s| {
833                    for (y, line) in lines.iter().enumerate() {
834                        let text = Text::from_markup(line).unwrap();
835                        s.render(Rect::new(0, y as u16, 30, 1), &text);
836                    }
837                })
838                .unwrap();
839            let frame = output(&mut surface);
840            if n > 0 {
841                out.push_str(&frame);
842            }
843        }
844        assert!(!scrolls(&out), "{out:?}");
845    }
846
847    #[test]
848    fn the_first_draw_enters_the_alternate_screen_and_writes_everything() {
849        let mut surface = Surface::new(Vec::new(), 10, 2);
850        surface
851            .draw(|s| s.render(Rect::new(0, 0, 10, 1), &Text::new("hello")))
852            .unwrap();
853        let out = output(&mut surface);
854        assert!(out.starts_with("\x1b[?1049h\x1b[?25l"), "{out:?}");
855        assert!(out.contains("\x1b[2J") && out.contains("hello"), "{out:?}");
856        surface.finish().unwrap();
857        let out = output(&mut surface);
858        assert!(out.ends_with("\x1b[0m\x1b[?25h\x1b[?1049l"), "{out:?}");
859    }
860
861    #[test]
862    fn an_unchanged_frame_writes_nothing() {
863        let mut surface = Surface::new(Vec::new(), 10, 2);
864        let draw = |s: &mut Drawing<'_>| s.render(Rect::new(0, 0, 10, 1), &Text::new("same"));
865        surface.draw(draw).unwrap();
866        output(&mut surface);
867        surface.draw(draw).unwrap();
868        assert_eq!(output(&mut surface), "");
869    }
870
871    #[test]
872    fn a_resize_keeps_the_cells_and_writes_everything_once() {
873        let mut surface = Surface::new(Vec::new(), 10, 2);
874        surface
875            .draw(|s| s.render(Rect::new(0, 0, 10, 1), &Text::new("kept")))
876            .unwrap();
877        output(&mut surface);
878        surface.resize(6, 3);
879        assert_eq!(surface.screen().plain()[0], "kept  ");
880        surface.draw(|_| {}).unwrap();
881        let out = output(&mut surface);
882        assert!(out.contains("\x1b[2J") && out.contains("kept"), "{out:?}");
883        surface.draw(|_| {}).unwrap();
884        assert_eq!(output(&mut surface), "");
885    }
886
887    #[test]
888    fn inline_draws_below_the_cursor_and_leaves_the_frame_on_finish() {
889        let mut surface = Surface::new(Vec::new(), 8, 24).viewport(Viewport::Inline(2));
890        assert_eq!(surface.area(), Rect::new(0, 0, 8, 2));
891        surface
892            .draw(|s| s.render(s.area(), &Text::new("one\ntwo")))
893            .unwrap();
894        let out = output(&mut surface);
895        assert!(!out.contains("\x1b[?1049h"), "{out:?}");
896        assert!(!out.contains("\x1b[2J"), "{out:?}");
897        assert!(out.contains("one") && out.contains("two"), "{out:?}");
898        surface.finish().unwrap();
899        let out = output(&mut surface);
900        assert!(out.ends_with("\r\n\x1b[0m\x1b[?25h"), "{out:?}");
901    }
902
903    #[test]
904    fn the_cursor_shows_only_where_a_frame_puts_it() {
905        let mut surface = Surface::new(Vec::new(), 10, 2);
906        surface
907            .draw(|s| {
908                s.render(Rect::new(0, 0, 10, 1), &Text::new("> "));
909                s.set_cursor(2, 0);
910            })
911            .unwrap();
912        let out = output(&mut surface);
913        assert!(out.ends_with("\x1b[1;3H\x1b[?25h"), "{out:?}");
914        surface.draw(|_| {}).unwrap();
915        assert_eq!(output(&mut surface), "\x1b[?25l");
916    }
917
918    #[test]
919    fn a_synchronized_surface_wraps_each_frame() {
920        let mut surface = Surface::new(Vec::new(), 10, 1).synchronized(true);
921        surface
922            .draw(|s| s.render(s.area(), &Text::new("x")))
923            .unwrap();
924        let out = output(&mut surface);
925        assert!(out.starts_with(BEGIN_SYNCHRONIZED_UPDATE), "{out:?}");
926        assert!(out.ends_with(END_SYNCHRONIZED_UPDATE), "{out:?}");
927        surface.draw(|_| {}).unwrap();
928        assert_eq!(output(&mut surface), "", "nothing to write, no wrapper");
929    }
930
931    #[test]
932    fn controls_in_rendered_text_become_visible() {
933        let mut surface = Surface::new(Vec::new(), 10, 1);
934        surface
935            .draw(|s| s.render(s.area(), &Text::new("a\x1b[2Jb")))
936            .unwrap();
937        assert_eq!(surface.screen().plain()[0], "a␛[2Jb    ");
938    }
939
940    /// A writer whose next `fails` writes fail.
941    struct Flaky {
942        fails: usize,
943        out: Vec<u8>,
944    }
945
946    impl Write for Flaky {
947        fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
948            if self.fails > 0 {
949                self.fails -= 1;
950                return Err(io::Error::other("full"));
951            }
952            self.out.write(buf)
953        }
954        fn flush(&mut self) -> io::Result<()> {
955            Ok(())
956        }
957    }
958
959    #[test]
960    fn a_failed_finish_can_be_retried() {
961        let mut surface = Surface::new(
962            Flaky {
963                fails: 0,
964                out: Vec::new(),
965            },
966            4,
967            1,
968        );
969        surface
970            .draw(|s| s.render(s.area(), &Text::new("x")))
971            .unwrap();
972        surface.writer_mut().fails = 1;
973        assert!(surface.finish().is_err());
974        surface.finish().unwrap();
975        let out = String::from_utf8(surface.writer().out.clone()).unwrap();
976        assert!(out.ends_with("\x1b[?1049l"), "{out:?}");
977    }
978
979    /// A one-cell graphic on every `*`.
980    #[derive(Debug)]
981    struct Star;
982
983    impl crate::frame::Graphic for Star {
984        fn key(&self) -> u64 {
985            1
986        }
987        fn draw(&self, _frame: usize) -> String {
988            "<star>".into()
989        }
990    }
991
992    /// One star at a column it is told, whatever the cells say.
993    struct Moving(std::sync::Mutex<usize>);
994
995    impl PlacementSource for Moving {
996        fn placements(&self, _frame: &Frame) -> Vec<Placement> {
997            vec![Placement {
998                row: 0,
999                column: *self.0.lock().unwrap(),
1000                cols: 1,
1001                rows: 1,
1002                graphic: Arc::new(Star),
1003                frame: 0,
1004            }]
1005        }
1006    }
1007
1008    #[test]
1009    fn a_graphic_that_moves_off_unchanged_cells_leaves_no_ghost() {
1010        let source = Arc::new(Moving(std::sync::Mutex::new(1)));
1011        let mut surface = Surface::new(Vec::new(), 6, 1)
1012            .graphics(Some(source.clone() as Arc<dyn PlacementSource>));
1013        let draw = |s: &mut Drawing<'_>| s.render(s.area(), &Text::new("abcdef"));
1014        surface.draw(draw).unwrap();
1015        output(&mut surface);
1016        *source.0.lock().unwrap() = 3;
1017        // The row is drawn again with the same cells: none is written by the
1018        // diff, so the cell under the old star must be repainted on purpose.
1019        surface.draw(draw).unwrap();
1020        let out = output(&mut surface);
1021        assert_eq!(out, "\x1b[1;2H\x1b[0mb\x1b[1;4H<star>", "{out:?}");
1022    }
1023
1024    #[test]
1025    fn dropping_a_started_surface_gives_the_terminal_back() {
1026        let mut sink = Vec::new();
1027        {
1028            let mut surface = Surface::new(&mut sink, 4, 1);
1029            surface
1030                .draw(|s| s.render(s.area(), &Text::new("x")))
1031                .unwrap();
1032        }
1033        let out = String::from_utf8(sink).unwrap();
1034        assert!(out.ends_with("\x1b[?1049l"), "{out:?}");
1035    }
1036}