mod screen;
use std::io::{self, Write};
use std::sync::Arc;
use rich::{Console, ConsoleOptions, Renderable, Segment};
use crate::frame::{Change, Frame, Placement, PlacementSource, plan_graphics};
pub(crate) use screen::char_width;
pub use screen::{Cell, PaintStats, PaintStrategy, Painter, Rect, Screen, TextRun};
pub trait Draw: Renderable {
fn draw(&self, console: &Console, options: &ConsoleOptions, screen: &mut Screen, rect: Rect);
}
pub fn draw_rendered(
console: &Console,
renderable: &dyn Renderable,
options: &ConsoleOptions,
screen: &mut Screen,
rect: Rect,
) {
let lines = console.render_lines(renderable, options, false);
screen.write_lines(rect, &lines);
}
pub const BEGIN_SYNCHRONIZED_UPDATE: &str = "\x1b[?2026h";
pub const END_SYNCHRONIZED_UPDATE: &str = "\x1b[?2026l";
pub fn control_picture(c: char) -> Option<char> {
match c {
'\t' => Some(' '),
'\u{1b}' => Some('␛'),
'\u{7f}' => Some('␡'),
'\0'..='\u{1f}' => char::from_u32(0x2400 + c as u32),
'\u{80}'..='\u{9f}' => Some('\u{fffd}'),
_ => None,
}
}
pub fn sanitize_line(line: &mut [Segment]) {
for segment in line.iter_mut().filter(|segment| !segment.control) {
if segment.text.chars().any(|c| control_picture(c).is_some()) {
segment.text = segment
.text
.chars()
.map(|c| control_picture(c).unwrap_or(c))
.collect();
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Viewport {
#[default]
Fullscreen,
Inline(u16),
}
pub struct Surface<W: Write> {
writer: W,
console: Console,
screen: Screen,
painter: Painter,
viewport: Viewport,
synchronized: bool,
started: bool,
finished: bool,
cursor_shown: bool,
full: bool,
graphics: Option<Arc<dyn PlacementSource>>,
placements: Vec<Placement>,
strategy: Option<PaintStrategy>,
terminal_scroll: Option<bool>,
under_tmux: bool,
}
impl Surface<io::Stdout> {
pub fn stdout(viewport: Viewport) -> Surface<io::Stdout> {
let console = Console::new();
let size = console.size();
let width = clamp(size.width);
let height = match viewport {
Viewport::Fullscreen => clamp(size.height),
Viewport::Inline(rows) => rows.min(clamp(size.height)),
};
Surface::new(io::stdout(), width, height)
.viewport(viewport)
.console(console)
}
}
impl<W: Write> Surface<W> {
pub fn new(writer: W, width: u16, height: u16) -> Surface<W> {
let console = Console::builder()
.width(width.max(1) as usize)
.height(height.max(1) as usize)
.force_terminal(true)
.color_system(Some(rich::ColorSystem::Truecolor))
.build();
let system = console.color_system();
Surface {
writer,
console,
screen: Screen::new(width, height),
painter: Painter::new(system),
viewport: Viewport::Fullscreen,
synchronized: false,
started: false,
finished: false,
cursor_shown: true,
full: true,
graphics: None,
placements: Vec::new(),
strategy: None,
terminal_scroll: None,
under_tmux: std::env::var_os("TMUX").is_some(),
}
.configured()
}
fn configured(mut self) -> Surface<W> {
self.painter.set_strategy(
self.strategy
.or_else(PaintStrategy::from_env)
.unwrap_or_default(),
);
let scroll = self
.terminal_scroll
.unwrap_or(!cfg!(windows) && !self.under_tmux);
self.painter.set_scroll(scroll);
self
}
#[must_use]
pub fn paint_strategy(mut self, strategy: PaintStrategy) -> Surface<W> {
self.strategy = Some(strategy);
self.configured()
}
#[must_use]
pub fn terminal_scroll(mut self, on: bool) -> Surface<W> {
self.terminal_scroll = Some(on);
self.configured()
}
#[must_use]
pub fn viewport(mut self, viewport: Viewport) -> Surface<W> {
self.viewport = viewport;
if let Viewport::Inline(rows) = viewport {
let area = self.screen.area();
self.screen = Screen::new(area.width, rows.min(area.height.max(1)));
}
let system = self.console.color_system();
self.painter = match viewport {
Viewport::Fullscreen => Painter::new(system),
Viewport::Inline(_) => Painter::new(system).inline(),
};
self.full = true;
self.configured()
}
#[must_use]
pub fn console(mut self, console: Console) -> Surface<W> {
self.painter.set_color_system(console.color_system());
self.console = console;
self
}
#[must_use]
pub fn synchronized(mut self, on: bool) -> Surface<W> {
self.synchronized = on;
self
}
#[must_use]
pub fn graphics(mut self, source: Option<Arc<dyn PlacementSource>>) -> Surface<W> {
self.graphics = source;
self
}
pub fn area(&self) -> Rect {
self.screen.area()
}
pub fn screen(&self) -> &Screen {
&self.screen
}
pub fn writer(&self) -> &W {
&self.writer
}
pub fn writer_mut(&mut self) -> &mut W {
&mut self.writer
}
pub fn resize(&mut self, width: u16, height: u16) {
let height = match self.viewport {
Viewport::Fullscreen => height,
Viewport::Inline(rows) => rows.min(height.max(1)),
};
if (width, height) == (self.screen.area().width, self.screen.area().height) {
return;
}
let mut screen = Screen::new(width, height);
let old = self.screen.area();
screen.blit(&self.screen, old, 0, 0);
self.screen = screen;
self.full = true;
}
pub fn clear(&mut self) {
let area = self.screen.area();
self.screen.clear(area);
self.full = true;
}
pub fn draw(&mut self, f: impl FnOnce(&mut Drawing<'_>)) -> io::Result<()> {
let mut drawing = Drawing {
screen: &mut self.screen,
console: &self.console,
graphics: self.graphics.as_deref(),
damage: Vec::new(),
cursor: None,
};
f(&mut drawing);
let Drawing { damage, cursor, .. } = drawing;
let out = self.frame(damage, cursor);
self.writer.write_all(out.as_bytes())?;
self.writer.flush()
}
fn frame(&mut self, damage: Vec<Rect>, cursor: Option<(u16, u16)>) -> String {
let mut out = String::new();
if !self.started {
if self.viewport == Viewport::Fullscreen {
out.push_str("\x1b[?1049h");
}
self.started = true;
}
let full = std::mem::take(&mut self.full);
if full {
self.painter.invalidate();
}
let damage = if full {
vec![self.screen.area()]
} else {
damage
};
let fixed: Vec<Rect> = self
.placements
.iter()
.map(|p| Rect::new(p.column as u16, p.row as u16, p.cols as u16, p.rows as u16))
.collect();
self.painter.set_fixed(&fixed);
let painted = self.painter.paint(&self.screen, &damage);
let written = self.painter.written().to_vec();
if self.cursor_shown && !painted.is_empty() {
out.push_str("\x1b[?25l");
self.cursor_shown = false;
}
out.push_str(&painted);
if self.graphics.is_some() {
out.push_str(&self.draw_graphics(&written));
}
let area = self.screen.area();
match cursor.filter(|&(x, y)| x < area.width && y < area.height) {
Some((x, y)) => {
out.push_str(&self.painter.move_to(x, y));
if !self.cursor_shown {
out.push_str("\x1b[?25h");
self.cursor_shown = true;
}
}
None => {
if self.cursor_shown {
out.push_str("\x1b[?25l");
self.cursor_shown = false;
}
}
}
self.painter.compact(&mut self.screen);
if self.synchronized && !out.is_empty() {
out = format!("{BEGIN_SYNCHRONIZED_UPDATE}{out}{END_SYNCHRONIZED_UPDATE}");
}
out
}
fn draw_graphics(&mut self, written: &[Rect]) -> String {
let Some(source) = self.graphics.clone() else {
return String::new();
};
let frame = screen_frame(&self.screen);
let height = frame.height();
let next: Vec<Placement> = source
.placements(&frame)
.into_iter()
.filter(|p| p.row + p.rows <= height)
.collect();
let changes: Vec<Change> = written
.iter()
.flat_map(|rect| {
(rect.y..rect.bottom()).map(move |row| Change {
row: row as usize,
columns: rect.x as usize..rect.right() as usize,
})
})
.collect();
let plan = plan_graphics(&self.placements, &next, &changes);
let mut out = String::new();
let repaint: Vec<Rect> = plan
.repaint
.iter()
.map(|(row, columns)| {
Rect::new(
columns.start as u16,
*row as u16,
(columns.end - columns.start) as u16,
1,
)
})
.collect();
if !repaint.is_empty() {
out.push_str(&self.painter.repaint(&self.screen, &repaint));
}
for placement in &plan.draw {
out.push_str(
&self
.painter
.move_to(placement.column as u16, placement.row as u16),
);
out.push_str(&placement.graphic.draw(placement.frame));
}
self.placements = next;
out
}
pub fn finish(&mut self) -> io::Result<()> {
if self.finished || !self.started {
self.finished = true;
return Ok(());
}
let mut out = String::new();
if let Some(source) = &self.graphics {
let retained = match self.viewport {
Viewport::Inline(_) => self.placements.clone(),
Viewport::Fullscreen => Vec::new(),
};
out.push_str(&source.release(&retained));
}
out.push_str(&self.painter.finish());
out.push_str("\x1b[0m\x1b[?25h");
if self.viewport == Viewport::Fullscreen {
out.push_str("\x1b[?1049l");
}
self.writer.write_all(out.as_bytes())?;
self.writer.flush()?;
self.finished = true;
Ok(())
}
}
impl<W: Write> Drop for Surface<W> {
fn drop(&mut self) {
let _ = self.finish();
}
}
pub struct Drawing<'a> {
screen: &'a mut Screen,
console: &'a Console,
graphics: Option<&'a dyn PlacementSource>,
damage: Vec<Rect>,
cursor: Option<(u16, u16)>,
}
impl Drawing<'_> {
pub fn area(&self) -> Rect {
self.screen.area()
}
pub fn console(&self) -> &Console {
self.console
}
pub fn render(&mut self, rect: Rect, renderable: &dyn Renderable) {
let options = self
.console
.options()
.update_width(rect.width.max(1) as usize)
.update_height(rect.height.max(1) as usize);
let lines = self.console.render_lines(renderable, &options, false);
self.write_lines(rect, lines);
}
pub fn draw(&mut self, rect: Rect, drawable: &dyn Draw) {
if self.graphics.is_some() {
return self.render(rect, drawable);
}
let options = self
.console
.options()
.update_width(rect.width.max(1) as usize)
.update_height(rect.height.max(1) as usize);
drawable.draw(self.console, &options, self.screen, rect);
self.damage.push(rect);
}
pub fn write_lines(&mut self, rect: Rect, lines: Vec<Vec<Segment>>) {
let lines: Vec<Vec<Segment>> = match self.graphics {
Some(source) => lines.into_iter().map(|l| source.prepare(l)).collect(),
None => lines,
};
self.screen.write_lines(rect, &lines);
self.damage.push(rect);
}
pub fn clear(&mut self, rect: Rect) {
self.screen.clear(rect);
self.damage.push(rect);
}
pub fn set_cursor(&mut self, x: u16, y: u16) {
self.cursor = Some((x, y));
}
pub fn screen_mut(&mut self) -> &mut Screen {
let area = self.screen.area();
self.damage.push(area);
self.screen
}
}
fn screen_frame(screen: &Screen) -> Frame {
let mut segments = Vec::new();
for line in screen.lines() {
segments.extend(line);
segments.push(Segment::line());
}
Frame::from_segments(&segments)
}
fn clamp(cells: usize) -> u16 {
cells.clamp(1, u16::MAX as usize) as u16
}
#[cfg(test)]
mod tests {
use super::*;
use rich::Text;
fn output(surface: &mut Surface<Vec<u8>>) -> String {
String::from_utf8(std::mem::take(surface.writer_mut())).unwrap()
}
fn painting(strategy: PaintStrategy, tmux: bool, width: u16, height: u16) -> Surface<Vec<u8>> {
let mut surface = Surface::new(Vec::new(), width, height);
surface.under_tmux = tmux;
surface.paint_strategy(strategy)
}
fn scrolls(out: &str) -> bool {
let bytes = out.as_bytes();
bytes.iter().enumerate().any(|(i, &b)| {
if b != 0x1b || bytes.get(i + 1) != Some(&b'[') {
return false;
}
let rest = &bytes[i + 2..];
let digits = rest
.iter()
.take_while(|c| c.is_ascii_digit() || **c == b';')
.count();
matches!(rest.get(digits), Some(b'S' | b'T' | b'r'))
})
}
fn differences(a: &vt100::Screen, b: &vt100::Screen, width: u16, height: u16) -> Vec<String> {
let mut found = Vec::new();
for row in 0..height {
for col in 0..width {
let (Some(x), Some(y)) = (a.cell(row, col), b.cell(row, col)) else {
continue;
};
let look = |c: &vt100::Cell| {
(
match c.contents() {
"" => " ".to_string(),
text => text.to_string(),
},
c.fgcolor(),
c.bgcolor(),
c.bold(),
c.italic(),
c.underline(),
c.inverse(),
)
};
if look(x) != look(y) {
found.push(format!("({col}, {row}): {:?} != {:?}", look(x), look(y)));
}
}
}
found
}
fn best_matches_full(frames: &[Vec<String>], width: u16, height: u16, tmux: bool) -> String {
let mut best = painting(PaintStrategy::Best, tmux, width, height);
let mut full = painting(PaintStrategy::Full, tmux, width, height);
let mut best_term = vt100::Parser::new(height, width, 0);
let mut full_term = vt100::Parser::new(height, width, 0);
let mut later = String::new();
for (n, lines) in frames.iter().enumerate() {
for (is_best, surface, term) in [
(true, &mut best, &mut best_term),
(false, &mut full, &mut full_term),
] {
surface
.draw(|s| {
for (y, line) in lines.iter().enumerate() {
let text = Text::from_markup(line).unwrap();
s.render(Rect::new(0, y as u16, width, 1), &text);
}
})
.unwrap();
term.process(surface.writer());
if n > 0 && is_best {
later.push_str(std::str::from_utf8(surface.writer()).unwrap());
}
surface.writer_mut().clear();
}
let wrong = differences(best_term.screen(), full_term.screen(), width, height);
assert!(wrong.is_empty(), "frame {n}: {wrong:?}");
}
later
}
fn scrolling_log(frames: usize, rows: usize) -> Vec<Vec<String>> {
const STYLES: [&str; 4] = ["red", "bold green", "italic", "on blue"];
(0..frames)
.map(|frame| {
(frame..frame + rows)
.map(|i| format!("[{}]entry {i}[/] of the log", STYLES[i % STYLES.len()]))
.collect()
})
.collect()
}
#[test]
fn surfaces_paint_with_best_by_default() {
if PaintStrategy::from_env().is_none() {
let surface = Surface::new(Vec::new(), 4, 1);
assert_eq!(surface.painter.strategy(), PaintStrategy::Best);
}
let surface = Surface::new(Vec::new(), 4, 1).paint_strategy(PaintStrategy::Rows);
assert_eq!(surface.painter.strategy(), PaintStrategy::Rows);
let surface = surface.viewport(Viewport::Inline(1));
assert_eq!(surface.painter.strategy(), PaintStrategy::Rows);
}
#[test]
fn best_replayed_equals_a_full_repaint_when_rows_scroll() {
let later = best_matches_full(&scrolling_log(8, 6), 30, 6, false);
if cfg!(windows) {
assert!(!scrolls(&later), "{later:?}");
} else {
assert!(scrolls(&later), "best should have scrolled: {later:?}");
}
}
#[test]
fn best_replayed_equals_a_full_repaint_when_nothing_scrolls() {
let frames: Vec<Vec<String>> = (0..6)
.map(|t| {
vec![
"[bold]a clock[/]".to_string(),
format!("tick [cyan]{t}[/] of [red]{}[/]", 10 - t),
"#".repeat(t + 1),
]
})
.collect();
let later = best_matches_full(&frames, 20, 3, false);
assert!(!scrolls(&later), "{later:?}");
}
#[test]
fn under_tmux_best_does_not_scroll() {
let later = best_matches_full(&scrolling_log(8, 6), 30, 6, true);
assert!(!scrolls(&later), "{later:?}");
let mut surface =
painting(PaintStrategy::Best, true, 30, 6).terminal_scroll(!cfg!(windows));
let mut out = String::new();
for (n, lines) in scrolling_log(4, 6).iter().enumerate() {
surface
.draw(|s| {
for (y, line) in lines.iter().enumerate() {
let text = Text::from_markup(line).unwrap();
s.render(Rect::new(0, y as u16, 30, 1), &text);
}
})
.unwrap();
let frame = output(&mut surface);
if n > 0 {
out.push_str(&frame);
}
}
assert_eq!(scrolls(&out), !cfg!(windows), "{out:?}");
}
#[test]
fn an_inline_surface_does_not_scroll() {
let mut surface = painting(PaintStrategy::Best, false, 30, 6)
.viewport(Viewport::Inline(6))
.terminal_scroll(true);
let mut out = String::new();
for (n, lines) in scrolling_log(4, 6).iter().enumerate() {
surface
.draw(|s| {
for (y, line) in lines.iter().enumerate() {
let text = Text::from_markup(line).unwrap();
s.render(Rect::new(0, y as u16, 30, 1), &text);
}
})
.unwrap();
let frame = output(&mut surface);
if n > 0 {
out.push_str(&frame);
}
}
assert!(!scrolls(&out), "{out:?}");
}
#[test]
fn the_first_draw_enters_the_alternate_screen_and_writes_everything() {
let mut surface = Surface::new(Vec::new(), 10, 2);
surface
.draw(|s| s.render(Rect::new(0, 0, 10, 1), &Text::new("hello")))
.unwrap();
let out = output(&mut surface);
assert!(out.starts_with("\x1b[?1049h\x1b[?25l"), "{out:?}");
assert!(out.contains("\x1b[2J") && out.contains("hello"), "{out:?}");
surface.finish().unwrap();
let out = output(&mut surface);
assert!(out.ends_with("\x1b[0m\x1b[?25h\x1b[?1049l"), "{out:?}");
}
#[test]
fn an_unchanged_frame_writes_nothing() {
let mut surface = Surface::new(Vec::new(), 10, 2);
let draw = |s: &mut Drawing<'_>| s.render(Rect::new(0, 0, 10, 1), &Text::new("same"));
surface.draw(draw).unwrap();
output(&mut surface);
surface.draw(draw).unwrap();
assert_eq!(output(&mut surface), "");
}
#[test]
fn a_resize_keeps_the_cells_and_writes_everything_once() {
let mut surface = Surface::new(Vec::new(), 10, 2);
surface
.draw(|s| s.render(Rect::new(0, 0, 10, 1), &Text::new("kept")))
.unwrap();
output(&mut surface);
surface.resize(6, 3);
assert_eq!(surface.screen().plain()[0], "kept ");
surface.draw(|_| {}).unwrap();
let out = output(&mut surface);
assert!(out.contains("\x1b[2J") && out.contains("kept"), "{out:?}");
surface.draw(|_| {}).unwrap();
assert_eq!(output(&mut surface), "");
}
#[test]
fn inline_draws_below_the_cursor_and_leaves_the_frame_on_finish() {
let mut surface = Surface::new(Vec::new(), 8, 24).viewport(Viewport::Inline(2));
assert_eq!(surface.area(), Rect::new(0, 0, 8, 2));
surface
.draw(|s| s.render(s.area(), &Text::new("one\ntwo")))
.unwrap();
let out = output(&mut surface);
assert!(!out.contains("\x1b[?1049h"), "{out:?}");
assert!(!out.contains("\x1b[2J"), "{out:?}");
assert!(out.contains("one") && out.contains("two"), "{out:?}");
surface.finish().unwrap();
let out = output(&mut surface);
assert!(out.ends_with("\r\n\x1b[0m\x1b[?25h"), "{out:?}");
}
#[test]
fn the_cursor_shows_only_where_a_frame_puts_it() {
let mut surface = Surface::new(Vec::new(), 10, 2);
surface
.draw(|s| {
s.render(Rect::new(0, 0, 10, 1), &Text::new("> "));
s.set_cursor(2, 0);
})
.unwrap();
let out = output(&mut surface);
assert!(out.ends_with("\x1b[1;3H\x1b[?25h"), "{out:?}");
surface.draw(|_| {}).unwrap();
assert_eq!(output(&mut surface), "\x1b[?25l");
}
#[test]
fn a_synchronized_surface_wraps_each_frame() {
let mut surface = Surface::new(Vec::new(), 10, 1).synchronized(true);
surface
.draw(|s| s.render(s.area(), &Text::new("x")))
.unwrap();
let out = output(&mut surface);
assert!(out.starts_with(BEGIN_SYNCHRONIZED_UPDATE), "{out:?}");
assert!(out.ends_with(END_SYNCHRONIZED_UPDATE), "{out:?}");
surface.draw(|_| {}).unwrap();
assert_eq!(output(&mut surface), "", "nothing to write, no wrapper");
}
#[test]
fn controls_in_rendered_text_become_visible() {
let mut surface = Surface::new(Vec::new(), 10, 1);
surface
.draw(|s| s.render(s.area(), &Text::new("a\x1b[2Jb")))
.unwrap();
assert_eq!(surface.screen().plain()[0], "a␛[2Jb ");
}
struct Flaky {
fails: usize,
out: Vec<u8>,
}
impl Write for Flaky {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
if self.fails > 0 {
self.fails -= 1;
return Err(io::Error::other("full"));
}
self.out.write(buf)
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
#[test]
fn a_failed_finish_can_be_retried() {
let mut surface = Surface::new(
Flaky {
fails: 0,
out: Vec::new(),
},
4,
1,
);
surface
.draw(|s| s.render(s.area(), &Text::new("x")))
.unwrap();
surface.writer_mut().fails = 1;
assert!(surface.finish().is_err());
surface.finish().unwrap();
let out = String::from_utf8(surface.writer().out.clone()).unwrap();
assert!(out.ends_with("\x1b[?1049l"), "{out:?}");
}
#[derive(Debug)]
struct Star;
impl crate::frame::Graphic for Star {
fn key(&self) -> u64 {
1
}
fn draw(&self, _frame: usize) -> String {
"<star>".into()
}
}
struct Moving(std::sync::Mutex<usize>);
impl PlacementSource for Moving {
fn placements(&self, _frame: &Frame) -> Vec<Placement> {
vec![Placement {
row: 0,
column: *self.0.lock().unwrap(),
cols: 1,
rows: 1,
graphic: Arc::new(Star),
frame: 0,
}]
}
}
#[test]
fn a_graphic_that_moves_off_unchanged_cells_leaves_no_ghost() {
let source = Arc::new(Moving(std::sync::Mutex::new(1)));
let mut surface = Surface::new(Vec::new(), 6, 1)
.graphics(Some(source.clone() as Arc<dyn PlacementSource>));
let draw = |s: &mut Drawing<'_>| s.render(s.area(), &Text::new("abcdef"));
surface.draw(draw).unwrap();
output(&mut surface);
*source.0.lock().unwrap() = 3;
surface.draw(draw).unwrap();
let out = output(&mut surface);
assert_eq!(out, "\x1b[1;2H\x1b[0mb\x1b[1;4H<star>", "{out:?}");
}
#[test]
fn dropping_a_started_surface_gives_the_terminal_back() {
let mut sink = Vec::new();
{
let mut surface = Surface::new(&mut sink, 4, 1);
surface
.draw(|s| s.render(s.area(), &Text::new("x")))
.unwrap();
}
let out = String::from_utf8(sink).unwrap();
assert!(out.ends_with("\x1b[?1049l"), "{out:?}");
}
}