fix: Align clear() semantics with contract (#2320)
This commit is contained in:
@@ -109,19 +109,28 @@ use crate::layout::{Position, Size};
|
|||||||
mod test;
|
mod test;
|
||||||
pub use self::test::TestBackend;
|
pub use self::test::TestBackend;
|
||||||
|
|
||||||
/// Enum representing the different types of clearing operations that can be performed
|
/// Defines which region of the terminal's visible display area is cleared.
|
||||||
/// on the terminal screen.
|
///
|
||||||
|
/// Clearing operates on character cells in the active display surface. It does not move, hide, or
|
||||||
|
/// reset the cursor position. If the cursor lies inside the cleared region, the character cell at
|
||||||
|
/// the cursor position is cleared as well.
|
||||||
|
///
|
||||||
|
/// Clearing applies to the terminal's visible display area, not just content previously drawn by
|
||||||
|
/// Ratatui. No guarantees are made about scrollback, history, or off-screen buffers.
|
||||||
#[derive(Debug, Display, EnumString, Clone, Copy, Eq, PartialEq, Hash)]
|
#[derive(Debug, Display, EnumString, Clone, Copy, Eq, PartialEq, Hash)]
|
||||||
pub enum ClearType {
|
pub enum ClearType {
|
||||||
/// Clear the entire screen.
|
/// Clears all character cells in the visible display area.
|
||||||
All,
|
All,
|
||||||
/// Clear everything after the cursor.
|
/// Clears all character cells from the cursor position (inclusive) through the end of the
|
||||||
|
/// display area.
|
||||||
AfterCursor,
|
AfterCursor,
|
||||||
/// Clear everything before the cursor.
|
/// Clears all character cells from the start of the display area through the cursor position
|
||||||
|
/// (inclusive).
|
||||||
BeforeCursor,
|
BeforeCursor,
|
||||||
/// Clear the current line.
|
/// Clears all character cells in the cursor's current line.
|
||||||
CurrentLine,
|
CurrentLine,
|
||||||
/// Clear everything from the cursor until the next newline.
|
/// Clears all character cells from the cursor position (inclusive) to the end of the current
|
||||||
|
/// line.
|
||||||
UntilNewLine,
|
UntilNewLine,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,7 +246,14 @@ pub trait Backend {
|
|||||||
self.set_cursor_position(Position { x, y })
|
self.set_cursor_position(Position { x, y })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clears the whole terminal screen
|
/// Clears all character cells in the terminal's visible display area.
|
||||||
|
///
|
||||||
|
/// This operation preserves the cursor position. If the cursor lies within the cleared
|
||||||
|
/// region, the character cell at the cursor position is cleared. No guarantees are made about
|
||||||
|
/// scrollback, history, or off-screen buffers.
|
||||||
|
///
|
||||||
|
/// This is equivalent to calling [`clear_region`](Self::clear_region) with
|
||||||
|
/// [`ClearType::All`].
|
||||||
///
|
///
|
||||||
/// # Example
|
/// # Example
|
||||||
///
|
///
|
||||||
@@ -251,7 +267,13 @@ pub trait Backend {
|
|||||||
/// ```
|
/// ```
|
||||||
fn clear(&mut self) -> Result<(), Self::Error>;
|
fn clear(&mut self) -> Result<(), Self::Error>;
|
||||||
|
|
||||||
/// Clears a specific region of the terminal specified by the [`ClearType`] parameter
|
/// Clears a specific region of the terminal's visible display area, as defined by
|
||||||
|
/// [`ClearType`].
|
||||||
|
///
|
||||||
|
/// This operation preserves the cursor position. If the cursor lies within the cleared
|
||||||
|
/// region, the character cell at the cursor position is cleared. Clearing applies to the
|
||||||
|
/// active display surface only and does not make guarantees about scrollback, history, or
|
||||||
|
/// off-screen buffers.
|
||||||
///
|
///
|
||||||
/// This method is optional and may not be implemented by all backends. The default
|
/// This method is optional and may not be implemented by all backends. The default
|
||||||
/// implementation calls [`clear`] if the `clear_type` is [`ClearType::All`] and returns an
|
/// implementation calls [`clear`] if the `clear_type` is [`ClearType::All`] and returns an
|
||||||
|
|||||||
@@ -288,12 +288,12 @@ impl Backend for TestBackend {
|
|||||||
let region = match clear_type {
|
let region = match clear_type {
|
||||||
ClearType::All => return self.clear(),
|
ClearType::All => return self.clear(),
|
||||||
ClearType::AfterCursor => {
|
ClearType::AfterCursor => {
|
||||||
let index = self.buffer.index_of(self.pos.0, self.pos.1) + 1;
|
let index = self.buffer.index_of(self.pos.0, self.pos.1);
|
||||||
&mut self.buffer.content[index..]
|
&mut self.buffer.content[index..]
|
||||||
}
|
}
|
||||||
ClearType::BeforeCursor => {
|
ClearType::BeforeCursor => {
|
||||||
let index = self.buffer.index_of(self.pos.0, self.pos.1);
|
let index = self.buffer.index_of(self.pos.0, self.pos.1);
|
||||||
&mut self.buffer.content[..index]
|
&mut self.buffer.content[..=index]
|
||||||
}
|
}
|
||||||
ClearType::CurrentLine => {
|
ClearType::CurrentLine => {
|
||||||
let line_start_index = self.buffer.index_of(0, self.pos.1);
|
let line_start_index = self.buffer.index_of(0, self.pos.1);
|
||||||
@@ -633,7 +633,7 @@ mod tests {
|
|||||||
backend.assert_buffer_lines([
|
backend.assert_buffer_lines([
|
||||||
"aaaaaaaaaa",
|
"aaaaaaaaaa",
|
||||||
"aaaaaaaaaa",
|
"aaaaaaaaaa",
|
||||||
"aaaa ",
|
"aaa ",
|
||||||
" ",
|
" ",
|
||||||
" ",
|
" ",
|
||||||
]);
|
]);
|
||||||
@@ -657,7 +657,7 @@ mod tests {
|
|||||||
" ",
|
" ",
|
||||||
" ",
|
" ",
|
||||||
" ",
|
" ",
|
||||||
" aaaaa",
|
" aaaa",
|
||||||
"aaaaaaaaaa",
|
"aaaaaaaaaa",
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use crate::backend::{Backend, ClearType};
|
use crate::backend::{Backend, ClearType};
|
||||||
use crate::buffer::Buffer;
|
use crate::buffer::{Buffer, Cell};
|
||||||
use crate::layout::Position;
|
use crate::layout::{Position, Rect};
|
||||||
use crate::terminal::{Frame, Terminal, Viewport};
|
use crate::terminal::{Frame, Terminal, Viewport};
|
||||||
|
|
||||||
impl<B: Backend> Terminal<B> {
|
impl<B: Backend> Terminal<B> {
|
||||||
@@ -101,41 +101,77 @@ impl<B: Backend> Terminal<B> {
|
|||||||
/// - [`Viewport::Inline`]: clears after the viewport's origin, leaving any content above the
|
/// - [`Viewport::Inline`]: clears after the viewport's origin, leaving any content above the
|
||||||
/// viewport untouched.
|
/// viewport untouched.
|
||||||
///
|
///
|
||||||
|
/// Current behavior: for [`Viewport::Inline`], clearing runs from the viewport origin through
|
||||||
|
/// the end of the visible display area, not just the viewport's rectangle. This is an
|
||||||
|
/// implementation detail rather than a contract; do not rely on it.
|
||||||
|
///
|
||||||
|
/// This preserves the cursor position.
|
||||||
|
///
|
||||||
/// This also resets the "previous" buffer so the next [`Terminal::flush`] redraws the full
|
/// This also resets the "previous" buffer so the next [`Terminal::flush`] redraws the full
|
||||||
/// viewport. [`Terminal::resize`] calls this internally.
|
/// viewport. [`Terminal::resize`] calls this internally.
|
||||||
///
|
///
|
||||||
/// Implementation note: this uses [`ClearType::AfterCursor`] starting at the viewport origin.
|
/// Implementation note: this uses [`ClearType::AfterCursor`] starting at the viewport origin.
|
||||||
pub fn clear(&mut self) -> Result<(), B::Error> {
|
pub fn clear(&mut self) -> Result<(), B::Error> {
|
||||||
|
let original_cursor = self.backend.get_cursor_position()?;
|
||||||
match self.viewport {
|
match self.viewport {
|
||||||
Viewport::Fullscreen => self.backend.clear_region(ClearType::All)?,
|
Viewport::Fullscreen => self.backend.clear_region(ClearType::All)?,
|
||||||
Viewport::Inline(_) => {
|
Viewport::Inline(_) => {
|
||||||
self.backend
|
self.backend
|
||||||
.set_cursor_position(self.viewport_area.as_position())?;
|
.set_cursor_position(self.viewport_area.as_position())?;
|
||||||
// TODO: `ClearType::AfterCursor` is exclusive of the cursor cell in `TestBackend`
|
|
||||||
// (and in terminals that interpret this as "after" rather than "from"), which can
|
|
||||||
// leave the viewport origin cell uncleared. Consider switching to a clear that
|
|
||||||
// includes the cursor cell when fixing clear semantics.
|
|
||||||
self.backend.clear_region(ClearType::AfterCursor)?;
|
self.backend.clear_region(ClearType::AfterCursor)?;
|
||||||
}
|
}
|
||||||
Viewport::Fixed(_) => {
|
Viewport::Fixed(_) => {
|
||||||
let area = self.viewport_area;
|
let area = self.viewport_area;
|
||||||
for y in area.top()..area.bottom() {
|
self.clear_fixed_viewport(area)?;
|
||||||
// TODO: Fixed viewports can start at x > 0 and have a limited width. Clearing
|
|
||||||
// from x = 0 clears outside the viewport. Consider clearing only within
|
|
||||||
// `viewport_area` (respecting both x offset and width) when fixing clear
|
|
||||||
// semantics.
|
|
||||||
self.backend.set_cursor_position(Position { x: 0, y })?;
|
|
||||||
// TODO: `ClearType::AfterCursor` is exclusive of the cursor cell in
|
|
||||||
// `TestBackend`, so the first cell of each cleared row can remain. Consider a
|
|
||||||
// clear mode that includes the cursor cell when fixing clear semantics.
|
|
||||||
self.backend.clear_region(ClearType::AfterCursor)?;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
self.backend.set_cursor_position(original_cursor)?;
|
||||||
// Reset the back buffer to make sure the next update will redraw everything.
|
// Reset the back buffer to make sure the next update will redraw everything.
|
||||||
self.buffers[1 - self.current].reset();
|
self.buffers[1 - self.current].reset();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clears a fixed viewport using terminal clear commands when possible.
|
||||||
|
///
|
||||||
|
/// Terminal clear commands can be faster than per-cell updates.
|
||||||
|
fn clear_fixed_viewport(&mut self, area: Rect) -> Result<(), B::Error> {
|
||||||
|
if area.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let size = self.backend.size()?;
|
||||||
|
let is_full_width = area.x == 0 && area.width == size.width;
|
||||||
|
let ends_at_bottom = area.bottom() == size.height;
|
||||||
|
if is_full_width && ends_at_bottom {
|
||||||
|
self.backend.set_cursor_position(area.as_position())?;
|
||||||
|
self.backend.clear_region(ClearType::AfterCursor)?;
|
||||||
|
} else if is_full_width {
|
||||||
|
self.clear_full_width_rows(area)?;
|
||||||
|
} else {
|
||||||
|
self.clear_region_cells(area)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears full-width rows using line clear commands.
|
||||||
|
///
|
||||||
|
/// This avoids per-cell writes when the viewport spans the full width.
|
||||||
|
fn clear_full_width_rows(&mut self, area: Rect) -> Result<(), B::Error> {
|
||||||
|
for y in area.top()..area.bottom() {
|
||||||
|
self.backend.set_cursor_position(Position { x: 0, y })?;
|
||||||
|
self.backend.clear_region(ClearType::CurrentLine)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears a non-full-width region by writing empty cells directly.
|
||||||
|
///
|
||||||
|
/// This is used when line-based clears would affect cells outside the viewport.
|
||||||
|
fn clear_region_cells(&mut self, area: Rect) -> Result<(), B::Error> {
|
||||||
|
let clear_cell = Cell::default();
|
||||||
|
let updates = area.positions().map(|pos| (pos.x, pos.y, &clear_cell));
|
||||||
|
self.backend.draw(updates)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -222,83 +258,123 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn clear_inline_clears_after_viewport_origin_and_resets_back_buffer() {
|
fn clear_inline_clears_after_viewport_origin_and_resets_back_buffer() {
|
||||||
// Characterization test:
|
|
||||||
// The current implementation clears using ClearType::AfterCursor, which is exclusive of
|
|
||||||
// the cursor cell. This yields somewhat surprising results (the origin cell is left
|
|
||||||
// untouched). We'll fix the clear semantics later; this test locks down current behavior.
|
|
||||||
//
|
|
||||||
// Inline clear is implemented as:
|
// Inline clear is implemented as:
|
||||||
// 1) move the backend cursor to the viewport origin
|
// 1) move the backend cursor to the viewport origin
|
||||||
// 2) call ClearType::AfterCursor once
|
// 2) call ClearType::AfterCursor once
|
||||||
//
|
let mut backend = TestBackend::with_lines([
|
||||||
// Note: TestBackend's ClearType::AfterCursor clears *after the cursor position*, keeping
|
"before 1 ",
|
||||||
// the cell at the cursor intact, and clears through the end of the screen buffer.
|
"before 2 ",
|
||||||
let mut backend = TestBackend::with_lines(["aaa", "bbb", "ccc"]);
|
"viewport 1",
|
||||||
backend.set_cursor_position((0, 1)).unwrap();
|
"viewport 2",
|
||||||
let mut terminal = Terminal::with_options(
|
"after 1 ",
|
||||||
backend,
|
"after 2 ",
|
||||||
TerminalOptions {
|
]);
|
||||||
viewport: Viewport::Inline(1),
|
backend
|
||||||
},
|
.set_cursor_position(Position { x: 2, y: 2 })
|
||||||
)
|
.unwrap();
|
||||||
.unwrap();
|
let options = TerminalOptions {
|
||||||
|
viewport: Viewport::Inline(2),
|
||||||
|
};
|
||||||
|
let mut terminal = Terminal::with_options(backend, options).unwrap();
|
||||||
|
terminal
|
||||||
|
.backend_mut()
|
||||||
|
.set_cursor_position(Position { x: 2, y: 2 })
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
terminal.buffers[1][(2, 1)] = Cell::new("x");
|
terminal.buffers[1][(2, 2)] = Cell::new("x");
|
||||||
terminal.clear().unwrap();
|
terminal.clear().unwrap();
|
||||||
|
|
||||||
terminal
|
// Inline viewport is anchored to the cursor row (y = 2) with height 2. Clear runs from
|
||||||
.backend()
|
// the viewport origin through the end of the display, including the rows after it.
|
||||||
.assert_buffer_lines(["aaa", "b ", " "]);
|
terminal.backend().assert_buffer_lines([
|
||||||
|
"before 1 ",
|
||||||
|
"before 2 ",
|
||||||
|
" ",
|
||||||
|
" ",
|
||||||
|
" ",
|
||||||
|
" ",
|
||||||
|
]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
terminal.buffers[1 - terminal.current],
|
terminal.buffers[1 - terminal.current],
|
||||||
Buffer::empty(terminal.viewport_area)
|
Buffer::empty(terminal.viewport_area)
|
||||||
);
|
);
|
||||||
// The inline branch also explicitly sets the cursor to the viewport origin before
|
|
||||||
// clearing, so the backend cursor ends up at that origin.
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
terminal.backend().cursor_position(),
|
terminal.backend().cursor_position(),
|
||||||
Position { x: 0, y: 1 }
|
Position { x: 2, y: 2 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn clear_fixed_clears_viewport_rows_and_resets_back_buffer() {
|
fn clear_fixed_clears_viewport_rows_and_resets_back_buffer() {
|
||||||
// Characterization test:
|
// For full-width fixed viewports that reach the terminal bottom, clear uses
|
||||||
// The current implementation clears using ClearType::AfterCursor, which is exclusive of
|
// ClearType::AfterCursor starting at the viewport origin.
|
||||||
// the cursor cell. This yields somewhat surprising results (each row's first cell is left
|
let mut backend = TestBackend::with_lines(["before 1 ", "viewport 1", "viewport 2"]);
|
||||||
// untouched, and TestBackend clears through the end of the screen). We'll fix the clear
|
backend.set_cursor_position((2, 0)).unwrap();
|
||||||
// semantics later; this test locks down current behavior.
|
let options = TerminalOptions {
|
||||||
//
|
viewport: Viewport::Fixed(Rect::new(0, 1, 10, 2)),
|
||||||
// Fixed clear is implemented as: for each viewport row, set the cursor to the start of
|
};
|
||||||
// the row (x = 0) and call ClearType::AfterCursor.
|
let mut terminal = Terminal::with_options(backend, options).unwrap();
|
||||||
//
|
|
||||||
// Note: TestBackend's ClearType::AfterCursor clears from *after the cursor* through the
|
|
||||||
// end of the screen buffer (not just the current line). That means the first iteration
|
|
||||||
// clears everything below the viewport's first row too.
|
|
||||||
let backend = TestBackend::with_lines(["aaa", "bbb", "ccc"]);
|
|
||||||
let mut terminal = Terminal::with_options(
|
|
||||||
backend,
|
|
||||||
TerminalOptions {
|
|
||||||
viewport: Viewport::Fixed(Rect::new(0, 1, 3, 2)),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
terminal.buffers[1][(2, 1)] = Cell::new("x");
|
|
||||||
terminal.clear().unwrap();
|
terminal.clear().unwrap();
|
||||||
|
|
||||||
terminal
|
terminal
|
||||||
.backend()
|
.backend()
|
||||||
.assert_buffer_lines(["aaa", "b ", " "]);
|
.assert_buffer_lines(["before 1 ", " ", " "]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
terminal.buffers[1 - terminal.current],
|
terminal.buffers[1 - terminal.current],
|
||||||
Buffer::empty(terminal.viewport_area)
|
Buffer::empty(terminal.viewport_area)
|
||||||
);
|
);
|
||||||
// The fixed branch sets the cursor for each row it processes; after the loop, the cursor
|
|
||||||
// is left at the start of the last processed row.
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
terminal.backend().cursor_position(),
|
terminal.backend().cursor_position(),
|
||||||
Position { x: 0, y: 2 }
|
Position { x: 2, y: 0 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clear_fixed_full_width_not_at_bottom() {
|
||||||
|
let mut backend =
|
||||||
|
TestBackend::with_lines(["before 1 ", "viewport 1", "viewport 2", "after 1 "]);
|
||||||
|
backend.set_cursor_position((1, 0)).unwrap();
|
||||||
|
let options = TerminalOptions {
|
||||||
|
viewport: Viewport::Fixed(Rect::new(0, 1, 10, 2)),
|
||||||
|
};
|
||||||
|
let mut terminal = Terminal::with_options(backend, options).unwrap();
|
||||||
|
|
||||||
|
terminal.clear().unwrap();
|
||||||
|
|
||||||
|
terminal.backend().assert_buffer_lines([
|
||||||
|
"before 1 ",
|
||||||
|
" ",
|
||||||
|
" ",
|
||||||
|
"after 1 ",
|
||||||
|
]);
|
||||||
|
assert_eq!(
|
||||||
|
terminal.backend().cursor_position(),
|
||||||
|
Position { x: 1, y: 0 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clear_fixed_respects_non_full_width_viewport() {
|
||||||
|
let mut backend =
|
||||||
|
TestBackend::with_lines(["before 1 ", "viewport 1", "viewport 2", "after 1 "]);
|
||||||
|
backend.set_cursor_position((3, 0)).unwrap();
|
||||||
|
let options = TerminalOptions {
|
||||||
|
viewport: Viewport::Fixed(Rect::new(1, 1, 3, 2)),
|
||||||
|
};
|
||||||
|
let mut terminal = Terminal::with_options(backend, options).unwrap();
|
||||||
|
|
||||||
|
terminal.clear().unwrap();
|
||||||
|
|
||||||
|
terminal.backend().assert_buffer_lines([
|
||||||
|
"before 1 ",
|
||||||
|
"v port 1",
|
||||||
|
"v port 2",
|
||||||
|
"after 1 ",
|
||||||
|
]);
|
||||||
|
assert_eq!(
|
||||||
|
terminal.backend().cursor_position(),
|
||||||
|
Position { x: 3, y: 0 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -572,7 +572,7 @@ mod tests {
|
|||||||
"1111111111",
|
"1111111111",
|
||||||
"2222222222",
|
"2222222222",
|
||||||
"INSERTLINE",
|
"INSERTLINE",
|
||||||
"4 ",
|
" ",
|
||||||
" ",
|
" ",
|
||||||
" ",
|
" ",
|
||||||
" ",
|
" ",
|
||||||
@@ -639,7 +639,7 @@ mod tests {
|
|||||||
"5555555555",
|
"5555555555",
|
||||||
"INSERTED1 ",
|
"INSERTED1 ",
|
||||||
"INSERTED2 ",
|
"INSERTED2 ",
|
||||||
"8 ",
|
" ",
|
||||||
" ",
|
" ",
|
||||||
" ",
|
" ",
|
||||||
" ",
|
" ",
|
||||||
|
|||||||
Reference in New Issue
Block a user