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