diff --git a/src/backend/test.rs b/src/backend/test.rs index 53a61dab..c26996ea 100644 --- a/src/backend/test.rs +++ b/src/backend/test.rs @@ -3,7 +3,7 @@ use std::{ fmt::{self, Write}, - io, + io, iter, }; use unicode_width::UnicodeWidthStr; @@ -35,6 +35,7 @@ use crate::{ #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct TestBackend { buffer: Buffer, + scrollback: Buffer, cursor: bool, pos: (u16, u16), } @@ -73,6 +74,7 @@ impl TestBackend { pub fn new(width: u16, height: u16) -> Self { Self { buffer: Buffer::empty(Rect::new(0, 0, width, height)), + scrollback: Buffer::empty(Rect::new(0, 0, width, 0)), cursor: false, pos: (0, 0), } @@ -83,9 +85,29 @@ impl TestBackend { &self.buffer } + /// Returns a reference to the internal scrollback buffer of the `TestBackend`. + /// + /// The scrollback buffer represents the part of the screen that is currently hidden from view, + /// but that could be accessed by scrolling back in the terminal's history. This would normally + /// be done using the terminal's scrollbar or an equivalent keyboard shortcut. + /// + /// The scrollback buffer starts out empty. Lines are appended when they scroll off the top of + /// the main buffer. This happens when lines are appended to the bottom of the main buffer + /// using [`Backend::append_lines`]. + /// + /// The scrollback buffer has a maximum height of [`u16::MAX`]. If lines are appended to the + /// bottom of the scrollback buffer when it is at its maximum height, a corresponding number of + /// lines will be removed from the top. + pub const fn scrollback(&self) -> &Buffer { + &self.scrollback + } + /// Resizes the `TestBackend` to the specified width and height. pub fn resize(&mut self, width: u16, height: u16) { self.buffer.resize(Rect::new(0, 0, width, height)); + let scrollback_height = self.scrollback.area.height; + self.scrollback + .resize(Rect::new(0, 0, width, scrollback_height)); } /// Asserts that the `TestBackend`'s buffer is equal to the expected buffer. @@ -93,6 +115,7 @@ impl TestBackend { /// This is a shortcut for `assert_eq!(self.buffer(), &expected)`. /// /// # Panics + /// /// When they are not equal, a panic occurs with a detailed error message showing the /// differences between the expected and actual buffers. #[allow(deprecated)] @@ -102,11 +125,42 @@ impl TestBackend { crate::assert_buffer_eq!(&self.buffer, expected); } + /// Asserts that the `TestBackend`'s scrollback buffer is equal to the expected buffer. + /// + /// This is a shortcut for `assert_eq!(self.scrollback(), &expected)`. + /// + /// # Panics + /// + /// When they are not equal, a panic occurs with a detailed error message showing the + /// differences between the expected and actual buffers. + #[track_caller] + pub fn assert_scrollback(&self, expected: &Buffer) { + assert_eq!(&self.scrollback, expected); + } + + /// Asserts that the `TestBackend`'s scrollback buffer is empty. + /// + /// # Panics + /// + /// When the scrollback buffer is not equal, a panic occurs with a detailed error message + /// showing the differences between the expected and actual buffers. + pub fn assert_scrollback_empty(&self) { + let expected = Buffer { + area: Rect { + width: self.scrollback.area.width, + ..Rect::ZERO + }, + content: vec![], + }; + self.assert_scrollback(&expected); + } + /// Asserts that the `TestBackend`'s buffer is equal to the expected lines. /// /// This is a shortcut for `assert_eq!(self.buffer(), &Buffer::with_lines(expected))`. /// /// # Panics + /// /// When they are not equal, a panic occurs with a detailed error message showing the /// differences between the expected and actual buffers. #[track_caller] @@ -118,11 +172,29 @@ impl TestBackend { self.assert_buffer(&Buffer::with_lines(expected)); } + /// Asserts that the `TestBackend`'s scrollback buffer is equal to the expected lines. + /// + /// This is a shortcut for `assert_eq!(self.scrollback(), &Buffer::with_lines(expected))`. + /// + /// # Panics + /// + /// When they are not equal, a panic occurs with a detailed error message showing the + /// differences between the expected and actual buffers. + #[track_caller] + pub fn assert_scrollback_lines<'line, Lines>(&self, expected: Lines) + where + Lines: IntoIterator, + Lines::Item: Into>, + { + self.assert_scrollback(&Buffer::with_lines(expected)); + } + /// Asserts that the `TestBackend`'s cursor position is equal to the expected one. /// /// This is a shortcut for `assert_eq!(self.get_cursor_position().unwrap(), expected)`. /// /// # Panics + /// /// When they are not equal, a panic occurs with a detailed error message showing the /// differences between the expected and actual position. #[track_caller] @@ -215,7 +287,7 @@ impl Backend for TestBackend { /// the cursor y position then that number of empty lines (at most the buffer's height in this /// case but this limit is instead replaced with scrolling in most backend implementations) will /// be added after the current position and the cursor will be moved to the last row. - fn append_lines(&mut self, n: u16) -> io::Result<()> { + fn append_lines(&mut self, line_count: u16) -> io::Result<()> { let Position { x: cur_x, y: cur_y } = self.get_cursor_position()?; let Rect { width, height, .. } = self.buffer.area; @@ -224,19 +296,29 @@ impl Backend for TestBackend { let max_y = height.saturating_sub(1); let lines_after_cursor = max_y.saturating_sub(cur_y); - if n > lines_after_cursor { - let rotate_by = n.saturating_sub(lines_after_cursor).min(max_y); - if rotate_by == height - 1 { - self.clear()?; - } + if line_count > lines_after_cursor { + // We need to insert blank lines at the bottom and scroll the lines from the top into + // scrollback. + let scroll_by: usize = (line_count - lines_after_cursor).into(); + let width: usize = self.buffer.area.width.into(); + let cells_to_scrollback = self.buffer.content.len().min(width * scroll_by); - self.set_cursor_position(Position { x: 0, y: rotate_by })?; - self.clear_region(ClearType::BeforeCursor)?; - self.buffer.content.rotate_left((width * rotate_by).into()); + append_to_scrollback( + &mut self.scrollback, + self.buffer.content.splice( + 0..cells_to_scrollback, + iter::repeat_with(Default::default).take(cells_to_scrollback), + ), + ); + self.buffer.content.rotate_left(cells_to_scrollback); + append_to_scrollback( + &mut self.scrollback, + iter::repeat_with(Default::default).take(width * scroll_by - cells_to_scrollback), + ); } - let new_cursor_y = cur_y.saturating_add(n).min(max_y); + let new_cursor_y = cur_y.saturating_add(line_count).min(max_y); self.set_cursor_position(Position::new(new_cursor_x, new_cursor_y))?; Ok(()) @@ -263,8 +345,25 @@ impl Backend for TestBackend { } } +/// Append the provided cells to the bottom of a scrollback buffer. The number of cells must be a +/// multiple of the buffer's width. If the scrollback buffer ends up larger than 65535 lines tall, +/// then lines will be removed from the top to get it down to size. +fn append_to_scrollback(scrollback: &mut Buffer, cells: impl IntoIterator) { + scrollback.content.extend(cells); + let width = scrollback.area.width as usize; + let new_height = (scrollback.content.len() / width).min(u16::MAX as usize); + let keep_from = scrollback + .content + .len() + .saturating_sub(width * u16::MAX as usize); + scrollback.content.drain(0..keep_from); + scrollback.area.height = new_height as u16; +} + #[cfg(test)] mod tests { + use itertools::Itertools as _; + use super::*; #[test] @@ -273,6 +372,7 @@ mod tests { TestBackend::new(10, 2), TestBackend { buffer: Buffer::with_lines([" "; 2]), + scrollback: Buffer::empty(Rect::new(0, 0, 10, 0)), cursor: false, pos: (0, 0), } @@ -323,6 +423,13 @@ mod tests { backend.assert_buffer_lines(["aaaaaaaaaa"; 2]); } + #[test] + #[should_panic = "assertion `left == right` failed"] + fn assert_scrollback_panics() { + let backend = TestBackend::new(10, 2); + backend.assert_scrollback_lines(["aaaaaaaaaa"; 2]); + } + #[test] fn display() { let backend = TestBackend::new(10, 2); @@ -536,6 +643,7 @@ mod tests { "dddddddddd", "eeeeeeeeee", ]); + backend.assert_scrollback_empty(); } #[test] @@ -557,13 +665,14 @@ mod tests { backend.append_lines(1).unwrap(); - backend.buffer = Buffer::with_lines([ + backend.assert_buffer_lines([ "bbbbbbbbbb", "cccccccccc", "dddddddddd", "eeeeeeeeee", " ", ]); + backend.assert_scrollback_lines(["aaaaaaaaaa"]); // It also moves the cursor to the right, as is common of the behaviour of // terminals in raw-mode @@ -597,6 +706,7 @@ mod tests { "dddddddddd", "eeeeeeeeee", ]); + backend.assert_scrollback_empty(); } #[test] @@ -624,6 +734,7 @@ mod tests { " ", " ", ]); + backend.assert_scrollback_lines(["aaaaaaaaaa", "bbbbbbbbbb"]); } #[test] @@ -651,6 +762,13 @@ mod tests { " ", " ", ]); + backend.assert_scrollback_lines([ + "aaaaaaaaaa", + "bbbbbbbbbb", + "cccccccccc", + "dddddddddd", + "eeeeeeeeee", + ]); } #[test] @@ -676,6 +794,115 @@ mod tests { "eeeeeeeeee", " ", ]); + backend.assert_scrollback_lines(["aaaaaaaaaa"]); + } + + #[test] + fn append_multiple_lines_where_cursor_at_end_appends_more_than_height_lines() { + let mut backend = TestBackend::new(10, 5); + backend.buffer = Buffer::with_lines([ + "aaaaaaaaaa", + "bbbbbbbbbb", + "cccccccccc", + "dddddddddd", + "eeeeeeeeee", + ]); + + backend + .set_cursor_position(Position { x: 0, y: 4 }) + .unwrap(); + + backend.append_lines(8).unwrap(); + backend.assert_cursor_position(Position { x: 1, y: 4 }); + + backend.assert_buffer_lines([ + " ", + " ", + " ", + " ", + " ", + ]); + backend.assert_scrollback_lines([ + "aaaaaaaaaa", + "bbbbbbbbbb", + "cccccccccc", + "dddddddddd", + "eeeeeeeeee", + " ", + " ", + " ", + ]); + } + + #[test] + fn append_lines_truncates_beyond_u16_max() -> io::Result<()> { + let mut backend = TestBackend::new(10, 5); + + // Fill the scrollback with 65535 + 10 lines. + let row_count = u16::MAX as usize + 10; + for row in 0..=row_count { + if row > 4 { + backend.set_cursor_position(Position { x: 0, y: 4 })?; + backend.append_lines(1)?; + } + let cells = format!("{row:>10}").chars().map(Cell::from).collect_vec(); + let content = cells + .iter() + .enumerate() + .map(|(column, cell)| (column as u16, 4.min(row) as u16, cell)); + backend.draw(content)?; + } + + // check that the buffer contains the last 5 lines appended + backend.assert_buffer_lines([ + " 65541", + " 65542", + " 65543", + " 65544", + " 65545", + ]); + + // TODO: ideally this should be something like: + // let lines = (6..=65545).map(|row| format!("{row:>10}")); + // backend.assert_scrollback_lines(lines); + // but there's some truncation happening in Buffer::with_lines that needs to be fixed + assert_eq!( + Buffer { + area: Rect::new(0, 0, 10, 5), + content: backend.scrollback.content[0..10 * 5].to_vec(), + }, + Buffer::with_lines([ + " 6", + " 7", + " 8", + " 9", + " 10", + ]), + "first 5 lines of scrollback should have been truncated" + ); + + assert_eq!( + Buffer { + area: Rect::new(0, 0, 10, 5), + content: backend.scrollback.content[10 * 65530..10 * 65535].to_vec(), + }, + Buffer::with_lines([ + " 65536", + " 65537", + " 65538", + " 65539", + " 65540", + ]), + "last 5 lines of scrollback should have been appended" + ); + + // These checks come after the content checks as otherwise we won't see the failing content + // when these checks fail. + // Make sure the scrollback is the right size. + assert_eq!(backend.scrollback.area.width, 10); + assert_eq!(backend.scrollback.area.height, 65535); + assert_eq!(backend.scrollback.content.len(), 10 * 65535); + Ok(()) } #[test] diff --git a/src/buffer/cell.rs b/src/buffer/cell.rs index 88b70e69..037dda79 100644 --- a/src/buffer/cell.rs +++ b/src/buffer/cell.rs @@ -157,6 +157,14 @@ impl Default for Cell { } } +impl From for Cell { + fn from(ch: char) -> Self { + let mut cell = Self::EMPTY; + cell.set_char(ch); + cell + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/terminal/terminal.rs b/src/terminal/terminal.rs index 21a9fc3f..3364a750 100644 --- a/src/terminal/terminal.rs +++ b/src/terminal/terminal.rs @@ -1,6 +1,8 @@ use std::io; -use crate::{backend::ClearType, prelude::*, CompletedFrame, TerminalOptions, Viewport}; +use crate::{ + backend::ClearType, buffer::Cell, prelude::*, CompletedFrame, TerminalOptions, Viewport, +}; /// An interface to interact and draw [`Frame`]s on the user's terminal. /// @@ -494,32 +496,58 @@ where } /// Insert some content before the current inline viewport. This has no effect when the - /// viewport is fullscreen. + /// viewport is not inline. /// - /// 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`. + /// The `draw_fn` closure will be called to draw into a writable `Buffer` that is `height` + /// lines tall. The content of that `Buffer` will then be inserted before the viewport. + /// + /// If the viewport isn't yet at the bottom of the screen, inserted lines will push it towards + /// the bottom. Once the viewport is at the bottom of the screen, inserted lines will scroll + /// the area of the screen above the viewport upwards. /// /// Before: /// ```ignore - /// +-------------------+ - /// | | - /// | viewport | - /// | | - /// +-------------------+ + /// +---------------------+ + /// | pre-existing line 1 | + /// | pre-existing line 2 | + /// +---------------------+ + /// | viewport | + /// +---------------------+ + /// | | + /// | | + /// +---------------------+ /// ``` /// - /// After: + /// After inserting 2 lines: /// ```ignore - /// +-------------------+ - /// | buffer | - /// +-------------------+ - /// +-------------------+ - /// | | - /// | viewport | - /// | | - /// +-------------------+ + /// +---------------------+ + /// | pre-existing line 1 | + /// | pre-existing line 2 | + /// | inserted line 1 | + /// | inserted line 2 | + /// +---------------------+ + /// | viewport | + /// +---------------------+ + /// +---------------------+ /// ``` /// + /// After inserting 2 more lines: + /// ```ignore + /// +---------------------+ + /// | pre-existing line 2 | + /// | inserted line 1 | + /// | inserted line 2 | + /// | inserted line 3 | + /// | inserted line 4 | + /// +---------------------+ + /// | viewport | + /// +---------------------+ + /// ``` + /// + /// If more lines are inserted than there is space on the screen, then the top lines will go + /// directly into the terminal's scrollback buffer. At the limit, if the viewport takes up the + /// whole screen, all lines will be inserted directly into the scrollback buffer. + /// /// # Examples /// /// ## Insert a single line before the current viewport @@ -545,51 +573,121 @@ where return Ok(()); } - // Clear the viewport off the screen - self.clear()?; - - // Move the viewport by height, but don't move it past the bottom of the terminal - let viewport_at_bottom = self.last_known_area.bottom() - self.viewport_area.height; - self.set_viewport_area(Rect { - y: self - .viewport_area - .y - .saturating_add(height) - .min(viewport_at_bottom), - ..self.viewport_area - }); - - // Draw contents into buffer + // The approach of this function is to first render all of the lines to insert into a + // temporary buffer, and then to loop drawing chunks from the buffer to the screen. drawing + // this buffer onto the screen. let area = Rect { - x: self.viewport_area.left(), + x: 0, y: 0, width: self.viewport_area.width, height, }; let mut buffer = Buffer::empty(area); draw_fn(&mut buffer); + let mut buffer = buffer.content.as_slice(); - // Split buffer into screen-sized chunks and draw - let max_chunk_size = (self.viewport_area.top() as usize) * (area.width as usize); - for buffer_content_chunk in buffer.content.chunks(max_chunk_size) { - let chunk_size = (buffer_content_chunk.len() / (area.width as usize)) as u16; + // Use i32 variables so we don't have worry about overflowed u16s when adding, or about + // negative results when subtracting. + let mut drawn_height: i32 = self.viewport_area.top().into(); + let mut buffer_height: i32 = height.into(); + let viewport_height: i32 = self.viewport_area.height.into(); + let screen_height: i32 = self.last_known_area.height.into(); - self.backend - .append_lines(self.viewport_area.height.saturating_sub(1) + chunk_size)?; - - let iter = buffer_content_chunk.iter().enumerate().map(|(i, c)| { - let (x, y) = buffer.pos_of(i); - ( - x, - self.viewport_area.top().saturating_sub(chunk_size) + y, - c, - ) - }); - self.backend.draw(iter)?; - self.backend.flush()?; - self.set_cursor_position(self.viewport_area.as_position())?; + // The algorithm here is to loop, drawing large chunks of text (up to a screen-full at a + // time), until the remainder of the buffer plus the viewport fits on the screen. We choose + // this loop condition because it guarantees that we can write the remainder of the buffer + // with just one call to Self::draw_lines(). + while buffer_height + viewport_height > screen_height { + // We will draw as much of the buffer as possible on this iteration in order to make + // forward progress. So we have: + // + // to_draw = min(buffer_height, screen_height) + // + // We may need to scroll the screen up to make room to draw. We choose the minimal + // possible scroll amount so we don't end up with the viewport sitting in the middle of + // the screen when this function is done. The amount to scroll by is: + // + // scroll_up = max(0, drawn_height + to_draw - screen_height) + // + // We want `scroll_up` to be enough so that, after drawing, we have used the whole + // screen (drawn_height - scroll_up + to_draw = screen_height). However, there might + // already be enough room on the screen to draw without scrolling (drawn_height + + // to_draw <= screen_height). In this case, we just don't scroll at all. + let to_draw = buffer_height.min(screen_height); + let scroll_up = 0.max(drawn_height + to_draw - screen_height); + self.scroll_up(scroll_up as u16)?; + buffer = self.draw_lines((drawn_height - scroll_up) as u16, to_draw as u16, buffer)?; + drawn_height += to_draw - scroll_up; + buffer_height -= to_draw; } + // There is now enough room on the screen for the remaining buffer plus the viewport, + // though we may still need to scroll up some of the existing text first. It's possible + // that by this point we've drained the buffer, but we may still need to scroll up to make + // room for the viewport. + // + // We want to scroll up the exact amount that will leave us completely filling the screen. + // However, it's possible that the viewport didn't start on the bottom of the screen and + // the added lines weren't enough to push it all the way to the bottom. We deal with this + // case by just ensuring that our scroll amount is non-negative. + // + // We want: + // screen_height = drawn_height - scroll_up + buffer_height + viewport_height + // Or, equivalently: + // scroll_up = drawn_height + buffer_height + viewport_height - screen_height + let scroll_up = 0.max(drawn_height + buffer_height + viewport_height - screen_height); + self.scroll_up(scroll_up as u16)?; + self.draw_lines( + (drawn_height - scroll_up) as u16, + buffer_height as u16, + buffer, + )?; + drawn_height += buffer_height - scroll_up; + + self.set_viewport_area(Rect { + y: drawn_height as u16, + ..self.viewport_area + }); + + // Clear the viewport off the screen. We didn't clear earlier for two reasons. First, it + // wasn't necessary because the buffer we drew out of isn't sparse, so it overwrote + // whatever was on the screen. Second, there is a weird bug with tmux where a full screen + // clear plus immediate scrolling causes some garbage to go into the scrollback. + self.clear()?; + + Ok(()) + } + + /// Draw lines at the given vertical offset. The slice of cells must contain enough cells + /// for the requested lines. A slice of the unused cells are returned. + fn draw_lines<'a>( + &mut self, + y_offset: u16, + lines_to_draw: u16, + cells: &'a [Cell], + ) -> io::Result<&'a [Cell]> { + let width: usize = self.last_known_area.width.into(); + let (to_draw, remainder) = cells.split_at(width * lines_to_draw as usize); + if lines_to_draw > 0 { + let iter = to_draw + .iter() + .enumerate() + .map(|(i, c)| ((i % width) as u16, y_offset + (i / width) as u16, c)); + self.backend.draw(iter)?; + self.backend.flush()?; + } + Ok(remainder) + } + + /// Scroll the whole screen up by the given number of lines. + fn scroll_up(&mut self, lines_to_scroll: u16) -> io::Result<()> { + if lines_to_scroll > 0 { + self.set_cursor_position(Position::new( + 0, + self.last_known_area.height.saturating_sub(1), + ))?; + self.backend.append_lines(lines_to_scroll)?; + } Ok(()) } } diff --git a/tests/terminal.rs b/tests/terminal.rs index 556e6935..6315637e 100644 --- a/tests/terminal.rs +++ b/tests/terminal.rs @@ -3,7 +3,7 @@ use std::error::Error; use ratatui::{ backend::{Backend, TestBackend}, layout::Rect, - widgets::{Paragraph, Widget}, + widgets::{Block, Paragraph, Widget}, Terminal, TerminalOptions, Viewport, }; @@ -110,6 +110,7 @@ fn terminal_insert_before_moves_viewport() -> Result<(), Box> { " ", " ", ]); + terminal.backend().assert_scrollback_empty(); Ok(()) } @@ -152,6 +153,9 @@ fn terminal_insert_before_scrolls_on_large_input() -> Result<(), Box> "------ Line 5 ------", "[---- Viewport ----]", ]); + terminal + .backend() + .assert_scrollback_lines(["------ Line 1 ------"]); Ok(()) } @@ -204,6 +208,77 @@ fn terminal_insert_before_scrolls_on_many_inserts() -> Result<(), Box "------ Line 5 ------", "[---- Viewport ----]", ]); + terminal + .backend() + .assert_scrollback_lines(["------ Line 1 ------"]); + + Ok(()) +} + +#[test] +fn terminal_insert_before_large_viewport() -> Result<(), Box> { + // This test covers a bug previously present whereby doing an insert_before when the + // viewport covered the entire screen would cause a panic. + + let backend = TestBackend::new(20, 3); + let mut terminal = Terminal::with_options( + backend, + TerminalOptions { + viewport: Viewport::Inline(3), + }, + )?; + + terminal.insert_before(1, |buf| { + Paragraph::new(vec!["------ Line 1 ------".into()]).render(buf.area, buf); + })?; + + terminal.insert_before(3, |buf| { + Paragraph::new(vec![ + "------ Line 2 ------".into(), + "------ Line 3 ------".into(), + "------ Line 4 ------".into(), + ]) + .render(buf.area, buf); + })?; + + terminal.insert_before(7, |buf| { + Paragraph::new(vec![ + "------ Line 5 ------".into(), + "------ Line 6 ------".into(), + "------ Line 7 ------".into(), + "------ Line 8 ------".into(), + "------ Line 9 ------".into(), + "----- Line 10 ------".into(), + "----- Line 11 ------".into(), + ]) + .render(buf.area, buf); + })?; + + terminal.draw(|f| { + let paragraph = Paragraph::new("Viewport") + .centered() + .block(Block::bordered()); + f.render_widget(paragraph, f.area()); + })?; + + terminal.backend().assert_buffer_lines([ + "┌──────────────────┐", + "│ Viewport │", + "└──────────────────┘", + ]); + terminal.backend().assert_scrollback_lines([ + "------ Line 1 ------", + "------ Line 2 ------", + "------ Line 3 ------", + "------ Line 4 ------", + "------ Line 5 ------", + "------ Line 6 ------", + "------ Line 7 ------", + "------ Line 8 ------", + "------ Line 9 ------", + "----- Line 10 ------", + "----- Line 11 ------", + ]); Ok(()) }