fix: Align clear() semantics with contract (#2320)

This commit is contained in:
Josh McKinney
2026-01-13 04:46:06 -08:00
committed by GitHub
parent 5f0331ec89
commit 720303e806
4 changed files with 179 additions and 81 deletions

View File

@@ -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

View File

@@ -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",
]); ]);
} }

View File

@@ -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 }
); );
} }
} }

View File

@@ -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 ", " ",
" ", " ",
" ", " ",
" ", " ",