From d72968d86b94100579feba80c5cd207c2e7e13e7 Mon Sep 17 00:00:00 2001 From: Neal Fachan Date: Mon, 14 Oct 2024 15:46:13 -0700 Subject: [PATCH] feat(scrolling-regions)!: use terminal scrolling regions to stop Terminal::insert_before from flickering (#1341) The current implementation of Terminal::insert_before causes the viewport to flicker. This is described in #584 . This PR removes that flickering by using terminal scrolling regions (sometimes called "scroll regions"). A terminal can have its scrolling region set to something other than the whole screen. When a scroll ANSI sequence is sent to the terminal and it has a non-default scrolling region, the terminal will scroll just inside of that region. We use scrolling regions to implement insert_before. We create a region on the screen above the viewport, scroll that up to make room for the newly inserted lines, and then draw the new lines. We may need to repeat this process depending on how much space there is and how many lines we need to draw. When the viewport takes up the entire screen, we take a modified approach. We create a scrolling region of just the top line (could be more) of the viewport, then use that to draw the lines we want to output. When we're done, we scroll it up by one line, into the scrollback history, and then redraw the top line from the viewport. A final edge case is when the viewport hasn't yet reached the bottom of the screen. This case, we set up a different scrolling region, where the top is the top of the viewport, and the bottom is the viewport's bottom plus the number of lines we want to scroll by. We then scroll this region down to open up space above the viewport for drawing the inserted lines. Regardless of what we do, we need to reset the scrolling region. This PR takes the approach of always resetting the scrolling region after every operation. So the Backend gets new scroll_region_up and scroll_region_down methods instead of set_scrolling_region, scroll_up, scroll_down, and reset_scrolling_region methods. We chose that approach for two reasons. First, we don't want Ratatui to have to remember that state and then reset the scrolling region when tearing down. Second, the pre-Windows-10 console code doesn't support scrolling regio This PR: - Adds a new scrolling-regions feature. - Adds two new Backend methods: scroll_region_up and scroll_region_down. - Implements those Backend methods on all backends in the codebase. - The crossterm and termion implementations use raw ANSI escape sequences. I'm trying to merge changes into those two projects separately to support these functions. - Adds code to Terminal::insert_before to choose between insert_before_scrolling_regions and insert_before_no_scrolling_regions. The latter is the old implementation. - Adds lots of tests to the TestBackend to for the scrolling-region-related Backend methods. - Adds versions of terminal tests that show that insert_before doesn't clobber the viewport. This is a change in behavior from before. --- Cargo.toml | 4 + src/backend.rs | 58 +++++++++++ src/backend/crossterm.rs | 122 +++++++++++++++++++++++ src/backend/termion.rs | 44 +++++++++ src/backend/termwiz.rs | 46 +++++++++ src/backend/test.rs | 206 +++++++++++++++++++++++++++++++++----- src/terminal/terminal.rs | 122 ++++++++++++++++++++++- tests/terminal.rs | 209 +++++++++++++++++++++++++++++++++++++++ 8 files changed, 785 insertions(+), 26 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index d4b322f6..6649c07c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -138,6 +138,10 @@ macros = [] ## enables conversions from colors in the [`palette`] crate to [`Color`](crate::style::Color). palette = ["dep:palette"] +## Use terminal scrolling regions to make some operations less prone to +## flickering. (i.e. Terminal::insert_before). +scrolling-regions = [] + ## enables all widgets. all-widgets = ["widget-calendar"] diff --git a/src/backend.rs b/src/backend.rs index 473b66d1..0d42093f 100644 --- a/src/backend.rs +++ b/src/backend.rs @@ -327,6 +327,64 @@ pub trait Backend { /// Flush any buffered content to the terminal screen. fn flush(&mut self) -> io::Result<()>; + + /// Scroll a region of the screen upwards, where a region is specified by a (half-open) range + /// of rows. + /// + /// Each row in the region is replaced by the row `line_count` rows below it, except the bottom + /// `line_count` rows, which are replaced by empty rows. If `line_count` is equal to or larger + /// than the number of rows in the region, then all rows are replaced with empty rows. + /// + /// If the region includes row 0, then `line_count` rows are copied into the bottom of the + /// scrollback buffer. These rows are first taken from the old contents of the region, starting + /// from the top. If there aren't sufficient rows in the region, then the remainder are empty + /// rows. + /// + /// The position of the cursor afterwards is undefined. + /// + /// The behavior is designed to match what ANSI terminals do when scrolling regions are + /// established. With ANSI terminals, a scrolling region can be established with the "^[[X;Yr" + /// sequence, where X and Y define the lines of the region. The scrolling region can be reset + /// to be the whole screen with the "^[[r" sequence. + /// + /// When a scrolling region is established in an ANSI terminal, various operations' behaviors + /// are changed in such a way that the scrolling region acts like a "virtual screen". In + /// particular, the scrolling sequence "^[[NS", which scrolls lines up by a count of N. + /// + /// On an ANSI terminal, this method will probably translate to something like: + /// "^[[X;Yr^[[NS^[[r". That is, set the scrolling region, scroll up, then reset the scrolling + /// region. + /// + /// For examples of how this function is expected to work, refer to the tests for + /// [`TestBackend::scroll_region_up`]. + #[cfg(feature = "scrolling-regions")] + fn scroll_region_up(&mut self, region: std::ops::Range, line_count: u16) + -> io::Result<()>; + + /// Scroll a region of the screen downwards, where a region is specified by a (half-open) range + /// of rows. + /// + /// Each row in the region is replaced by the row `line_count` rows above it, except the top + /// `line_count` rows, which are replaced by empty rows. If `line_count` is equal to or larger + /// than the number of rows in the region, then all rows are replaced with empty rows. + /// + /// The position of the cursor afterwards is undefined. + /// + /// See the documentation for [`Self::scroll_region_down`] for more information about how this + /// is expected to be implemented for ANSI terminals. All of that applies, except the ANSI + /// sequence to scroll down is "^[[NT". + /// + /// This function is asymmetrical with regards to the scrollback buffer. The reason is that + /// this how terminals seem to implement things. + /// + /// For examples of how this function is expected to work, refer to the tests for + /// [`TestBackend::scroll_region_down`]. + #[cfg(feature = "scrolling-regions")] + fn scroll_region_down( + &mut self, + region: std::ops::Range, + line_count: u16, + ) -> io::Result<()>; } #[cfg(test)] diff --git a/src/backend/crossterm.rs b/src/backend/crossterm.rs index 1df67c05..256c0c42 100644 --- a/src/backend/crossterm.rs +++ b/src/backend/crossterm.rs @@ -274,6 +274,32 @@ where fn flush(&mut self) -> io::Result<()> { self.writer.flush() } + + #[cfg(feature = "scrolling-regions")] + fn scroll_region_up(&mut self, region: std::ops::Range, amount: u16) -> io::Result<()> { + queue!( + self.writer, + ScrollUpInRegion { + first_row: region.start, + last_row: region.end.saturating_sub(1), + lines_to_scroll: amount, + } + )?; + self.writer.flush() + } + + #[cfg(feature = "scrolling-regions")] + fn scroll_region_down(&mut self, region: std::ops::Range, amount: u16) -> io::Result<()> { + queue!( + self.writer, + ScrollDownInRegion { + first_row: region.start, + last_row: region.end.saturating_sub(1), + lines_to_scroll: amount, + } + )?; + self.writer.flush() + } } impl From for CColor { @@ -485,6 +511,102 @@ impl From for Style { } } +/// A command that scrolls the terminal screen a given number of rows up in a specific scrolling +/// region. +/// +/// This will hopefully be replaced by a struct in crossterm proper. There are two outstanding +/// crossterm PRs that will address this: +/// - [918](https://github.com/crossterm-rs/crossterm/pull/918) +/// - [923](https://github.com/crossterm-rs/crossterm/pull/923) +#[cfg(feature = "scrolling-regions")] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ScrollUpInRegion { + /// The first row of the scrolling region. + pub first_row: u16, + + /// The last row of the scrolling region. + pub last_row: u16, + + /// The number of lines to scroll up by. + pub lines_to_scroll: u16, +} + +#[cfg(feature = "scrolling-regions")] +impl crate::crossterm::Command for ScrollUpInRegion { + fn write_ansi(&self, f: &mut impl std::fmt::Write) -> std::fmt::Result { + if self.lines_to_scroll != 0 { + // Set a scrolling region that contains just the desired lines. + write!( + f, + crate::crossterm::csi!("{};{}r"), + self.first_row.saturating_add(1), + self.last_row.saturating_add(1) + )?; + // Scroll the region by the desired count. + write!(f, crate::crossterm::csi!("{}S"), self.lines_to_scroll)?; + // Reset the scrolling region to be the whole screen. + write!(f, crate::crossterm::csi!("r"))?; + } + Ok(()) + } + + #[cfg(windows)] + fn execute_winapi(&self) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "ScrollUpInRegion command not supported for winapi", + )) + } +} + +/// A command that scrolls the terminal screen a given number of rows down in a specific scrolling +/// region. +/// +/// This will hopefully be replaced by a struct in crossterm proper. There are two outstanding +/// crossterm PRs that will address this: +/// - [918](https://github.com/crossterm-rs/crossterm/pull/918) +/// - [923](https://github.com/crossterm-rs/crossterm/pull/923) +#[cfg(feature = "scrolling-regions")] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ScrollDownInRegion { + /// The first row of the scrolling region. + pub first_row: u16, + + /// The last row of the scrolling region. + pub last_row: u16, + + /// The number of lines to scroll down by. + pub lines_to_scroll: u16, +} + +#[cfg(feature = "scrolling-regions")] +impl crate::crossterm::Command for ScrollDownInRegion { + fn write_ansi(&self, f: &mut impl std::fmt::Write) -> std::fmt::Result { + if self.lines_to_scroll != 0 { + // Set a scrolling region that contains just the desired lines. + write!( + f, + crate::crossterm::csi!("{};{}r"), + self.first_row.saturating_add(1), + self.last_row.saturating_add(1) + )?; + // Scroll the region by the desired count. + write!(f, crate::crossterm::csi!("{}T"), self.lines_to_scroll)?; + // Reset the scrolling region to be the whole screen. + write!(f, crate::crossterm::csi!("r"))?; + } + Ok(()) + } + + #[cfg(windows)] + fn execute_winapi(&self) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "ScrollDownInRegion command not supported for winapi", + )) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/backend/termion.rs b/src/backend/termion.rs index d9d023f1..707b32ec 100644 --- a/src/backend/termion.rs +++ b/src/backend/termion.rs @@ -239,6 +239,30 @@ where fn flush(&mut self) -> io::Result<()> { self.writer.flush() } + + #[cfg(feature = "scrolling-regions")] + fn scroll_region_up(&mut self, region: std::ops::Range, amount: u16) -> io::Result<()> { + write!( + self.writer, + "{}{}{}", + SetRegion(region.start.saturating_add(1), region.end), + termion::scroll::Up(amount), + ResetRegion, + )?; + self.writer.flush() + } + + #[cfg(feature = "scrolling-regions")] + fn scroll_region_down(&mut self, region: std::ops::Range, amount: u16) -> io::Result<()> { + write!( + self.writer, + "{}{}{}", + SetRegion(region.start.saturating_add(1), region.end), + termion::scroll::Down(amount), + ResetRegion, + )?; + self.writer.flush() + } } struct Fg(Color); @@ -468,6 +492,26 @@ impl From for Modifier { } } +/// Set scrolling region. +#[derive(Copy, Clone, PartialEq, Eq)] +pub struct SetRegion(pub u16, pub u16); + +impl fmt::Display for SetRegion { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "\x1B[{};{}r", self.0, self.1) + } +} + +/// Reset scrolling region. +#[derive(Copy, Clone, PartialEq, Eq)] +pub struct ResetRegion; + +impl fmt::Display for ResetRegion { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "\x1B[r") + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/backend/termwiz.rs b/src/backend/termwiz.rs index 94b9d040..abd9ec66 100644 --- a/src/backend/termwiz.rs +++ b/src/backend/termwiz.rs @@ -250,6 +250,52 @@ impl Backend for TermwizBackend { .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; Ok(()) } + + #[cfg(feature = "scrolling-regions")] + fn scroll_region_up(&mut self, region: std::ops::Range, amount: u16) -> io::Result<()> { + // termwiz doesn't have a command to just set the scrolling region. Instead, setting the + // scrolling region and scrolling are combined. However, this has the side-effect of + // leaving the scrolling region set. To reset the scrolling region, termwiz advises one to + // make a scrolling-region scroll command that contains the entire screen, but scrolls by 0 + // lines. See [`Change::ScrollRegionUp`] for more details. + let (_, rows) = self.buffered_terminal.dimensions(); + self.buffered_terminal.add_changes(vec![ + Change::ScrollRegionUp { + first_row: region.start as usize, + region_size: region.len(), + scroll_count: amount as usize, + }, + Change::ScrollRegionUp { + first_row: 0, + region_size: rows, + scroll_count: 0, + }, + ]); + Ok(()) + } + + #[cfg(feature = "scrolling-regions")] + fn scroll_region_down(&mut self, region: std::ops::Range, amount: u16) -> io::Result<()> { + // termwiz doesn't have a command to just set the scrolling region. Instead, setting the + // scrolling region and scrolling are combined. However, this has the side-effect of + // leaving the scrolling region set. To reset the scrolling region, termwiz advises one to + // make a scrolling-region scroll command that contains the entire screen, but scrolls by 0 + // lines. See [`Change::ScrollRegionDown`] for more details. + let (_, rows) = self.buffered_terminal.dimensions(); + self.buffered_terminal.add_changes(vec![ + Change::ScrollRegionDown { + first_row: region.start as usize, + region_size: region.len(), + scroll_count: amount as usize, + }, + Change::ScrollRegionDown { + first_row: 0, + region_size: rows, + scroll_count: 0, + }, + ]); + Ok(()) + } } impl From for Style { diff --git a/src/backend/test.rs b/src/backend/test.rs index 96e1b156..4a52ec45 100644 --- a/src/backend/test.rs +++ b/src/backend/test.rs @@ -80,6 +80,28 @@ impl TestBackend { } } + /// Creates a new `TestBackend` with the specified lines as the initial screen state. + /// + /// The backend's screen size is determined from the initial lines. + #[must_use] + pub fn with_lines<'line, Lines>(lines: Lines) -> Self + where + Lines: IntoIterator, + Lines::Item: Into>, + { + let buffer = Buffer::with_lines(lines); + let scrollback = Buffer::empty(Rect { + width: buffer.area.width, + ..Rect::ZERO + }); + Self { + buffer, + scrollback, + cursor: false, + pos: (0, 0), + } + } + /// Returns a reference to the internal buffer of the `TestBackend`. pub const fn buffer(&self) -> &Buffer { &self.buffer @@ -343,6 +365,77 @@ impl Backend for TestBackend { fn flush(&mut self) -> io::Result<()> { Ok(()) } + + #[cfg(feature = "scrolling-regions")] + fn scroll_region_up(&mut self, region: std::ops::Range, scroll_by: u16) -> io::Result<()> { + let width: usize = self.buffer.area.width.into(); + let cell_region_start = width * region.start.min(self.buffer.area.height) as usize; + let cell_region_end = width * region.end.min(self.buffer.area.height) as usize; + let cell_region_len = cell_region_end - cell_region_start; + let cells_to_scroll_by = width * scroll_by as usize; + + // Deal with the simple case where nothing needs to be copied into scrollback. + if cell_region_start > 0 { + if cells_to_scroll_by >= cell_region_len { + // The scroll amount is large enough to clear the whole region. + self.buffer.content[cell_region_start..cell_region_end].fill_with(Default::default); + } else { + // Scroll up by rotating, then filling in the bottom with empty cells. + self.buffer.content[cell_region_start..cell_region_end] + .rotate_left(cells_to_scroll_by); + self.buffer.content[cell_region_end - cells_to_scroll_by..cell_region_end] + .fill_with(Default::default); + } + return Ok(()); + } + + // The rows inserted into the scrollback will first come from the buffer, and if that is + // insufficient, will then be blank rows. + let cells_from_region = cell_region_len.min(cells_to_scroll_by); + append_to_scrollback( + &mut self.scrollback, + self.buffer.content.splice( + 0..cells_from_region, + iter::repeat_with(Default::default).take(cells_from_region), + ), + ); + if cells_to_scroll_by < cell_region_len { + // Rotate the remaining cells to the front of the region. + self.buffer.content[cell_region_start..cell_region_end].rotate_left(cells_from_region); + } else { + // Splice cleared out the region. Insert empty rows in scrollback. + append_to_scrollback( + &mut self.scrollback, + iter::repeat_with(Default::default).take(cells_to_scroll_by - cell_region_len), + ); + } + Ok(()) + } + + #[cfg(feature = "scrolling-regions")] + fn scroll_region_down( + &mut self, + region: std::ops::Range, + scroll_by: u16, + ) -> io::Result<()> { + let width: usize = self.buffer.area.width.into(); + let cell_region_start = width * region.start.min(self.buffer.area.height) as usize; + let cell_region_end = width * region.end.min(self.buffer.area.height) as usize; + let cell_region_len = cell_region_end - cell_region_start; + let cells_to_scroll_by = width * scroll_by as usize; + + if cells_to_scroll_by >= cell_region_len { + // The scroll amount is large enough to clear the whole region. + self.buffer.content[cell_region_start..cell_region_end].fill_with(Default::default); + } else { + // Scroll up by rotating, then filling in the top with empty cells. + self.buffer.content[cell_region_start..cell_region_end] + .rotate_right(cells_to_scroll_by); + self.buffer.content[cell_region_start..cell_region_start + cells_to_scroll_by] + .fill_with(Default::default); + } + Ok(()) + } } /// Append the provided cells to the bottom of a scrollback buffer. The number of cells must be a @@ -492,8 +585,7 @@ mod tests { #[test] fn clear_region_all() { - let mut backend = TestBackend::new(10, 5); - backend.buffer = Buffer::with_lines([ + let mut backend = TestBackend::with_lines([ "aaaaaaaaaa", "aaaaaaaaaa", "aaaaaaaaaa", @@ -513,8 +605,7 @@ mod tests { #[test] fn clear_region_after_cursor() { - let mut backend = TestBackend::new(10, 5); - backend.buffer = Buffer::with_lines([ + let mut backend = TestBackend::with_lines([ "aaaaaaaaaa", "aaaaaaaaaa", "aaaaaaaaaa", @@ -537,8 +628,7 @@ mod tests { #[test] fn clear_region_before_cursor() { - let mut backend = TestBackend::new(10, 5); - backend.buffer = Buffer::with_lines([ + let mut backend = TestBackend::with_lines([ "aaaaaaaaaa", "aaaaaaaaaa", "aaaaaaaaaa", @@ -561,8 +651,7 @@ mod tests { #[test] fn clear_region_current_line() { - let mut backend = TestBackend::new(10, 5); - backend.buffer = Buffer::with_lines([ + let mut backend = TestBackend::with_lines([ "aaaaaaaaaa", "aaaaaaaaaa", "aaaaaaaaaa", @@ -585,8 +674,7 @@ mod tests { #[test] fn clear_region_until_new_line() { - let mut backend = TestBackend::new(10, 5); - backend.buffer = Buffer::with_lines([ + let mut backend = TestBackend::with_lines([ "aaaaaaaaaa", "aaaaaaaaaa", "aaaaaaaaaa", @@ -609,8 +697,7 @@ mod tests { #[test] fn append_lines_not_at_last_line() { - let mut backend = TestBackend::new(10, 5); - backend.buffer = Buffer::with_lines([ + let mut backend = TestBackend::with_lines([ "aaaaaaaaaa", "bbbbbbbbbb", "cccccccccc", @@ -648,8 +735,7 @@ mod tests { #[test] fn append_lines_at_last_line() { - let mut backend = TestBackend::new(10, 5); - backend.buffer = Buffer::with_lines([ + let mut backend = TestBackend::with_lines([ "aaaaaaaaaa", "bbbbbbbbbb", "cccccccccc", @@ -681,8 +767,7 @@ mod tests { #[test] fn append_multiple_lines_not_at_last_line() { - let mut backend = TestBackend::new(10, 5); - backend.buffer = Buffer::with_lines([ + let mut backend = TestBackend::with_lines([ "aaaaaaaaaa", "bbbbbbbbbb", "cccccccccc", @@ -711,8 +796,7 @@ mod tests { #[test] fn append_multiple_lines_past_last_line() { - let mut backend = TestBackend::new(10, 5); - backend.buffer = Buffer::with_lines([ + let mut backend = TestBackend::with_lines([ "aaaaaaaaaa", "bbbbbbbbbb", "cccccccccc", @@ -739,8 +823,7 @@ mod tests { #[test] fn append_multiple_lines_where_cursor_at_end_appends_height_lines() { - let mut backend = TestBackend::new(10, 5); - backend.buffer = Buffer::with_lines([ + let mut backend = TestBackend::with_lines([ "aaaaaaaaaa", "bbbbbbbbbb", "cccccccccc", @@ -773,8 +856,7 @@ mod tests { #[test] fn append_multiple_lines_where_cursor_appends_height_lines() { - let mut backend = TestBackend::new(10, 5); - backend.buffer = Buffer::with_lines([ + let mut backend = TestBackend::with_lines([ "aaaaaaaaaa", "bbbbbbbbbb", "cccccccccc", @@ -799,8 +881,7 @@ mod tests { #[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([ + let mut backend = TestBackend::with_lines([ "aaaaaaaaaa", "bbbbbbbbbb", "cccccccccc", @@ -916,4 +997,81 @@ mod tests { let mut backend = TestBackend::new(10, 2); backend.flush().unwrap(); } + + #[cfg(feature = "scrolling-regions")] + mod scrolling_regions { + use rstest::rstest; + + use super::*; + + const A: &str = "aaaa"; + const B: &str = "bbbb"; + const C: &str = "cccc"; + const D: &str = "dddd"; + const E: &str = "eeee"; + const S: &str = " "; + + #[rstest] + #[case([A, B, C, D, E], 0..5, 0, [], [A, B, C, D, E])] + #[case([A, B, C, D, E], 0..5, 2, [A, B], [C, D, E, S, S])] + #[case([A, B, C, D, E], 0..5, 5, [A, B, C, D, E], [S, S, S, S, S])] + #[case([A, B, C, D, E], 0..5, 7, [A, B, C, D, E, S, S], [S, S, S, S, S])] + #[case([A, B, C, D, E], 0..3, 0, [], [A, B, C, D, E])] + #[case([A, B, C, D, E], 0..3, 2, [A, B], [C, S, S, D, E])] + #[case([A, B, C, D, E], 0..3, 3, [A, B, C], [S, S, S, D, E])] + #[case([A, B, C, D, E], 0..3, 4, [A, B, C, S], [S, S, S, D, E])] + #[case([A, B, C, D, E], 1..4, 0, [], [A, B, C, D, E])] + #[case([A, B, C, D, E], 1..4, 2, [], [A, D, S, S, E])] + #[case([A, B, C, D, E], 1..4, 3, [], [A, S, S, S, E])] + #[case([A, B, C, D, E], 1..4, 4, [], [A, S, S, S, E])] + #[case([A, B, C, D, E], 0..0, 0, [], [A, B, C, D, E])] + #[case([A, B, C, D, E], 0..0, 2, [S, S], [A, B, C, D, E])] + #[case([A, B, C, D, E], 2..2, 0, [], [A, B, C, D, E])] + #[case([A, B, C, D, E], 2..2, 2, [], [A, B, C, D, E])] + fn scroll_region_up( + #[case] initial_screen: [&'static str; L], + #[case] range: std::ops::Range, + #[case] scroll_by: u16, + #[case] expected_scrollback: [&'static str; M], + #[case] expected_buffer: [&'static str; N], + ) { + let mut backend = TestBackend::with_lines(initial_screen); + backend.scroll_region_up(range, scroll_by).unwrap(); + if expected_scrollback.is_empty() { + backend.assert_scrollback_empty(); + } else { + backend.assert_scrollback_lines(expected_scrollback); + } + backend.assert_buffer_lines(expected_buffer); + } + + #[rstest] + #[case([A, B, C, D, E], 0..5, 0, [A, B, C, D, E])] + #[case([A, B, C, D, E], 0..5, 2, [S, S, A, B, C])] + #[case([A, B, C, D, E], 0..5, 5, [S, S, S, S, S])] + #[case([A, B, C, D, E], 0..5, 7, [S, S, S, S, S])] + #[case([A, B, C, D, E], 0..3, 0, [A, B, C, D, E])] + #[case([A, B, C, D, E], 0..3, 2, [S, S, A, D, E])] + #[case([A, B, C, D, E], 0..3, 3, [S, S, S, D, E])] + #[case([A, B, C, D, E], 0..3, 4, [S, S, S, D, E])] + #[case([A, B, C, D, E], 1..4, 0, [A, B, C, D, E])] + #[case([A, B, C, D, E], 1..4, 2, [A, S, S, B, E])] + #[case([A, B, C, D, E], 1..4, 3, [A, S, S, S, E])] + #[case([A, B, C, D, E], 1..4, 4, [A, S, S, S, E])] + #[case([A, B, C, D, E], 0..0, 0, [A, B, C, D, E])] + #[case([A, B, C, D, E], 0..0, 2, [A, B, C, D, E])] + #[case([A, B, C, D, E], 2..2, 0, [A, B, C, D, E])] + #[case([A, B, C, D, E], 2..2, 2, [A, B, C, D, E])] + fn scroll_region_down( + #[case] initial_screen: [&'static str; M], + #[case] range: std::ops::Range, + #[case] scroll_by: u16, + #[case] expected_buffer: [&'static str; N], + ) { + let mut backend = TestBackend::with_lines(initial_screen); + backend.scroll_region_down(range, scroll_by).unwrap(); + backend.assert_scrollback_empty(); + backend.assert_buffer_lines(expected_buffer); + } + } } diff --git a/src/terminal/terminal.rs b/src/terminal/terminal.rs index 3e3309d5..fd9537c4 100644 --- a/src/terminal/terminal.rs +++ b/src/terminal/terminal.rs @@ -579,10 +579,22 @@ where where F: FnOnce(&mut Buffer), { - if !matches!(self.viewport, Viewport::Inline(_)) { - return Ok(()); + match self.viewport { + #[cfg(feature = "scrolling-regions")] + Viewport::Inline(_) => self.insert_before_scrolling_regions(height, draw_fn), + #[cfg(not(feature = "scrolling-regions"))] + Viewport::Inline(_) => self.insert_before_no_scrolling_regions(height, draw_fn), + _ => Ok(()), } + } + /// Implement `Self::insert_before` using standard backend capabilities. + #[cfg(not(feature = "scrolling-regions"))] + fn insert_before_no_scrolling_regions( + &mut self, + height: u16, + draw_fn: impl FnOnce(&mut Buffer), + ) -> io::Result<()> { // 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. @@ -668,6 +680,86 @@ where Ok(()) } + /// Implement `Self::insert_before` using scrolling regions. + /// + /// If a terminal supports scrolling regions, it means that we can define a subset of rows of + /// the screen, and then tell the terminal to scroll up or down just within that region. The + /// rows outside of the region are not affected. + /// + /// This function utilizes this feature to avoid having to redraw the viewport. This is done + /// either by splitting the screen at the top of the viewport, and then creating a gap by + /// either scrolling the viewport down, or scrolling the area above it up. The lines to insert + /// are then drawn into the gap created. + #[cfg(feature = "scrolling-regions")] + fn insert_before_scrolling_regions( + &mut self, + mut height: u16, + draw_fn: impl FnOnce(&mut Buffer), + ) -> io::Result<()> { + // 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: 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(); + + // Handle the special case where the viewport takes up the whole screen. + if self.viewport_area.height == self.last_known_area.height { + // "Borrow" the top line of the viewport. Draw over it, then immediately scroll it into + // scrollback. Do this repeatedly until the whole buffer has been put into scrollback. + let mut first = true; + while !buffer.is_empty() { + buffer = if first { + self.draw_lines(0, 1, buffer)? + } else { + self.draw_lines_over_cleared(0, 1, buffer)? + }; + first = false; + self.backend.scroll_region_up(0..1, 1)?; + } + + // Redraw the top line of the viewport. + let width = self.viewport_area.width as usize; + let top_line = self.buffers[1 - self.current].content[0..width].to_vec(); + self.draw_lines_over_cleared(0, 1, &top_line)?; + return Ok(()); + } + + // Handle the case where the viewport isn't yet at the bottom of the screen. + { + let viewport_top = self.viewport_area.top(); + let viewport_bottom = self.viewport_area.bottom(); + let screen_bottom = self.last_known_area.bottom(); + if viewport_bottom < screen_bottom { + let to_draw = height.min(screen_bottom - viewport_bottom); + self.backend + .scroll_region_down(viewport_top..viewport_bottom + to_draw, to_draw)?; + buffer = self.draw_lines_over_cleared(viewport_top, to_draw, buffer)?; + self.set_viewport_area(Rect { + y: viewport_top + to_draw, + ..self.viewport_area + }); + height -= to_draw; + } + } + + let viewport_top = self.viewport_area.top(); + while height > 0 { + let to_draw = height.min(viewport_top); + self.backend.scroll_region_up(0..viewport_top, to_draw)?; + buffer = self.draw_lines_over_cleared(viewport_top - to_draw, to_draw, buffer)?; + height -= to_draw; + } + + 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>( @@ -689,7 +781,33 @@ where Ok(remainder) } + /// Draw lines at the given vertical offset, assuming that the lines they are replacing on the + /// screen are cleared. The slice of cells must contain enough cells for the requested lines. A + /// slice of the unused cells are returned. + #[cfg(feature = "scrolling-regions")] + fn draw_lines_over_cleared<'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 area = Rect::new(0, y_offset, width as u16, y_offset + lines_to_draw); + let old = Buffer::empty(area); + let new = Buffer { + area, + content: to_draw.to_vec(), + }; + self.backend.draw(old.diff(&new).into_iter())?; + self.backend.flush()?; + } + Ok(remainder) + } + /// Scroll the whole screen up by the given number of lines. + #[cfg(not(feature = "scrolling-regions"))] fn scroll_up(&mut self, lines_to_scroll: u16) -> io::Result<()> { if lines_to_scroll > 0 { self.set_cursor_position(Position::new( diff --git a/tests/terminal.rs b/tests/terminal.rs index cf799b5a..af29688e 100644 --- a/tests/terminal.rs +++ b/tests/terminal.rs @@ -106,6 +106,46 @@ fn terminal_insert_before_moves_viewport() -> Result<(), Box> { Ok(()) } +#[test] +#[cfg(feature = "scrolling-regions")] +fn terminal_insert_before_moves_viewport_does_not_clobber() -> Result<(), Box> { + // This is like terminal_insert_before_moves_viewport, except it draws first before calling + // insert_before, and doesn't draw again afterwards. When using scrolling regions, we + // shouldn't clobber the viewport. + + let backend = TestBackend::new(20, 5); + let mut terminal = Terminal::with_options( + backend, + TerminalOptions { + viewport: Viewport::Inline(1), + }, + )?; + + terminal.draw(|f| { + let paragraph = Paragraph::new("[---- Viewport ----]"); + f.render_widget(paragraph, f.area()); + })?; + + terminal.insert_before(2, |buf| { + Paragraph::new(vec![ + "------ Line 1 ------".into(), + "------ Line 2 ------".into(), + ]) + .render(buf.area, buf); + })?; + + terminal.backend().assert_scrollback_empty(); + terminal.backend().assert_buffer_lines([ + "------ Line 1 ------", + "------ Line 2 ------", + "[---- Viewport ----]", + " ", + " ", + ]); + + Ok(()) +} + #[test] fn terminal_insert_before_scrolls_on_large_input() -> Result<(), Box> { // When we have a terminal with 5 lines, and a single line viewport, if we insert many @@ -151,6 +191,51 @@ fn terminal_insert_before_scrolls_on_large_input() -> Result<(), Box> Ok(()) } +#[test] +#[cfg(feature = "scrolling-regions")] +fn terminal_insert_before_scrolls_on_large_input_does_not_clobber() -> Result<(), Box> { + // This is like terminal_insert_scrolls_on_large_input, except it draws first before calling + // insert_before, and doesn't draw again afterwards. When using scrolling regions, we + // shouldn't clobber the viewport. + + let backend = TestBackend::new(20, 5); + let mut terminal = Terminal::with_options( + backend, + TerminalOptions { + viewport: Viewport::Inline(1), + }, + )?; + + terminal.draw(|f| { + let paragraph = Paragraph::new("[---- Viewport ----]"); + f.render_widget(paragraph, f.area()); + })?; + + terminal.insert_before(5, |buf| { + Paragraph::new(vec![ + "------ Line 1 ------".into(), + "------ Line 2 ------".into(), + "------ Line 3 ------".into(), + "------ Line 4 ------".into(), + "------ Line 5 ------".into(), + ]) + .render(buf.area, buf); + })?; + + terminal + .backend() + .assert_scrollback_lines(["------ Line 1 ------"]); + terminal.backend().assert_buffer_lines([ + "------ Line 2 ------", + "------ Line 3 ------", + "------ Line 4 ------", + "------ Line 5 ------", + "[---- Viewport ----]", + ]); + + Ok(()) +} + #[test] fn terminal_insert_before_scrolls_on_many_inserts() -> Result<(), Box> { // This test ensures similar behaviour to `terminal_insert_before_scrolls_on_large_input` @@ -206,6 +291,60 @@ fn terminal_insert_before_scrolls_on_many_inserts() -> Result<(), Box Ok(()) } +#[test] +#[cfg(feature = "scrolling-regions")] +fn terminal_insert_before_scrolls_on_many_inserts_does_not_clobber() -> Result<(), Box> { + // This is like terminal_insert_before_scrolls_on_many_inserts, except it draws first before + // calling insert_before, and doesn't draw again afterwards. When using scrolling regions, we + // shouldn't clobber the viewport. + + let backend = TestBackend::new(20, 5); + let mut terminal = Terminal::with_options( + backend, + TerminalOptions { + viewport: Viewport::Inline(1), + }, + )?; + + terminal.draw(|f| { + let paragraph = Paragraph::new("[---- Viewport ----]"); + f.render_widget(paragraph, f.area()); + })?; + + terminal.insert_before(1, |buf| { + Paragraph::new(vec!["------ Line 1 ------".into()]).render(buf.area, buf); + })?; + + terminal.insert_before(1, |buf| { + Paragraph::new(vec!["------ Line 2 ------".into()]).render(buf.area, buf); + })?; + + terminal.insert_before(1, |buf| { + Paragraph::new(vec!["------ Line 3 ------".into()]).render(buf.area, buf); + })?; + + terminal.insert_before(1, |buf| { + Paragraph::new(vec!["------ Line 4 ------".into()]).render(buf.area, buf); + })?; + + terminal.insert_before(1, |buf| { + Paragraph::new(vec!["------ Line 5 ------".into()]).render(buf.area, buf); + })?; + + terminal + .backend() + .assert_scrollback_lines(["------ Line 1 ------"]); + terminal.backend().assert_buffer_lines([ + "------ Line 2 ------", + "------ Line 3 ------", + "------ Line 4 ------", + "------ Line 5 ------", + "[---- Viewport ----]", + ]); + + Ok(()) +} + #[test] fn terminal_insert_before_large_viewport() -> Result<(), Box> { // This test covers a bug previously present whereby doing an insert_before when the @@ -273,3 +412,73 @@ fn terminal_insert_before_large_viewport() -> Result<(), Box> { Ok(()) } + +#[test] +#[cfg(feature = "scrolling-regions")] +fn terminal_insert_before_large_viewport_does_not_clobber() -> Result<(), Box> { + // This is like terminal_insert_before_large_viewport, except it draws first before calling + // insert_before, and doesn't draw again afterwards. When using scrolling regions, we shouldn't + // clobber the viewport. + + let backend = TestBackend::new(20, 3); + let mut terminal = Terminal::with_options( + backend, + TerminalOptions { + viewport: Viewport::Inline(3), + }, + )?; + + terminal.draw(|f| { + let paragraph = Paragraph::new("Viewport") + .centered() + .block(Block::bordered()); + f.render_widget(paragraph, f.area()); + })?; + + 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.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(()) +}