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(()) +}