use crate::core::buffer::Buffer;
use crate::core::rect::Rect;
use crate::sanitize::char_display_width;
use crate::widgets::Widget;
use crossterm::{
cursor,
event::{
self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
Event,
},
execute, terminal,
};
use std::fmt;
use std::io::{self, Stdout, Write};
use std::time::{Duration, Instant};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FrameTiming {
pub elapsed: Duration,
pub bytes_written: usize,
pub dirty_cells: usize,
pub areas: usize,
pub full_repaint: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FrameDiagnostic {
pub name: String,
pub area: Option<Rect>,
pub elapsed: Duration,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PresentStrategy {
Diff,
Full,
Areas(Vec<Rect>),
DirtyBounds,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TerminalOptions {
pub mouse_capture: bool,
pub bracketed_paste: bool,
pub alternate_screen: bool,
pub hide_cursor: bool,
}
impl TerminalOptions {
pub const fn new() -> Self {
Self {
mouse_capture: false,
bracketed_paste: false,
alternate_screen: true,
hide_cursor: true,
}
}
}
impl Default for TerminalOptions {
fn default() -> Self {
Self::new()
}
}
pub struct Frame<'a> {
area: Rect,
buffer: &'a mut Buffer,
diagnostics: &'a mut Vec<FrameDiagnostic>,
frame_count: u64,
cursor_position: Option<(u16, u16)>,
}
impl<'a> Frame<'a> {
pub fn area(&self) -> Rect {
self.area
}
pub fn buffer(&mut self) -> &mut Buffer {
self.buffer
}
pub fn frame_count(&self) -> u64 {
self.frame_count
}
pub fn set_cursor_position(&mut self, x: u16, y: u16) {
self.cursor_position = Some((x, y));
}
pub fn cursor_position(&self) -> Option<(u16, u16)> {
self.cursor_position
}
pub fn render_widget<W>(&mut self, widget: W, area: Rect)
where
W: Widget,
{
widget.render(&mut *self.buffer, area);
}
pub fn render_widget_timed<W>(&mut self, name: impl Into<String>, widget: W, area: Rect)
where
W: Widget,
{
let start = Instant::now();
widget.render(&mut *self.buffer, area);
self.diagnostics.push(FrameDiagnostic {
name: name.into(),
area: Some(area),
elapsed: start.elapsed(),
});
}
pub fn time_named<R>(&mut self, name: impl Into<String>, f: impl FnOnce(&mut Self) -> R) -> R {
let start = Instant::now();
let result = f(self);
self.diagnostics.push(FrameDiagnostic {
name: name.into(),
area: None,
elapsed: start.elapsed(),
});
result
}
pub fn time_pane<R>(
&mut self,
name: impl Into<String>,
area: Rect,
f: impl FnOnce(&mut Self) -> R,
) -> R {
let start = Instant::now();
let result = f(self);
self.diagnostics.push(FrameDiagnostic {
name: name.into(),
area: Some(area),
elapsed: start.elapsed(),
});
result
}
pub fn diagnostics(&self) -> &[FrameDiagnostic] {
self.diagnostics
}
}
pub struct Terminal {
stdout: Stdout,
width: u16,
height: u16,
front_buffer: Buffer,
back_buffer: Buffer,
hidden_cursor: bool,
mouse_capture: bool,
bracketed_paste: bool,
alternate_screen: bool,
restored: bool,
frame_count: u64,
cursor_position: Option<(u16, u16)>,
last_frame_timing: Option<FrameTiming>,
last_frame_diagnostics: Vec<FrameDiagnostic>,
frame_timing_hook: Option<Box<dyn FnMut(FrameTiming) + Send + 'static>>,
}
impl fmt::Debug for Terminal {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Terminal")
.field("width", &self.width)
.field("height", &self.height)
.field("hidden_cursor", &self.hidden_cursor)
.field("mouse_capture", &self.mouse_capture)
.field("bracketed_paste", &self.bracketed_paste)
.field("alternate_screen", &self.alternate_screen)
.field("restored", &self.restored)
.field("frame_count", &self.frame_count)
.field("cursor_position", &self.cursor_position)
.field("last_frame_timing", &self.last_frame_timing)
.field("last_frame_diagnostics", &self.last_frame_diagnostics)
.finish()
}
}
impl Terminal {
pub fn init() -> io::Result<Self> {
Self::init_with(TerminalOptions::default())
}
pub fn init_with(options: TerminalOptions) -> io::Result<Self> {
let mut stdout = io::stdout();
terminal::enable_raw_mode()?;
if options.alternate_screen {
execute!(stdout, terminal::EnterAlternateScreen)?;
}
if options.hide_cursor {
execute!(stdout, cursor::Hide)?;
}
if options.mouse_capture {
execute!(stdout, EnableMouseCapture)?;
}
if options.bracketed_paste {
execute!(stdout, EnableBracketedPaste)?;
}
let (w, h) = terminal::size()?;
let front = Buffer::new(w as usize, h as usize);
let back = Buffer::new(w as usize, h as usize);
Ok(Self {
stdout,
width: w,
height: h,
front_buffer: front,
back_buffer: back,
hidden_cursor: options.hide_cursor,
mouse_capture: options.mouse_capture,
bracketed_paste: options.bracketed_paste,
alternate_screen: options.alternate_screen,
restored: false,
frame_count: 0,
cursor_position: None,
last_frame_timing: None,
last_frame_diagnostics: Vec::new(),
frame_timing_hook: None,
})
}
pub fn restore(&mut self) -> io::Result<()> {
if self.restored {
return Ok(());
}
if self.bracketed_paste {
execute!(self.stdout, DisableBracketedPaste)?;
self.bracketed_paste = false;
}
if self.mouse_capture {
execute!(self.stdout, DisableMouseCapture)?;
self.mouse_capture = false;
}
if self.hidden_cursor {
execute!(self.stdout, cursor::Show)?;
self.hidden_cursor = false;
}
if self.alternate_screen {
execute!(self.stdout, terminal::LeaveAlternateScreen)?;
self.alternate_screen = false;
}
terminal::disable_raw_mode()?;
self.restored = true;
Ok(())
}
pub fn size(&self) -> Rect {
Rect::new(0, 0, self.width, self.height)
}
pub fn width(&self) -> usize {
self.width as usize
}
pub fn height(&self) -> usize {
self.height as usize
}
pub fn back_buffer(&mut self) -> &mut Buffer {
&mut self.back_buffer
}
pub fn front_buffer(&self) -> &Buffer {
&self.front_buffer
}
pub fn dirty_bounds(&self) -> Option<Rect> {
self.front_buffer.changed_bounds(&self.back_buffer)
}
pub fn last_frame_timing(&self) -> Option<FrameTiming> {
self.last_frame_timing
}
pub fn last_frame_diagnostics(&self) -> &[FrameDiagnostic] {
&self.last_frame_diagnostics
}
pub fn set_frame_timing_hook<F>(&mut self, hook: F)
where
F: FnMut(FrameTiming) + Send + 'static,
{
self.frame_timing_hook = Some(Box::new(hook));
}
pub fn clear_frame_timing_hook(&mut self) {
self.frame_timing_hook = None;
}
fn record_frame_timing(&mut self, timing: FrameTiming) {
self.last_frame_timing = Some(timing);
if let Some(hook) = self.frame_timing_hook.as_mut() {
hook(timing);
}
}
pub fn draw<F>(&mut self, f: F) -> io::Result<()>
where
F: FnOnce(&mut Frame),
{
self.draw_timed(f).map(|_| ())
}
pub fn draw_timed<F>(&mut self, f: F) -> io::Result<FrameTiming>
where
F: FnOnce(&mut Frame),
{
self.draw_with_present_strategy(PresentStrategy::Diff, f)
}
pub fn draw_areas_timed<F>(&mut self, areas: &[Rect], f: F) -> io::Result<FrameTiming>
where
F: FnOnce(&mut Frame),
{
self.draw_with_present_strategy(PresentStrategy::Areas(areas.to_vec()), f)
}
pub fn draw_with_present_strategy<F>(
&mut self,
strategy: PresentStrategy,
f: F,
) -> io::Result<FrameTiming>
where
F: FnOnce(&mut Frame),
{
let start = Instant::now();
self.size_changed()?;
let area_limited_draw = matches!(&strategy, PresentStrategy::Areas(_));
match &strategy {
PresentStrategy::Areas(areas) => {
for area in areas {
self.clear_area(*area);
}
}
_ => self.clear(),
}
let mut diagnostics = Vec::new();
{
let mut frame = Frame {
area: self.size(),
buffer: &mut self.back_buffer,
diagnostics: &mut diagnostics,
frame_count: self.frame_count,
cursor_position: None,
};
f(&mut frame);
self.cursor_position = frame.cursor_position;
}
self.last_frame_diagnostics = diagnostics;
let mut copied_areas: Option<Vec<Rect>> = None;
let output = match strategy {
PresentStrategy::Diff => diff_output(
&self.front_buffer,
&self.back_buffer,
self.width,
self.height,
None,
false,
),
PresentStrategy::Full => PresentOutput {
ansi: self.back_buffer.to_ansi_string(),
dirty_cells: self.width as usize * self.height as usize,
areas: 1,
full_repaint: true,
},
PresentStrategy::Areas(areas) => {
copied_areas = Some(areas);
diff_output(
&self.front_buffer,
&self.back_buffer,
self.width,
self.height,
copied_areas.as_deref(),
false,
)
}
PresentStrategy::DirtyBounds => {
let areas = self.dirty_bounds().into_iter().collect::<Vec<_>>();
copied_areas = Some(areas);
diff_output(
&self.front_buffer,
&self.back_buffer,
self.width,
self.height,
copied_areas.as_deref(),
false,
)
}
};
let mut timing = self.write_present_output(output, start, copied_areas.as_deref())?;
if area_limited_draw {
self.back_buffer = self.front_buffer.clone();
}
if let Some((x, y)) = self.cursor_position {
self.set_cursor(x, y)?;
}
timing.elapsed = start.elapsed();
self.frame_count = self.frame_count.wrapping_add(1);
self.record_frame_timing(timing);
Ok(timing)
}
pub fn present(&mut self) -> io::Result<()> {
self.present_timed().map(|_| ())
}
pub fn present_timed(&mut self) -> io::Result<FrameTiming> {
let start = Instant::now();
let output = diff_output(
&self.front_buffer,
&self.back_buffer,
self.width,
self.height,
None,
false,
);
let timing = self.write_present_output(output, start, None)?;
self.record_frame_timing(timing);
Ok(timing)
}
pub fn present_full(&mut self) -> io::Result<()> {
self.present_full_timed().map(|_| ())
}
pub fn present_full_timed(&mut self) -> io::Result<FrameTiming> {
let start = Instant::now();
let output = PresentOutput {
ansi: self.back_buffer.to_ansi_string(),
dirty_cells: self.width as usize * self.height as usize,
areas: 1,
full_repaint: true,
};
let timing = self.write_present_output(output, start, None)?;
self.record_frame_timing(timing);
Ok(timing)
}
pub fn present_area(&mut self, area: Rect) -> io::Result<()> {
self.present_areas(&[area])
}
pub fn present_area_timed(&mut self, area: Rect) -> io::Result<FrameTiming> {
self.present_areas_timed(&[area])
}
pub fn present_areas(&mut self, areas: &[Rect]) -> io::Result<()> {
self.present_areas_timed(areas).map(|_| ())
}
pub fn present_areas_timed(&mut self, areas: &[Rect]) -> io::Result<FrameTiming> {
let start = Instant::now();
let output = diff_output(
&self.front_buffer,
&self.back_buffer,
self.width,
self.height,
Some(areas),
false,
);
let timing = self.write_present_output(output, start, Some(areas))?;
self.record_frame_timing(timing);
Ok(timing)
}
pub fn draw_full<F>(&mut self, f: F) -> io::Result<()>
where
F: FnOnce(&mut Frame),
{
self.draw_full_timed(f).map(|_| ())
}
pub fn draw_full_timed<F>(&mut self, f: F) -> io::Result<FrameTiming>
where
F: FnOnce(&mut Frame),
{
self.draw_with_present_strategy(PresentStrategy::Full, f)
}
fn write_present_output(
&mut self,
output: PresentOutput,
start: Instant,
copied_areas: Option<&[Rect]>,
) -> io::Result<FrameTiming> {
let bytes_written = output.ansi.len();
write!(self.stdout, "{}", output.ansi)?;
self.stdout.flush()?;
if let Some(areas) = copied_areas {
for area in areas {
self.front_buffer.copy_area_from(&self.back_buffer, *area);
}
} else {
self.front_buffer = self.back_buffer.clone();
}
Ok(FrameTiming {
elapsed: start.elapsed(),
bytes_written,
dirty_cells: output.dirty_cells,
areas: output.areas,
full_repaint: output.full_repaint,
})
}
pub fn clear(&mut self) {
self.back_buffer = Buffer::new(self.width as usize, self.height as usize);
}
pub fn clear_area(&mut self, area: Rect) {
self.back_buffer.clear(area);
}
pub fn hide_cursor(&mut self) -> io::Result<()> {
execute!(self.stdout, cursor::Hide)?;
self.hidden_cursor = true;
Ok(())
}
pub fn show_cursor(&mut self) -> io::Result<()> {
execute!(self.stdout, cursor::Show)?;
self.hidden_cursor = false;
Ok(())
}
pub fn set_cursor(&mut self, x: u16, y: u16) -> io::Result<()> {
execute!(self.stdout, cursor::MoveTo(x, y))?;
Ok(())
}
pub fn set_mouse_capture(&mut self, enabled: bool) -> io::Result<()> {
if enabled == self.mouse_capture {
return Ok(());
}
if enabled {
execute!(self.stdout, EnableMouseCapture)?;
} else {
execute!(self.stdout, DisableMouseCapture)?;
}
self.mouse_capture = enabled;
Ok(())
}
pub fn set_bracketed_paste(&mut self, enabled: bool) -> io::Result<()> {
if enabled == self.bracketed_paste {
return Ok(());
}
if enabled {
execute!(self.stdout, EnableBracketedPaste)?;
} else {
execute!(self.stdout, DisableBracketedPaste)?;
}
self.bracketed_paste = enabled;
Ok(())
}
pub fn poll_event(&self) -> io::Result<Option<Event>> {
if event::poll(std::time::Duration::from_millis(0))? {
Ok(Some(event::read()?))
} else {
Ok(None)
}
}
pub fn wait_event(&self) -> io::Result<Event> {
event::read()
}
pub fn flush(&mut self) -> io::Result<()> {
self.stdout.flush()
}
pub fn size_changed(&mut self) -> io::Result<bool> {
let (w, h) = terminal::size()?;
if w != self.width || h != self.height {
self.width = w;
self.height = h;
self.front_buffer.resize(w as usize, h as usize);
self.back_buffer.resize(w as usize, h as usize);
Ok(true)
} else {
Ok(false)
}
}
pub fn raw_output(&self) -> &Stdout {
&self.stdout
}
}
impl Drop for Terminal {
fn drop(&mut self) {
let _ = self.restore();
}
}
pub fn terminal_size() -> (u16, u16) {
terminal::size().unwrap_or((80, 24))
}
struct PresentOutput {
ansi: String,
dirty_cells: usize,
areas: usize,
full_repaint: bool,
}
#[cfg(test)]
fn diff_ansi_string(front: &Buffer, back: &Buffer, width: u16, height: u16) -> String {
diff_output(front, back, width, height, None, false).ansi
}
fn diff_output(
front: &Buffer,
back: &Buffer,
width: u16,
height: u16,
areas: Option<&[Rect]>,
full_repaint: bool,
) -> PresentOutput {
let mut output = String::with_capacity(width as usize * height as usize * 8);
let mut last_fg = crate::core::color::Color::BLACK;
let mut last_bg: Option<crate::core::color::Color> = None;
let mut last_bold = false;
let mut last_italic = false;
let mut last_underlined = false;
let mut dirty_cells = 0usize;
for y in 0..height as usize {
let mut cursor_col: Option<usize> = None;
for x in 0..width as usize {
if let Some(areas) = areas {
if !areas.iter().any(|area| rect_contains(*area, x, y)) {
continue;
}
}
if back.is_skip(x, y) {
continue;
}
let Some(back_cell) = back.get(x, y) else {
continue;
};
let front_cell = front.get(x, y);
let same_cell =
front_cell == Some(back_cell) && front.is_skip(x, y) == back.is_skip(x, y);
if same_cell {
continue;
}
dirty_cells += 1;
if cursor_col != Some(x) {
output.push_str(&format!("\x1b[{};{}H", y + 1, x + 1));
}
if back_cell.fg != last_fg {
output.push_str(&back_cell.fg.to_ansi_fg());
last_fg = back_cell.fg;
}
if back_cell.bg != last_bg {
match back_cell.bg {
Some(c) => output.push_str(&c.to_ansi_bg()),
None => output.push_str("\x1b[49m"),
}
last_bg = back_cell.bg;
}
if back_cell.bold != last_bold {
output.push_str(if back_cell.bold {
"\x1b[1m"
} else {
"\x1b[22m"
});
last_bold = back_cell.bold;
}
if back_cell.italic != last_italic {
output.push_str(if back_cell.italic {
"\x1b[3m"
} else {
"\x1b[23m"
});
last_italic = back_cell.italic;
}
if back_cell.underlined != last_underlined {
output.push_str(if back_cell.underlined {
"\x1b[4m"
} else {
"\x1b[24m"
});
last_underlined = back_cell.underlined;
}
output.push(back_cell.ch);
cursor_col = Some(x + char_display_width(back_cell.ch).max(1));
}
}
output.push_str("\x1b[0m");
PresentOutput {
ansi: output,
dirty_cells,
areas: areas.map(|areas| areas.len()).unwrap_or(1),
full_repaint,
}
}
fn rect_contains(area: Rect, x: usize, y: usize) -> bool {
x >= area.x as usize
&& x < area.right() as usize
&& y >= area.y as usize
&& y < area.bottom() as usize
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn terminal_options_default_is_safe() {
let options = TerminalOptions::default();
assert!(options.alternate_screen);
assert!(options.hide_cursor);
assert!(!options.mouse_capture);
}
fn visible_text(ansi: &str) -> String {
let mut visible = String::new();
let mut chars = ansi.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\x1b' {
if chars.peek() == Some(&'[') {
chars.next();
for code in chars.by_ref() {
if code.is_ascii_alphabetic() {
break;
}
}
}
continue;
}
visible.push(ch);
}
visible
}
#[test]
fn diff_repositions_non_contiguous_dirty_cells() {
let front = Buffer::new(12, 1);
let mut back = Buffer::new(12, 1);
back.set(
8,
0,
crate::core::buffer::Cell::new('A', crate::core::color::Color::WHITE, None),
);
back.set(
10,
0,
crate::core::buffer::Cell::new('B', crate::core::color::Color::WHITE, None),
);
let ansi = diff_ansi_string(&front, &back, 12, 1);
assert!(ansi.contains("\x1b[1;9H"));
assert!(ansi.contains("\x1b[1;11H"));
}
#[test]
fn diff_clears_old_wide_glyph_continuation_cell() {
let mut front = Buffer::new(4, 1);
let mut back = Buffer::new(4, 1);
front.set(
0,
0,
crate::core::buffer::Cell::new('δΈ', crate::core::color::Color::WHITE, None),
);
back.set(
0,
0,
crate::core::buffer::Cell::new('A', crate::core::color::Color::WHITE, None),
);
let ansi = diff_ansi_string(&front, &back, 4, 1);
assert!(ansi.contains("\x1b[1;1H"));
assert_eq!(visible_text(&ansi), "A ");
}
#[test]
fn diff_can_limit_output_to_dirty_rectangles() {
let front = Buffer::new(8, 1);
let mut back = Buffer::new(8, 1);
back.set(
1,
0,
crate::core::buffer::Cell::new('A', crate::core::color::Color::WHITE, None),
);
back.set(
6,
0,
crate::core::buffer::Cell::new('B', crate::core::color::Color::WHITE, None),
);
let output = diff_output(&front, &back, 8, 1, Some(&[Rect::new(5, 0, 3, 1)]), false);
assert_eq!(output.dirty_cells, 1);
assert!(!output.ansi.contains("A"));
assert!(output.ansi.contains("B"));
}
#[test]
fn frame_records_named_widget_diagnostics() {
let mut buffer = Buffer::new(8, 3);
let mut diagnostics = Vec::new();
let mut frame = Frame {
area: Rect::new(0, 0, 8, 3),
buffer: &mut buffer,
diagnostics: &mut diagnostics,
frame_count: 0,
cursor_position: None,
};
frame.render_widget_timed(
"panel",
crate::widgets::block::Block::bordered().title("p"),
Rect::new(0, 0, 8, 3),
);
frame.time_pane("inner", Rect::new(1, 1, 6, 1), |_| {});
assert_eq!(frame.diagnostics().len(), 2);
assert_eq!(frame.diagnostics()[0].name, "panel");
assert_eq!(frame.diagnostics()[0].area, Some(Rect::new(0, 0, 8, 3)));
assert_eq!(frame.diagnostics()[1].name, "inner");
}
}