From 02213a25a8f0779662dfbbbca98990715177ecc5 Mon Sep 17 00:00:00 2001 From: Florian Dehau Date: Mon, 1 Nov 2021 18:41:05 +0100 Subject: [PATCH] feat!(terminal): inline viewport --- Cargo.toml | 6 + examples/inline.rs | 311 +++++++++++++++++++++++++++++++++++++++ src/backend/crossterm.rs | 79 +++++----- src/backend/mod.rs | 12 +- src/backend/termion.rs | 21 ++- src/backend/test.rs | 8 +- src/buffer.rs | 4 +- src/lib.rs | 2 +- src/terminal.rs | 274 +++++++++++++++++++++++++++------- tests/backend_termion.rs | 4 +- 10 files changed, 618 insertions(+), 103 deletions(-) create mode 100644 examples/inline.rs diff --git a/Cargo.toml b/Cargo.toml index 925985e..8608c15 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ edition = "2018" default = ["crossterm"] [dependencies] +tracing = "0.1" bitflags = "1.3" cassowary = "0.3" unicode-segmentation = "1.2" @@ -29,6 +30,7 @@ crossterm = { version = "0.22", optional = true } serde = { version = "1", optional = true, features = ["derive"]} [dev-dependencies] +tracing-subscriber = "0.2" rand = "0.8" argh = "0.1" @@ -87,3 +89,7 @@ required-features = ["crossterm"] [[example]] name = "user_input" required-features = ["crossterm"] + +[[example]] +name = "inline" +required-features = ["crossterm"] diff --git a/examples/inline.rs b/examples/inline.rs new file mode 100644 index 0000000..cfba81b --- /dev/null +++ b/examples/inline.rs @@ -0,0 +1,311 @@ +use rand::distributions::{Distribution, Uniform}; +use std::{ + collections::{BTreeMap, VecDeque}, + error::Error, + io, + sync::mpsc, + thread, + time::{Duration, Instant}, +}; +use tracing::{event, span, Level}; +use tui::{ + backend::{Backend, CrosstermBackend}, + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + symbols, + text::{Span, Spans}, + widgets::{Block, Gauge, LineGauge, List, ListItem, Paragraph, Widget}, + Frame, Terminal, TerminalOptions, ViewportVariant, +}; + +const NUM_DOWNLOADS: usize = 10; + +type DownloadId = usize; +type WorkerId = usize; + +enum Event { + Input(crossterm::event::KeyEvent), + Tick, + Resize, + DownloadUpdate(WorkerId, DownloadId, f64), + DownloadDone(WorkerId, DownloadId), +} + +struct Downloads { + pending: VecDeque, + in_progress: BTreeMap, +} + +impl Downloads { + fn next(&mut self, worker_id: WorkerId) -> Option { + match self.pending.pop_front() { + Some(d) => { + self.in_progress.insert( + worker_id, + DownloadInProgress { + id: d.id, + started_at: Instant::now(), + progress: 0.0, + }, + ); + Some(d) + } + None => None, + } + } +} + +struct DownloadInProgress { + id: DownloadId, + started_at: Instant, + progress: f64, +} + +struct Download { + id: DownloadId, + size: usize, +} + +struct Worker { + id: WorkerId, + tx: mpsc::Sender, +} + +fn main() -> Result<(), Box> { + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .with_writer(io::stderr) + .init(); + + crossterm::terminal::enable_raw_mode()?; + let stdout = io::stdout(); + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::with_options( + backend, + TerminalOptions { + viewport: ViewportVariant::Inline(8), + }, + )?; + + let (tx, rx) = mpsc::channel(); + input_handling(tx.clone()); + let workers = workers(tx); + let mut downloads = downloads(); + + for w in &workers { + let d = downloads.next(w.id).unwrap(); + w.tx.send(d).unwrap(); + } + + run_app(&mut terminal, workers, downloads, rx)?; + + crossterm::terminal::disable_raw_mode()?; + terminal.clear()?; + + Ok(()) +} + +fn input_handling(tx: mpsc::Sender) { + let tick_rate = Duration::from_millis(200); + thread::spawn(move || { + let mut last_tick = Instant::now(); + loop { + // poll for tick rate duration, if no events, sent tick event. + let timeout = tick_rate + .checked_sub(last_tick.elapsed()) + .unwrap_or_else(|| Duration::from_secs(0)); + if crossterm::event::poll(timeout).unwrap() { + match crossterm::event::read().unwrap() { + crossterm::event::Event::Key(key) => tx.send(Event::Input(key)).unwrap(), + crossterm::event::Event::Resize(_, _) => tx.send(Event::Resize).unwrap(), + _ => {} + }; + } + if last_tick.elapsed() >= tick_rate { + tx.send(Event::Tick).unwrap(); + last_tick = Instant::now(); + } + } + }); +} + +fn workers(tx: mpsc::Sender) -> Vec { + (0..4) + .map(|id| { + let (worker_tx, worker_rx) = mpsc::channel::(); + let tx = tx.clone(); + thread::spawn(move || { + while let Ok(download) = worker_rx.recv() { + let mut remaining = download.size; + while remaining > 0 { + let wait = (remaining as u64).min(10); + thread::sleep(Duration::from_millis(wait * 10)); + remaining = remaining.saturating_sub(10); + let progress = (download.size - remaining) * 100 / download.size; + tx.send(Event::DownloadUpdate(id, download.id, progress as f64)) + .unwrap(); + } + tx.send(Event::DownloadDone(id, download.id)).unwrap(); + } + }); + Worker { id, tx: worker_tx } + }) + .collect() +} + +fn downloads() -> Downloads { + let distribution = Uniform::new(0, 1000); + let mut rng = rand::thread_rng(); + let pending = (0..NUM_DOWNLOADS) + .map(|id| { + let size = distribution.sample(&mut rng); + Download { id, size } + }) + .collect(); + Downloads { + pending, + in_progress: BTreeMap::new(), + } +} + +fn run_app( + terminal: &mut Terminal, + workers: Vec, + mut downloads: Downloads, + rx: mpsc::Receiver, +) -> Result<(), Box> { + let mut redraw = true; + loop { + if redraw { + terminal.draw(|f| ui(f, &downloads))?; + } + redraw = true; + + let span = span!(Level::INFO, "recv"); + let _guard = span.enter(); + match rx.recv()? { + Event::Input(event) => { + if event.code == crossterm::event::KeyCode::Char('q') { + break; + } + } + Event::Resize => { + event!(Level::INFO, "resize"); + terminal.resize()?; + } + Event::Tick => { + event!(Level::INFO, "tick"); + } + Event::DownloadUpdate(worker_id, download_id, progress) => { + event!( + Level::INFO, + worker_id, + download_id, + progress, + "download update" + ); + let download = downloads.in_progress.get_mut(&worker_id).unwrap(); + download.progress = progress; + redraw = false + } + Event::DownloadDone(worker_id, download_id) => { + event!(Level::INFO, worker_id, download_id, "download done"); + let download = downloads.in_progress.remove(&worker_id).unwrap(); + terminal.insert_before(1, |buf| { + Paragraph::new(Spans::from(vec![ + Span::from("Finished "), + Span::styled( + format!("download {}", download_id), + Style::default().add_modifier(Modifier::BOLD), + ), + Span::from(format!( + " in {}ms", + download.started_at.elapsed().as_millis() + )), + ])) + .render(buf.area, buf); + })?; + match downloads.next(worker_id) { + Some(d) => workers[worker_id].tx.send(d).unwrap(), + None => { + if downloads.in_progress.is_empty() { + terminal.insert_before(1, |buf| { + Paragraph::new("Done !").render(buf.area, buf); + })?; + break; + } + } + }; + } + }; + } + Ok(()) +} + +fn ui(f: &mut Frame, downloads: &Downloads) { + let size = f.size(); + + let block = Block::default() + .title("Progress") + .title_alignment(Alignment::Center); + f.render_widget(block, size); + + let chunks = Layout::default() + .constraints(vec![Constraint::Length(2), Constraint::Length(4)]) + .margin(1) + .split(size); + + // total progress + let done = NUM_DOWNLOADS - downloads.pending.len() - downloads.in_progress.len(); + let progress = LineGauge::default() + .gauge_style(Style::default().fg(Color::Blue)) + .label(format!("{}/{}", done, NUM_DOWNLOADS)) + .ratio(done as f64 / NUM_DOWNLOADS as f64); + f.render_widget(progress, chunks[0]); + + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints(vec![Constraint::Percentage(20), Constraint::Percentage(80)]) + .split(chunks[1]); + + // in progress downloads + let items: Vec = downloads + .in_progress + .iter() + .map(|(_worker_id, download)| { + ListItem::new(Spans::from(vec![ + Span::raw(symbols::DOT), + Span::styled( + format!(" download {:>2}", download.id), + Style::default() + .fg(Color::LightGreen) + .add_modifier(Modifier::BOLD), + ), + Span::raw(format!( + " ({}ms)", + download.started_at.elapsed().as_millis() + )), + ])) + }) + .collect(); + let list = List::new(items); + f.render_widget(list, chunks[0]); + + for (i, (_, download)) in downloads.in_progress.iter().enumerate() { + let gauge = Gauge::default() + .gauge_style(Style::default().fg(Color::Yellow)) + .ratio(download.progress / 100.0); + if chunks[1].top().saturating_add(i as u16) > size.bottom() { + continue; + } + f.render_widget( + gauge, + Rect { + x: chunks[1].left(), + y: chunks[1].top().saturating_add(i as u16), + width: chunks[1].width, + height: 1, + }, + ); + } +} diff --git a/src/backend/crossterm.rs b/src/backend/crossterm.rs index f6703e1..140c6f3 100644 --- a/src/backend/crossterm.rs +++ b/src/backend/crossterm.rs @@ -1,5 +1,5 @@ use crate::{ - backend::Backend, + backend::{Backend, ClearType}, buffer::Cell, layout::Rect, style::{Color, Modifier}, @@ -11,7 +11,7 @@ use crossterm::{ Attribute as CAttribute, Color as CColor, Print, SetAttribute, SetBackgroundColor, SetForegroundColor, }, - terminal::{self, Clear, ClearType}, + terminal::{self, Clear}, }; use std::io::{self, Write}; @@ -56,7 +56,7 @@ where for (x, y, cell) in content { // Move the cursor if the previous location was not (x - 1, y) if !matches!(last_pos, Some(p) if x == p.0 + 1 && y == p.1) { - map_error(queue!(self.buffer, MoveTo(x, y)))?; + queue!(self.buffer, MoveTo(x, y))?; } last_pos = Some((x, y)); if cell.modifier != modifier { @@ -69,45 +69,60 @@ where } if cell.fg != fg { let color = CColor::from(cell.fg); - map_error(queue!(self.buffer, SetForegroundColor(color)))?; + queue!(self.buffer, SetForegroundColor(color))?; fg = cell.fg; } if cell.bg != bg { let color = CColor::from(cell.bg); - map_error(queue!(self.buffer, SetBackgroundColor(color)))?; + queue!(self.buffer, SetBackgroundColor(color))?; bg = cell.bg; } - map_error(queue!(self.buffer, Print(&cell.symbol)))?; + queue!(self.buffer, Print(&cell.symbol))?; } - map_error(queue!( + queue!( self.buffer, SetForegroundColor(CColor::Reset), SetBackgroundColor(CColor::Reset), SetAttribute(CAttribute::Reset) - )) + ) } fn hide_cursor(&mut self) -> io::Result<()> { - map_error(execute!(self.buffer, Hide)) + execute!(self.buffer, Hide) } fn show_cursor(&mut self) -> io::Result<()> { - map_error(execute!(self.buffer, Show)) + execute!(self.buffer, Show) } fn get_cursor(&mut self) -> io::Result<(u16, u16)> { crossterm::cursor::position() - .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string())) } fn set_cursor(&mut self, x: u16, y: u16) -> io::Result<()> { - map_error(execute!(self.buffer, MoveTo(x, y))) + execute!(self.buffer, MoveTo(x, y)) } - fn clear(&mut self) -> io::Result<()> { - map_error(execute!(self.buffer, Clear(ClearType::All))) + fn clear(&mut self, clear_type: ClearType) -> io::Result<()> { + execute!( + self.buffer, + Clear(match clear_type { + ClearType::All => crossterm::terminal::ClearType::All, + ClearType::AfterCursor => crossterm::terminal::ClearType::FromCursorDown, + ClearType::BeforeCursor => crossterm::terminal::ClearType::FromCursorUp, + ClearType::CurrentLine => crossterm::terminal::ClearType::CurrentLine, + ClearType::UntilNewLine => crossterm::terminal::ClearType::UntilNewLine, + }) + ) + } + + fn append_lines(&mut self, n: u16) -> io::Result<()> { + for _ in 0..n { + queue!(self.buffer, Print("\n"))?; + } + self.buffer.flush() } fn size(&self) -> io::Result { @@ -122,10 +137,6 @@ where } } -fn map_error(error: crossterm::Result<()>) -> io::Result<()> { - error.map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string())) -} - impl From for CColor { fn from(color: Color) -> Self { match color { @@ -166,54 +177,54 @@ impl ModifierDiff { //use crossterm::Attribute; let removed = self.from - self.to; if removed.contains(Modifier::REVERSED) { - map_error(queue!(w, SetAttribute(CAttribute::NoReverse)))?; + queue!(w, SetAttribute(CAttribute::NoReverse))?; } if removed.contains(Modifier::BOLD) { - map_error(queue!(w, SetAttribute(CAttribute::NormalIntensity)))?; + queue!(w, SetAttribute(CAttribute::NormalIntensity))?; if self.to.contains(Modifier::DIM) { - map_error(queue!(w, SetAttribute(CAttribute::Dim)))?; + queue!(w, SetAttribute(CAttribute::Dim))?; } } if removed.contains(Modifier::ITALIC) { - map_error(queue!(w, SetAttribute(CAttribute::NoItalic)))?; + queue!(w, SetAttribute(CAttribute::NoItalic))?; } if removed.contains(Modifier::UNDERLINED) { - map_error(queue!(w, SetAttribute(CAttribute::NoUnderline)))?; + queue!(w, SetAttribute(CAttribute::NoUnderline))?; } if removed.contains(Modifier::DIM) { - map_error(queue!(w, SetAttribute(CAttribute::NormalIntensity)))?; + queue!(w, SetAttribute(CAttribute::NormalIntensity))?; } if removed.contains(Modifier::CROSSED_OUT) { - map_error(queue!(w, SetAttribute(CAttribute::NotCrossedOut)))?; + queue!(w, SetAttribute(CAttribute::NotCrossedOut))?; } if removed.contains(Modifier::SLOW_BLINK) || removed.contains(Modifier::RAPID_BLINK) { - map_error(queue!(w, SetAttribute(CAttribute::NoBlink)))?; + queue!(w, SetAttribute(CAttribute::NoBlink))?; } let added = self.to - self.from; if added.contains(Modifier::REVERSED) { - map_error(queue!(w, SetAttribute(CAttribute::Reverse)))?; + queue!(w, SetAttribute(CAttribute::Reverse))?; } if added.contains(Modifier::BOLD) { - map_error(queue!(w, SetAttribute(CAttribute::Bold)))?; + queue!(w, SetAttribute(CAttribute::Bold))?; } if added.contains(Modifier::ITALIC) { - map_error(queue!(w, SetAttribute(CAttribute::Italic)))?; + queue!(w, SetAttribute(CAttribute::Italic))?; } if added.contains(Modifier::UNDERLINED) { - map_error(queue!(w, SetAttribute(CAttribute::Underlined)))?; + queue!(w, SetAttribute(CAttribute::Underlined))?; } if added.contains(Modifier::DIM) { - map_error(queue!(w, SetAttribute(CAttribute::Dim)))?; + queue!(w, SetAttribute(CAttribute::Dim))?; } if added.contains(Modifier::CROSSED_OUT) { - map_error(queue!(w, SetAttribute(CAttribute::CrossedOut)))?; + queue!(w, SetAttribute(CAttribute::CrossedOut))?; } if added.contains(Modifier::SLOW_BLINK) { - map_error(queue!(w, SetAttribute(CAttribute::SlowBlink)))?; + queue!(w, SetAttribute(CAttribute::SlowBlink))?; } if added.contains(Modifier::RAPID_BLINK) { - map_error(queue!(w, SetAttribute(CAttribute::RapidBlink)))?; + queue!(w, SetAttribute(CAttribute::RapidBlink))?; } Ok(()) diff --git a/src/backend/mod.rs b/src/backend/mod.rs index 42b8001..a042d6a 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -16,15 +16,25 @@ pub use self::crossterm::CrosstermBackend; mod test; pub use self::test::TestBackend; +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum ClearType { + All, + AfterCursor, + BeforeCursor, + CurrentLine, + UntilNewLine, +} + pub trait Backend { fn draw<'a, I>(&mut self, content: I) -> Result<(), io::Error> where I: Iterator; + fn append_lines(&mut self, n: u16) -> io::Result<()>; fn hide_cursor(&mut self) -> Result<(), io::Error>; fn show_cursor(&mut self) -> Result<(), io::Error>; fn get_cursor(&mut self) -> Result<(u16, u16), io::Error>; fn set_cursor(&mut self, x: u16, y: u16) -> Result<(), io::Error>; - fn clear(&mut self) -> Result<(), io::Error>; + fn clear(&mut self, clear_type: ClearType) -> Result<(), io::Error>; fn size(&self) -> Result; fn flush(&mut self) -> Result<(), io::Error>; } diff --git a/src/backend/termion.rs b/src/backend/termion.rs index a71229a..f422e05 100644 --- a/src/backend/termion.rs +++ b/src/backend/termion.rs @@ -1,5 +1,5 @@ -use super::Backend; use crate::{ + backend::{Backend, ClearType}, buffer::Cell, layout::Rect, style::{Color, Modifier}, @@ -42,10 +42,21 @@ impl Backend for TermionBackend where W: Write, { - /// Clears the entire screen and move the cursor to the top left of the screen - fn clear(&mut self) -> io::Result<()> { - write!(self.stdout, "{}", termion::clear::All)?; - write!(self.stdout, "{}", termion::cursor::Goto(1, 1))?; + fn clear(&mut self, clear_type: ClearType) -> io::Result<()> { + match clear_type { + ClearType::All => write!(self.stdout, "{}", termion::clear::All)?, + ClearType::AfterCursor => write!(self.stdout, "{}", termion::clear::AfterCursor)?, + ClearType::BeforeCursor => write!(self.stdout, "{}", termion::clear::BeforeCursor)?, + ClearType::CurrentLine => write!(self.stdout, "{}", termion::clear::CurrentLine)?, + ClearType::UntilNewLine => write!(self.stdout, "{}", termion::clear::UntilNewline)?, + }; + self.stdout.flush() + } + + fn append_lines(&mut self, n: u16) -> io::Result<()> { + for _ in 0..n { + writeln!(self.stdout)?; + } self.stdout.flush() } diff --git a/src/backend/test.rs b/src/backend/test.rs index 46b3726..3a9b1d1 100644 --- a/src/backend/test.rs +++ b/src/backend/test.rs @@ -1,5 +1,5 @@ use crate::{ - backend::Backend, + backend::{Backend, ClearType}, buffer::{Buffer, Cell}, layout::Rect, }; @@ -117,6 +117,10 @@ impl Backend for TestBackend { Ok(()) } + fn append_lines(&mut self, _n: u16) -> Result<(), io::Error> { + Ok(()) + } + fn hide_cursor(&mut self) -> Result<(), io::Error> { self.cursor = false; Ok(()) @@ -136,7 +140,7 @@ impl Backend for TestBackend { Ok(()) } - fn clear(&mut self) -> Result<(), io::Error> { + fn clear(&mut self, _clear_type: ClearType) -> Result<(), io::Error> { self.buffer.reset(); Ok(()) } diff --git a/src/buffer.rs b/src/buffer.rs index b856376..196a13b 100644 --- a/src/buffer.rs +++ b/src/buffer.rs @@ -431,7 +431,6 @@ impl Buffer { pub fn diff<'a>(&self, other: &'a Buffer) -> Vec<(u16, u16, &'a Cell)> { let previous_buffer = &self.content; let next_buffer = &other.content; - let width = self.area.width; let mut updates: Vec<(u16, u16, &Cell)> = vec![]; // Cells invalidated by drawing/replacing preceeding multi-width characters: @@ -441,8 +440,7 @@ impl Buffer { let mut to_skip: usize = 0; for (i, (current, previous)) in next_buffer.iter().zip(previous_buffer.iter()).enumerate() { if (current != previous || invalidated > 0) && to_skip == 0 { - let x = i as u16 % width; - let y = i as u16 / width; + let (x, y) = self.pos_of(i); updates.push((x, y, &next_buffer[i])); } diff --git a/src/lib.rs b/src/lib.rs index 1439350..25fc554 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -169,4 +169,4 @@ pub mod terminal; pub mod text; pub mod widgets; -pub use self::terminal::{Frame, Terminal, TerminalOptions, Viewport}; +pub use self::terminal::{Frame, Terminal, TerminalOptions, ViewportVariant}; diff --git a/src/terminal.rs b/src/terminal.rs index 3a1d37f..f2d4f7c 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -1,40 +1,30 @@ use crate::{ - backend::Backend, + backend::{Backend, ClearType}, buffer::Buffer, layout::Rect, widgets::{StatefulWidget, Widget}, }; use std::io; +use tracing::{event, span, Level}; #[derive(Debug, Clone, PartialEq)] -/// UNSTABLE -enum ResizeBehavior { - Fixed, - Auto, -} - -#[derive(Debug, Clone, PartialEq)] -/// UNSTABLE -pub struct Viewport { - area: Rect, - resize_behavior: ResizeBehavior, -} - -impl Viewport { - /// UNSTABLE - pub fn fixed(area: Rect) -> Viewport { - Viewport { - area, - resize_behavior: ResizeBehavior::Fixed, - } - } +pub enum ViewportVariant { + Fullscreen, + Inline(u16), + Fixed(Rect), } #[derive(Debug, Clone, PartialEq)] /// Options to pass to [`Terminal::with_options`] pub struct TerminalOptions { /// Viewport used to draw to the terminal - pub viewport: Viewport, + pub viewport: ViewportVariant, +} + +#[derive(Debug, Clone, PartialEq)] +struct Viewport { + variant: ViewportVariant, + area: Rect, } /// Interface to the terminal backed by Termion @@ -53,6 +43,11 @@ where hidden_cursor: bool, /// Viewport viewport: Viewport, + /// Last known size of the terminal. Used to detect if the internal buffers have to be resized. + last_known_size: Rect, + /// Last known position of the cursor. Used to find the new area when the viewport is inlined + /// and the terminal resized. + last_known_cursor_pos: (u16, u16), } /// Represents a consistent terminal interface for rendering. @@ -73,7 +68,7 @@ impl<'a, B> Frame<'a, B> where B: Backend, { - /// Terminal size, guaranteed not to change when rendering. + /// Frame size, guaranteed not to change when rendering. pub fn size(&self) -> Rect { self.terminal.viewport.area } @@ -173,29 +168,50 @@ where /// Wrapper around Terminal initialization. Each buffer is initialized with a blank string and /// default colors for the foreground and the background pub fn new(backend: B) -> io::Result> { - let size = backend.size()?; Terminal::with_options( backend, TerminalOptions { - viewport: Viewport { - area: size, - resize_behavior: ResizeBehavior::Auto, - }, + viewport: ViewportVariant::Fullscreen, }, ) } - /// UNSTABLE - pub fn with_options(backend: B, options: TerminalOptions) -> io::Result> { + pub fn with_options(mut backend: B, options: TerminalOptions) -> io::Result> { + let size = backend.size()?; + let (viewport_area, cursor_pos) = match options.viewport { + ViewportVariant::Fullscreen => (size, (0, 0)), + ViewportVariant::Inline(height) => { + let pos = backend.get_cursor()?; + let mut row = pos.1; + let max_height = size.height.min(height); + backend.append_lines(max_height.saturating_sub(1))?; + let missing_lines = row.saturating_add(max_height).saturating_sub(size.height); + if missing_lines > 0 { + row = row.saturating_sub(missing_lines); + } + ( + Rect { + x: 0, + y: row, + width: size.width, + height: max_height, + }, + pos, + ) + } + ViewportVariant::Fixed(area) => (area, (area.left(), area.top())), + }; Ok(Terminal { backend, - buffers: [ - Buffer::empty(options.viewport.area), - Buffer::empty(options.viewport.area), - ], + buffers: [Buffer::empty(viewport_area), Buffer::empty(viewport_area)], current: 0, hidden_cursor: false, - viewport: options.viewport, + viewport: Viewport { + variant: options.viewport, + area: viewport_area, + }, + last_known_size: size, + last_known_cursor_pos: cursor_pos, }) } @@ -225,39 +241,75 @@ where let previous_buffer = &self.buffers[1 - self.current]; let current_buffer = &self.buffers[self.current]; let updates = previous_buffer.diff(current_buffer); + if let Some((col, row, _)) = updates.last() { + self.last_known_cursor_pos = (*col, *row); + } self.backend.draw(updates.into_iter()) } - /// Updates the Terminal so that internal buffers match the requested size. Requested size will - /// be saved so the size can remain consistent when rendering. - /// This leads to a full clear of the screen. - pub fn resize(&mut self, area: Rect) -> io::Result<()> { - self.buffers[self.current].resize(area); - self.buffers[1 - self.current].resize(area); - self.viewport.area = area; - self.clear() - } - /// Queries the backend for size and resizes if it doesn't match the previous size. - pub fn autoresize(&mut self) -> io::Result<()> { - if self.viewport.resize_behavior == ResizeBehavior::Auto { - let size = self.size()?; - if size != self.viewport.area { - self.resize(size)?; + pub fn resize(&mut self) -> io::Result<()> { + let size = self.size()?; + if self.last_known_size == size { + return Ok(()); + } + + event!(Level::DEBUG, last_known_size = ?self.last_known_size, ?size, "terminal size changed"); + + let next_area = match self.viewport.variant { + ViewportVariant::Fullscreen => size, + ViewportVariant::Inline(height) => { + let (_, mut row) = self.get_cursor()?; + let offset_in_previous_viewport = self + .last_known_cursor_pos + .1 + .saturating_sub(self.viewport.area.top()); + let max_height = height.min(size.height); + let lines_after_cursor = height + .saturating_sub(offset_in_previous_viewport) + .saturating_sub(1); + let available_lines = size.height.saturating_sub(row).saturating_sub(1); + let missing_lines = lines_after_cursor.saturating_sub(available_lines); + self.backend.append_lines(lines_after_cursor)?; + if missing_lines > 0 { + row = row.saturating_sub(missing_lines); + } + row = row.saturating_sub(offset_in_previous_viewport); + Rect { + x: 0, + y: row, + width: size.width, + height: max_height, + } } + ViewportVariant::Fixed(area) => area, }; + self.set_viewport_area(next_area); + self.clear()?; + + self.last_known_size = size; Ok(()) } + fn set_viewport_area(&mut self, area: Rect) { + self.viewport.area = area; + self.buffers[self.current].resize(area); + self.buffers[1 - self.current].resize(area); + event!(Level::DEBUG, area = ?area, "viewport changed"); + } + /// Synchronizes terminal size, calls the rendering closure, flushes the current internal state /// and prepares for the next draw call. pub fn draw(&mut self, f: F) -> io::Result where F: FnOnce(&mut Frame), { + let span = span!(Level::DEBUG, "draw"); + let _guard = span.enter(); + // Autoresize - otherwise we get glitches if shrinking or potential desync between widgets // and the terminal (if growing), which may OOB. - self.autoresize()?; + self.resize()?; let mut frame = self.get_frame(); f(&mut frame); @@ -283,9 +335,12 @@ where // Flush self.backend.flush()?; + + event!(Level::DEBUG, "completed frame"); + Ok(CompletedFrame { buffer: &self.buffers[1 - self.current], - area: self.viewport.area, + area: self.last_known_size, }) } @@ -306,12 +361,28 @@ where } pub fn set_cursor(&mut self, x: u16, y: u16) -> io::Result<()> { - self.backend.set_cursor(x, y) + self.backend.set_cursor(x, y)?; + self.last_known_cursor_pos = (x, y); + Ok(()) } /// Clear the terminal and force a full redraw on the next draw call. pub fn clear(&mut self) -> io::Result<()> { - self.backend.clear()?; + event!(Level::DEBUG, "clear"); + match self.viewport.variant { + ViewportVariant::Fullscreen => self.backend.clear(ClearType::All)?, + ViewportVariant::Inline(_) => { + self.backend + .set_cursor(self.viewport.area.left(), self.viewport.area.top())?; + self.backend.clear(ClearType::AfterCursor)?; + } + ViewportVariant::Fixed(area) => { + for row in area.top()..area.bottom() { + self.backend.set_cursor(0, row)?; + self.backend.clear(ClearType::AfterCursor)?; + } + } + } // Reset the back buffer to make sure the next update will redraw everything. self.buffers[1 - self.current].reset(); Ok(()) @@ -321,4 +392,97 @@ where pub fn size(&self) -> io::Result { self.backend.size() } + + /// Insert some content before the current inline viewport. This has no effect when the + /// viewport is fullscreen. + /// + /// This function scrolls down the current viewport by the given height. The newly freed space is + /// then made available to the `draw_fn` closure through a writable `Buffer`. + /// + /// Before: + /// ```ignore + /// +-------------------+ + /// | | + /// | viewport | + /// | | + /// +-------------------+ + /// ``` + /// + /// After: + /// ```ignore + /// +-------------------+ + /// | buffer | + /// +-------------------+ + /// +-------------------+ + /// | | + /// | viewport | + /// | | + /// +-------------------+ + /// ``` + /// + /// # Examples + /// + /// ## Insert a single line before the current viewport + /// + /// ```rust + /// # use tui::widgets::{Paragraph, Widget}; + /// # use tui::text::{Spans, Span}; + /// # use tui::style::{Color, Style}; + /// # use tui::{Terminal}; + /// # use tui::backend::TestBackend; + /// # let backend = TestBackend::new(10, 10); + /// # let mut terminal = Terminal::new(backend).unwrap(); + /// terminal.insert_before(1, |buf| { + /// Paragraph::new(Spans::from(vec![ + /// Span::raw("This line will be added "), + /// Span::styled("before", Style::default().fg(Color::Blue)), + /// Span::raw(" the current viewport") + /// ])).render(buf.area, buf); + /// }); + /// ``` + pub fn insert_before(&mut self, height: u16, draw_fn: F) -> io::Result<()> + where + F: FnOnce(&mut Buffer), + { + let span = span!(Level::DEBUG, "insert_before"); + let _guard = span.enter(); + if !matches!(self.viewport.variant, ViewportVariant::Inline(_)) { + return Ok(()); + } + + self.clear()?; + let height = height.min(self.last_known_size.height); + self.backend.append_lines(height)?; + let missing_lines = + height.saturating_sub(self.last_known_size.bottom() - self.viewport.area.top()); + let area = Rect { + x: self.viewport.area.left(), + y: self.viewport.area.top().saturating_sub(missing_lines), + width: self.viewport.area.width, + height, + }; + let mut buffer = Buffer::empty(area); + + draw_fn(&mut buffer); + + let iter = buffer.content.iter().enumerate().map(|(i, c)| { + let (x, y) = buffer.pos_of(i); + (x, y, c) + }); + self.backend.draw(iter)?; + self.backend.flush()?; + + let remaining_lines = self.last_known_size.height - area.bottom(); + let missing_lines = self.viewport.area.height.saturating_sub(remaining_lines); + self.backend.append_lines(self.viewport.area.height)?; + + self.set_viewport_area(Rect { + x: area.left(), + y: area.bottom().saturating_sub(missing_lines), + width: area.width, + height: self.viewport.area.height, + }); + + Ok(()) + } } diff --git a/tests/backend_termion.rs b/tests/backend_termion.rs index 771bd73..476f281 100644 --- a/tests/backend_termion.rs +++ b/tests/backend_termion.rs @@ -8,14 +8,14 @@ fn backend_termion_should_only_write_diffs() -> Result<(), Box