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.
This commit is contained in:
Neal Fachan 2024-10-14 15:46:13 -07:00 committed by GitHub
parent 7bdccce3d5
commit d72968d86b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 785 additions and 26 deletions

View file

@ -138,6 +138,10 @@ macros = []
## enables conversions from colors in the [`palette`] crate to [`Color`](crate::style::Color). ## enables conversions from colors in the [`palette`] crate to [`Color`](crate::style::Color).
palette = ["dep:palette"] 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. ## enables all widgets.
all-widgets = ["widget-calendar"] all-widgets = ["widget-calendar"]

View file

@ -327,6 +327,64 @@ pub trait Backend {
/// Flush any buffered content to the terminal screen. /// Flush any buffered content to the terminal screen.
fn flush(&mut self) -> io::Result<()>; 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<u16>, 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<u16>,
line_count: u16,
) -> io::Result<()>;
} }
#[cfg(test)] #[cfg(test)]

View file

@ -274,6 +274,32 @@ where
fn flush(&mut self) -> io::Result<()> { fn flush(&mut self) -> io::Result<()> {
self.writer.flush() self.writer.flush()
} }
#[cfg(feature = "scrolling-regions")]
fn scroll_region_up(&mut self, region: std::ops::Range<u16>, 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<u16>, 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<Color> for CColor { impl From<Color> for CColor {
@ -485,6 +511,102 @@ impl From<ContentStyle> 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View file

@ -239,6 +239,30 @@ where
fn flush(&mut self) -> io::Result<()> { fn flush(&mut self) -> io::Result<()> {
self.writer.flush() self.writer.flush()
} }
#[cfg(feature = "scrolling-regions")]
fn scroll_region_up(&mut self, region: std::ops::Range<u16>, 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<u16>, 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); struct Fg(Color);
@ -468,6 +492,26 @@ impl From<termion::style::Reset> 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View file

@ -250,6 +250,52 @@ impl Backend for TermwizBackend {
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
Ok(()) Ok(())
} }
#[cfg(feature = "scrolling-regions")]
fn scroll_region_up(&mut self, region: std::ops::Range<u16>, 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<u16>, 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<CellAttributes> for Style { impl From<CellAttributes> for Style {

View file

@ -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<crate::text::Line<'line>>,
{
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`. /// Returns a reference to the internal buffer of the `TestBackend`.
pub const fn buffer(&self) -> &Buffer { pub const fn buffer(&self) -> &Buffer {
&self.buffer &self.buffer
@ -343,6 +365,77 @@ impl Backend for TestBackend {
fn flush(&mut self) -> io::Result<()> { fn flush(&mut self) -> io::Result<()> {
Ok(()) Ok(())
} }
#[cfg(feature = "scrolling-regions")]
fn scroll_region_up(&mut self, region: std::ops::Range<u16>, 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<u16>,
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 /// 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] #[test]
fn clear_region_all() { fn clear_region_all() {
let mut backend = TestBackend::new(10, 5); let mut backend = TestBackend::with_lines([
backend.buffer = Buffer::with_lines([
"aaaaaaaaaa", "aaaaaaaaaa",
"aaaaaaaaaa", "aaaaaaaaaa",
"aaaaaaaaaa", "aaaaaaaaaa",
@ -513,8 +605,7 @@ mod tests {
#[test] #[test]
fn clear_region_after_cursor() { fn clear_region_after_cursor() {
let mut backend = TestBackend::new(10, 5); let mut backend = TestBackend::with_lines([
backend.buffer = Buffer::with_lines([
"aaaaaaaaaa", "aaaaaaaaaa",
"aaaaaaaaaa", "aaaaaaaaaa",
"aaaaaaaaaa", "aaaaaaaaaa",
@ -537,8 +628,7 @@ mod tests {
#[test] #[test]
fn clear_region_before_cursor() { fn clear_region_before_cursor() {
let mut backend = TestBackend::new(10, 5); let mut backend = TestBackend::with_lines([
backend.buffer = Buffer::with_lines([
"aaaaaaaaaa", "aaaaaaaaaa",
"aaaaaaaaaa", "aaaaaaaaaa",
"aaaaaaaaaa", "aaaaaaaaaa",
@ -561,8 +651,7 @@ mod tests {
#[test] #[test]
fn clear_region_current_line() { fn clear_region_current_line() {
let mut backend = TestBackend::new(10, 5); let mut backend = TestBackend::with_lines([
backend.buffer = Buffer::with_lines([
"aaaaaaaaaa", "aaaaaaaaaa",
"aaaaaaaaaa", "aaaaaaaaaa",
"aaaaaaaaaa", "aaaaaaaaaa",
@ -585,8 +674,7 @@ mod tests {
#[test] #[test]
fn clear_region_until_new_line() { fn clear_region_until_new_line() {
let mut backend = TestBackend::new(10, 5); let mut backend = TestBackend::with_lines([
backend.buffer = Buffer::with_lines([
"aaaaaaaaaa", "aaaaaaaaaa",
"aaaaaaaaaa", "aaaaaaaaaa",
"aaaaaaaaaa", "aaaaaaaaaa",
@ -609,8 +697,7 @@ mod tests {
#[test] #[test]
fn append_lines_not_at_last_line() { fn append_lines_not_at_last_line() {
let mut backend = TestBackend::new(10, 5); let mut backend = TestBackend::with_lines([
backend.buffer = Buffer::with_lines([
"aaaaaaaaaa", "aaaaaaaaaa",
"bbbbbbbbbb", "bbbbbbbbbb",
"cccccccccc", "cccccccccc",
@ -648,8 +735,7 @@ mod tests {
#[test] #[test]
fn append_lines_at_last_line() { fn append_lines_at_last_line() {
let mut backend = TestBackend::new(10, 5); let mut backend = TestBackend::with_lines([
backend.buffer = Buffer::with_lines([
"aaaaaaaaaa", "aaaaaaaaaa",
"bbbbbbbbbb", "bbbbbbbbbb",
"cccccccccc", "cccccccccc",
@ -681,8 +767,7 @@ mod tests {
#[test] #[test]
fn append_multiple_lines_not_at_last_line() { fn append_multiple_lines_not_at_last_line() {
let mut backend = TestBackend::new(10, 5); let mut backend = TestBackend::with_lines([
backend.buffer = Buffer::with_lines([
"aaaaaaaaaa", "aaaaaaaaaa",
"bbbbbbbbbb", "bbbbbbbbbb",
"cccccccccc", "cccccccccc",
@ -711,8 +796,7 @@ mod tests {
#[test] #[test]
fn append_multiple_lines_past_last_line() { fn append_multiple_lines_past_last_line() {
let mut backend = TestBackend::new(10, 5); let mut backend = TestBackend::with_lines([
backend.buffer = Buffer::with_lines([
"aaaaaaaaaa", "aaaaaaaaaa",
"bbbbbbbbbb", "bbbbbbbbbb",
"cccccccccc", "cccccccccc",
@ -739,8 +823,7 @@ mod tests {
#[test] #[test]
fn append_multiple_lines_where_cursor_at_end_appends_height_lines() { fn append_multiple_lines_where_cursor_at_end_appends_height_lines() {
let mut backend = TestBackend::new(10, 5); let mut backend = TestBackend::with_lines([
backend.buffer = Buffer::with_lines([
"aaaaaaaaaa", "aaaaaaaaaa",
"bbbbbbbbbb", "bbbbbbbbbb",
"cccccccccc", "cccccccccc",
@ -773,8 +856,7 @@ mod tests {
#[test] #[test]
fn append_multiple_lines_where_cursor_appends_height_lines() { fn append_multiple_lines_where_cursor_appends_height_lines() {
let mut backend = TestBackend::new(10, 5); let mut backend = TestBackend::with_lines([
backend.buffer = Buffer::with_lines([
"aaaaaaaaaa", "aaaaaaaaaa",
"bbbbbbbbbb", "bbbbbbbbbb",
"cccccccccc", "cccccccccc",
@ -799,8 +881,7 @@ mod tests {
#[test] #[test]
fn append_multiple_lines_where_cursor_at_end_appends_more_than_height_lines() { fn append_multiple_lines_where_cursor_at_end_appends_more_than_height_lines() {
let mut backend = TestBackend::new(10, 5); let mut backend = TestBackend::with_lines([
backend.buffer = Buffer::with_lines([
"aaaaaaaaaa", "aaaaaaaaaa",
"bbbbbbbbbb", "bbbbbbbbbb",
"cccccccccc", "cccccccccc",
@ -916,4 +997,81 @@ mod tests {
let mut backend = TestBackend::new(10, 2); let mut backend = TestBackend::new(10, 2);
backend.flush().unwrap(); 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<const L: usize, const M: usize, const N: usize>(
#[case] initial_screen: [&'static str; L],
#[case] range: std::ops::Range<u16>,
#[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<const M: usize, const N: usize>(
#[case] initial_screen: [&'static str; M],
#[case] range: std::ops::Range<u16>,
#[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);
}
}
} }

View file

@ -579,10 +579,22 @@ where
where where
F: FnOnce(&mut Buffer), F: FnOnce(&mut Buffer),
{ {
if !matches!(self.viewport, Viewport::Inline(_)) { match self.viewport {
return Ok(()); #[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 // 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 // temporary buffer, and then to loop drawing chunks from the buffer to the screen. drawing
// this buffer onto the screen. // this buffer onto the screen.
@ -668,6 +680,86 @@ where
Ok(()) 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 /// 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. /// for the requested lines. A slice of the unused cells are returned.
fn draw_lines<'a>( fn draw_lines<'a>(
@ -689,7 +781,33 @@ where
Ok(remainder) 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. /// 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<()> { fn scroll_up(&mut self, lines_to_scroll: u16) -> io::Result<()> {
if lines_to_scroll > 0 { if lines_to_scroll > 0 {
self.set_cursor_position(Position::new( self.set_cursor_position(Position::new(

View file

@ -106,6 +106,46 @@ fn terminal_insert_before_moves_viewport() -> Result<(), Box<dyn Error>> {
Ok(()) Ok(())
} }
#[test]
#[cfg(feature = "scrolling-regions")]
fn terminal_insert_before_moves_viewport_does_not_clobber() -> Result<(), Box<dyn Error>> {
// 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] #[test]
fn terminal_insert_before_scrolls_on_large_input() -> Result<(), Box<dyn Error>> { fn terminal_insert_before_scrolls_on_large_input() -> Result<(), Box<dyn Error>> {
// When we have a terminal with 5 lines, and a single line viewport, if we insert many // 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<dyn Error>>
Ok(()) Ok(())
} }
#[test]
#[cfg(feature = "scrolling-regions")]
fn terminal_insert_before_scrolls_on_large_input_does_not_clobber() -> Result<(), Box<dyn Error>> {
// 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] #[test]
fn terminal_insert_before_scrolls_on_many_inserts() -> Result<(), Box<dyn Error>> { fn terminal_insert_before_scrolls_on_many_inserts() -> Result<(), Box<dyn Error>> {
// This test ensures similar behaviour to `terminal_insert_before_scrolls_on_large_input` // 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<dyn Error>
Ok(()) Ok(())
} }
#[test]
#[cfg(feature = "scrolling-regions")]
fn terminal_insert_before_scrolls_on_many_inserts_does_not_clobber() -> Result<(), Box<dyn Error>> {
// 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] #[test]
fn terminal_insert_before_large_viewport() -> Result<(), Box<dyn Error>> { fn terminal_insert_before_large_viewport() -> Result<(), Box<dyn Error>> {
// This test covers a bug previously present whereby doing an insert_before when the // 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<dyn Error>> {
Ok(()) Ok(())
} }
#[test]
#[cfg(feature = "scrolling-regions")]
fn terminal_insert_before_large_viewport_does_not_clobber() -> Result<(), Box<dyn Error>> {
// 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(())
}