test(core): Split Terminal into submodules and expand test coverage (#2315)

- Split the `Terminal` implementation into focused submodules to improve
readability and maintainability.
- Add characterization tests covering `Terminal` initialization, buffer
lifecycle, resizing and autoresize behavior, and rendering paths.
- Add inline viewport tests for `compute_inline_size` and
`insert_before` in both fallback and scrolling-regions modes, including
an end-to-end `draw -> insert_before -> draw scenario` scenario.
- Extend `TestBackend` cursor plumbing to support the new terminal tests
and assert cursor/ behavior.
This commit is contained in:
Josh McKinney
2026-01-01 11:46:33 -08:00
committed by GitHub
parent 64d964b259
commit b696ea37b2
10 changed files with 3071 additions and 1403 deletions

View File

@@ -105,6 +105,19 @@ impl TestBackend {
&self.buffer
}
/// Returns whether the cursor is visible.
pub const fn cursor_visible(&self) -> bool {
self.cursor
}
/// Returns the current cursor position.
pub const fn cursor_position(&self) -> Position {
Position {
x: self.pos.0,
y: self.pos.1,
}
}
/// 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,

View File

@@ -31,10 +31,381 @@
//! [`Backend`]: crate::backend::Backend
//! [`Buffer`]: crate::buffer::Buffer
mod backend;
mod buffers;
mod cursor;
mod frame;
mod terminal;
mod init;
mod inline;
mod render;
mod resize;
mod viewport;
pub use frame::{CompletedFrame, Frame};
pub use terminal::{Options as TerminalOptions, Terminal};
pub use viewport::Viewport;
use crate::backend::Backend;
use crate::buffer::Buffer;
use crate::layout::{Position, Rect};
/// An interface to interact and draw [`Frame`]s on the user's terminal.
///
/// This is the main entry point for Ratatui. It is responsible for drawing and maintaining the
/// state of the buffers, cursor and viewport.
///
/// If you're building a fullscreen application with the `ratatui` crate's default backend
/// ([Crossterm]), prefer [`ratatui::run`] (or [`ratatui::init`] + [`ratatui::restore`]) over
/// constructing `Terminal` directly. These helpers enable common terminal modes (raw mode +
/// alternate screen) and restore them on exit and on panic.
///
/// ```rust,ignore
/// ratatui::run(|terminal| {
/// let mut should_quit = false;
/// while !should_quit {
/// terminal.draw(|frame| {
/// frame.render_widget("Hello, World!", frame.area());
/// })?;
///
/// // Handle events, update application state, and set `should_quit = true` to exit.
/// }
/// Ok(())
/// })?;
/// ```
///
/// # Typical Usage
///
/// In a typical application, the flow is: set up a terminal, run an event loop, update state, and
/// draw each frame.
///
/// 1. Choose a setup path for a `Terminal`. Most apps call [`ratatui::run`], which passes a
/// preconfigured `Terminal` into your callback. If you need more control, use [`ratatui::init`]
/// and [`ratatui::restore`], or construct a `Terminal` manually via [`Terminal::new`]
/// (fullscreen) or [`Terminal::with_options`] (select a [`Viewport`]).
/// 2. Enter your application's event loop and call [`Terminal::draw`] (or [`Terminal::try_draw`])
/// to render the current UI state into a [`Frame`].
/// 3. Handle input and application state updates between draw calls.
/// 4. If the terminal is resized, call [`Terminal::draw`] again. Ratatui automatically resizes
/// fullscreen and inline viewports during `draw`; fixed viewports require an explicit call to
/// [`Terminal::resize`] if you want the region to change.
///
/// # Rendering Pipeline
///
/// A single call to [`Terminal::draw`] (or [`Terminal::try_draw`]) represents one render pass. In
/// broad strokes, Ratatui:
///
/// 1. Checks whether the underlying terminal size changed (see [`Terminal::autoresize`]).
/// 2. Creates a [`Frame`] backed by the current buffer (see [`Terminal::get_frame`]).
/// 3. Runs your render callback to populate that buffer.
/// 4. Diffs the current buffer against the previous buffer and writes the changes (see
/// [`Terminal::flush`]).
/// 5. Applies cursor visibility and position requested by the frame (see
/// [`Frame::set_cursor_position`]).
/// 6. Swaps the buffers to prepare for the next render pass (see [`Terminal::swap_buffers`]).
/// 7. Flushes the backend (see [`Backend::flush`]).
///
/// Each render pass starts with an empty buffer for the current viewport. Your render callback
/// should render everything that should be visible in [`Frame::area`], even if it is unchanged
/// from the previous frame. Ratatui diffs the current and previous buffers and only writes the
/// changes; anything you don't render is treated as empty and may clear previously drawn content.
///
/// If the viewport size changes between render passes (for example via [`Terminal::autoresize`] or
/// an explicit [`Terminal::resize`]), Ratatui clears the viewport and resets the previous buffer so
/// the next `draw` is treated as a full redraw.
///
/// Most applications should use [`Terminal::draw`] / [`Terminal::try_draw`]. For manual rendering
/// (primarily for tests), you can build a frame with [`Terminal::get_frame`], write diffs with
/// [`Terminal::flush`], then call [`Terminal::swap_buffers`]. If your backend buffers output, also
/// call [`Backend::flush`].
///
/// ```rust,no_run
/// # mod ratatui {
/// # pub use ratatui_core::backend;
/// # pub use ratatui_core::terminal::Terminal;
/// # }
/// use ratatui::Terminal;
/// use ratatui::backend::{Backend, TestBackend};
///
/// let backend = TestBackend::new(10, 10);
/// let mut terminal = Terminal::new(backend)?;
///
/// // Manual render pass (roughly what `Terminal::draw` does internally).
/// {
/// let mut frame = terminal.get_frame();
/// frame.render_widget("Hello World!", frame.area());
/// }
///
/// terminal.flush()?;
/// terminal.swap_buffers();
/// terminal.backend_mut().flush()?;
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
///
/// # Viewports
///
/// The viewport controls *where* Ratatui draws and therefore what [`Frame::area`] represents.
/// Most applications use [`Viewport::Fullscreen`], but Ratatui also supports [`Viewport::Inline`]
/// and [`Viewport::Fixed`].
///
/// Choose a viewport at initialization time with [`Terminal::with_options`] and
/// [`TerminalOptions`].
///
/// In [`Viewport::Fullscreen`], the viewport is the entire terminal and `Frame::area` starts at
/// (0, 0). Ratatui automatically resizes the internal buffers when the terminal size changes.
///
/// In [`Viewport::Fixed`], the viewport is a user-provided [`Rect`] in terminal coordinates.
/// `Frame::area` is that exact rectangle (including its `x`/`y` offset). Fixed viewports are not
/// automatically resized; if the region should change, call [`Terminal::resize`].
///
/// In [`Viewport::Inline`], Ratatui draws into a rectangle anchored to where the UI started. This
/// mode is described in more detail in the "Inline Viewport" section below.
///
/// ```rust,ignore
/// use ratatui::{layout::Rect, Terminal, TerminalOptions, Viewport};
/// use ratatui::backend::CrosstermBackend;
///
/// // Fullscreen (most common):
/// let fullscreen = Terminal::new(CrosstermBackend::new(std::io::stdout()))?;
///
/// // Fixed region (your app manages the coordinates):
/// let viewport = Viewport::Fixed(Rect::new(0, 0, 30, 10));
/// let fixed = Terminal::with_options(
/// CrosstermBackend::new(std::io::stdout()),
/// TerminalOptions { viewport },
/// )?;
/// ```
///
/// Applications should detect terminal resizes and call [`Terminal::draw`] to redraw the
/// application with the new size. This will automatically resize the internal buffers to match the
/// new size for inline and fullscreen viewports. Fixed viewports are not resized automatically.
///
/// # Inline Viewport
///
/// Inline mode is designed for applications that want to embed a UI into a larger CLI flow. In
/// [`Viewport::Inline`], Ratatui anchors the viewport to the backend cursor row at initialization
/// time and always starts drawing at column 0.
///
/// To reserve vertical space for the requested height, Ratatui may append lines. When the cursor is
/// near the bottom edge, terminals scroll; Ratatui accounts for that scrolling by shifting the
/// computed viewport origin upward so the viewport stays fully visible.
///
/// While running in inline mode, [`Terminal::insert_before`] can be used to print output above the
/// viewport without disturbing the UI.
/// When Ratatui is built with the `scrolling-regions` feature, `insert_before` can do this without
/// clearing and redrawing the viewport.
///
/// ```rust,ignore
/// use ratatui::{TerminalOptions, Viewport};
///
/// println!("Some output above the UI");
///
/// let options = TerminalOptions {
/// viewport: Viewport::Inline(10),
/// };
/// let mut terminal = ratatui::try_init_with_options(options)?;
///
/// terminal.insert_before(1, |buf| {
/// // Render a single line of output into `buf` before the UI.
/// // (For example: logs, status updates, or command output.)
/// })?;
/// ```
///
/// # More Information
///
/// - Choosing a viewport: [`Terminal::with_options`], [`TerminalOptions`], and [`Viewport`]
/// - The rendering pipeline: [`Terminal::draw`] and [`Terminal::try_draw`]
/// - Resize handling: [`Terminal::autoresize`] and [`Terminal::resize`]
/// - Manual rendering and testing: [`Terminal::get_frame`], [`Terminal::flush`], and
/// [`Terminal::swap_buffers`]
/// - Printing above an inline UI: [`Terminal::insert_before`]
///
/// # Initialization
///
/// Most interactive TUIs need process-wide terminal setup (for example: raw mode and an alternate
/// screen) and matching teardown on exit and on panic. In Ratatui, that setup lives in the
/// `ratatui` crate; `Terminal` itself focuses on rendering and does not implicitly change those
/// modes.
///
/// If you're using the `ratatui` crate with its default backend ([Crossterm]), there are three
/// common entry points:
///
/// - [`ratatui::run`]: recommended for most applications. Provides a [`ratatui::DefaultTerminal`],
/// runs your closure, and restores terminal state on exit and on panic.
/// - [`ratatui::init`] + [`ratatui::restore`]: like `run`, but you control the event loop and
/// decide when to restore.
/// - [`Terminal::new`] / [`Terminal::with_options`]: manual construction (for example: custom
/// backends such as [Termion] / [Termwiz], inline UIs, or fixed viewports). You are responsible
/// for terminal mode setup and teardown.
///
/// [`ratatui::run`] was introduced in Ratatui 0.30, so older tutorials may use `init`/`restore` or
/// manual construction.
///
/// Some applications install a custom panic hook to log a crash report, print a friendlier error,
/// or integrate with error reporting. If you do, install it before calling [`ratatui::init`] /
/// [`ratatui::run`]. Ratatui wraps the current hook so it can restore terminal state first (for
/// example: leaving the alternate screen and disabling raw mode) and then delegate to your hook.
///
/// Crossterm is cross-platform and is what most Ratatui applications use by default. Ratatui also
/// supports other backends such as [Termion] and [Termwiz], and third-party backends can integrate
/// by implementing [`Backend`].
///
/// # How it works
///
/// `Terminal` ties together a [`Backend`], a [`Viewport`], and a double-buffered diffing renderer.
/// The high-level flow is described in the "Rendering Pipeline" section above; this section focuses
/// on how that pipeline is implemented.
///
/// `Terminal` is generic over a [`Backend`] implementation and does not depend on a particular
/// terminal library. It relies on the backend to:
///
/// - report the current screen size (used by [`Terminal::autoresize`])
/// - draw cell updates (used by [`Terminal::flush`])
/// - clear regions (used by [`Terminal::clear`] and [`Terminal::resize`])
/// - move and show/hide the cursor (used by [`Terminal::try_draw`])
/// - optionally append lines (used by inline viewports and by [`Terminal::insert_before`])
///
/// ## Buffers and diffing
///
/// The `Terminal` maintains two [`Buffer`]s sized to the current viewport. During a render pass,
/// widgets draw into the "current" buffer via the [`Frame`] passed to your callback. At the end of
/// the pass, [`Terminal::flush`] diffs the current buffer against the previous buffer and sends
/// only the changed cells to the backend.
///
/// After flushing, [`Terminal::swap_buffers`] flips which buffer is considered "current" and resets
/// the next buffer. This is why each render pass starts from an empty buffer: your callback is
/// expected to fully redraw the viewport every time.
///
/// The [`CompletedFrame`] returned from [`Terminal::draw`] / [`Terminal::try_draw`] provides a
/// reference to the buffer that was just rendered, which can be useful for assertions in tests.
///
/// ## Viewport state and resizing
///
/// The active [`Viewport`] controls how the viewport area is computed:
///
/// - Fullscreen: `Frame::area` covers the full backend size.
/// - Fixed: `Frame::area` is the exact rectangle you provided in terminal coordinates.
/// - Inline: `Frame::area` is a rectangle anchored to the backend cursor row.
///
/// For fullscreen and inline viewports, [`Terminal::autoresize`] checks the backend size during
/// every render pass and calls [`Terminal::resize`] when it changes. Resizing updates the internal
/// buffer sizes and clears the affected region; it also resets the previous buffer so the next draw
/// is treated as a full redraw.
///
/// ## Cursor tracking
///
/// The cursor position requested by [`Frame::set_cursor_position`] is applied after
/// [`Terminal::flush`] so the cursor ends up on top of the rendered UI. `Terminal` also tracks a
/// "last known cursor position" as a best-effort record of where it last wrote, and uses that
/// information when recomputing inline viewports on resize.
///
/// ## Inline-specific behavior
///
/// Inline viewports reserve vertical space by calling [`Backend::append_lines`]. If the cursor is
/// close enough to the bottom edge, terminals scroll as lines are appended. Ratatui accounts for
/// that scrolling by shifting the computed viewport origin upward so the viewport remains fully
/// visible. On resize, Ratatui recomputes the inline origin while trying to keep the cursor at the
/// same relative row inside the viewport.
///
/// When Ratatui is built with the `scrolling-regions` feature, [`Terminal::insert_before`] uses
/// terminal scrolling regions to insert content above an inline viewport without clearing and
/// redrawing it.
///
/// [Crossterm]: https://crates.io/crates/crossterm
/// [Termion]: https://crates.io/crates/termion
/// [Termwiz]: https://crates.io/crates/termwiz
/// [`backend`]: crate::backend
/// [`Backend`]: crate::backend::Backend
/// [`Backend::flush`]: crate::backend::Backend::flush
/// [`Buffer`]: crate::buffer::Buffer
/// [`ratatui::DefaultTerminal`]: https://docs.rs/ratatui/latest/ratatui/type.DefaultTerminal.html
/// [`ratatui::init`]: https://docs.rs/ratatui/latest/ratatui/fn.init.html
/// [`ratatui::restore`]: https://docs.rs/ratatui/latest/ratatui/fn.restore.html
/// [`ratatui::run`]: https://docs.rs/ratatui/latest/ratatui/fn.run.html
#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)]
pub struct Terminal<B>
where
B: Backend,
{
/// The backend used to write updates to the terminal.
///
/// Most application code does not need to interact with the backend directly; see
/// [`Terminal::draw`]. Accessing the backend can be useful for backend-specific testing and
/// inspection (see [`Terminal::backend`]).
backend: B,
/// Double-buffered render state.
///
/// [`Terminal::flush`] diffs `buffers[current]` against the other buffer to compute a minimal
/// set of updates to send to the backend.
buffers: [Buffer; 2],
/// Index of the "current" buffer in [`Terminal::buffers`].
///
/// This toggles between 0 and 1 and is updated by [`Terminal::swap_buffers`].
current: usize,
/// Whether Ratatui believes it has hidden the cursor.
///
/// This is tracked so [`Drop`] can attempt to restore cursor visibility.
hidden_cursor: bool,
/// The configured [`Viewport`] mode.
///
/// This determines how the initial viewport area is computed during construction, whether
/// [`Terminal::autoresize`] runs, how [`Terminal::clear`] behaves, and whether operations like
/// [`Terminal::insert_before`] have any effect.
viewport: Viewport,
/// The current viewport rectangle in terminal coordinates.
///
/// This is the area returned by [`Frame::area`] and the size of the internal buffers. It is
/// set during construction and updated by [`Terminal::resize`]. In inline mode, calls to
/// [`Terminal::insert_before`] can also move the viewport vertically.
viewport_area: Rect,
/// Last known renderable "screen" area.
///
/// For fullscreen and inline viewports this tracks the backend-reported terminal size. For
/// fixed viewports, this tracks the user-provided fixed area.
///
/// This is used by [`Terminal::autoresize`] and is reported via [`CompletedFrame::area`].
last_known_area: Rect,
/// Last known cursor position in terminal coordinates.
///
/// This is updated when:
///
/// - [`Terminal::set_cursor_position`] is called directly.
/// - [`Frame::set_cursor_position`] is used during [`Terminal::draw`].
/// - [`Terminal::flush`] observes a diff update (used as a proxy for the "last written" cell).
///
/// Inline viewports use this during [`Terminal::resize`] to preserve the cursor's relative
/// position within the viewport.
last_known_cursor_pos: Position,
/// Number of frames rendered so far.
///
/// This increments after each successful [`Terminal::draw`] / [`Terminal::try_draw`] and wraps
/// at `usize::MAX`.
frame_count: usize,
}
/// Options to pass to [`Terminal::with_options`]
///
/// Most applications can use [`Terminal::new`]. Use `TerminalOptions` when you need to configure a
/// non-default [`Viewport`] at initialization time (see [`Terminal`] for an overview).
#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)]
pub struct TerminalOptions {
/// Viewport used to draw to the terminal.
///
/// See [`Terminal`] for a higher-level overview, and [`Viewport`] for the per-variant
/// definition.
pub viewport: Viewport,
}
impl<B> Drop for Terminal<B>
where
B: Backend,
{
fn drop(&mut self) {
// Attempt to restore the cursor state
if self.hidden_cursor {
#[allow(unused_variables)]
if let Err(err) = self.show_cursor() {
#[cfg(feature = "std")]
std::eprintln!("Failed to show the cursor: {err}");
}
}
}
}

View File

@@ -0,0 +1,75 @@
use crate::backend::Backend;
use crate::layout::Size;
use crate::terminal::Terminal;
impl<B: Backend> Terminal<B> {
/// Returns a shared reference to the backend.
///
/// This is primarily useful for backend-specific inspection in tests (e.g. reading
/// [`TestBackend`]'s buffer). Most applications should interact with the terminal via
/// [`Terminal::draw`] rather than calling backend methods directly.
///
/// [`TestBackend`]: crate::backend::TestBackend
pub const fn backend(&self) -> &B {
&self.backend
}
/// Returns a mutable reference to the backend.
///
/// This is an advanced escape hatch. Mutating the backend directly can desynchronize Ratatui's
/// internal buffers from what's on-screen; if you do this, you may need to call
/// [`Terminal::clear`] to force a full redraw.
pub const fn backend_mut(&mut self) -> &mut B {
&mut self.backend
}
/// Queries the real size of the backend.
///
/// This returns the size of the underlying terminal. The current renderable area depends on
/// the configured [`Viewport`]; use [`Frame::area`] inside [`Terminal::draw`] if you want the
/// area you should render into.
///
/// [`Frame::area`]: crate::terminal::Frame::area
/// [`Terminal::draw`]: crate::terminal::Terminal::draw
/// [`Viewport`]: crate::terminal::Viewport
pub fn size(&self) -> Result<Size, B::Error> {
self.backend.size()
}
}
#[cfg(test)]
mod tests {
use crate::backend::TestBackend;
use crate::layout::{Position, Size};
use crate::terminal::Terminal;
#[test]
fn backend_returns_shared_reference() {
let backend = TestBackend::new(3, 2);
let terminal = Terminal::new(backend).unwrap();
assert_eq!(terminal.backend().cursor_position(), Position::ORIGIN);
}
#[test]
fn backend_mut_allows_mutating_backend_state() {
let backend = TestBackend::new(3, 2);
let mut terminal = Terminal::new(backend).unwrap();
terminal.backend_mut().resize(4, 3);
assert_eq!(terminal.size().unwrap(), Size::new(4, 3));
terminal
.backend()
.assert_buffer_lines([" ", " ", " "]);
}
#[test]
fn size_queries_underlying_backend_size() {
let mut backend = TestBackend::new(3, 2);
backend.resize(4, 3);
let terminal = Terminal::new(backend).unwrap();
assert_eq!(terminal.size().unwrap(), Size::new(4, 3));
}
}

View File

@@ -0,0 +1,304 @@
use crate::backend::{Backend, ClearType};
use crate::buffer::Buffer;
use crate::layout::Position;
use crate::terminal::{Frame, Terminal, Viewport};
impl<B: Backend> Terminal<B> {
/// Returns a [`Frame`] for manual rendering.
///
/// Most applications should render via [`Terminal::draw`] / [`Terminal::try_draw`]. This method
/// exposes the frame construction step used by [`Terminal::try_draw`] so tests and advanced
/// callers can render without running the full draw pipeline.
///
/// Unlike `draw` / `try_draw`, this does not call [`Terminal::autoresize`], does not write
/// updates to the backend, and does not apply any cursor changes. After rendering, you
/// typically call [`Terminal::flush`], [`Terminal::swap_buffers`], and [`Backend::flush`].
///
/// The returned `Frame` mutably borrows the current buffer, so it must be dropped before you
/// can call methods like [`Terminal::flush`]. The example below uses a scope to make that
/// explicit.
///
/// # Example
///
/// ```rust,no_run
/// # mod ratatui {
/// # pub use ratatui_core::backend;
/// # pub use ratatui_core::terminal::Terminal;
/// # }
/// use ratatui::Terminal;
/// use ratatui::backend::{Backend, TestBackend};
///
/// let backend = TestBackend::new(30, 5);
/// let mut terminal = Terminal::new(backend)?;
/// {
/// let mut frame = terminal.get_frame();
/// frame.render_widget("Hello", frame.area());
/// }
/// // When not using `draw`, present the buffer manually:
/// terminal.flush()?;
/// terminal.swap_buffers();
/// terminal.backend_mut().flush()?;
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
///
/// [`Backend::flush`]: crate::backend::Backend::flush
pub const fn get_frame(&mut self) -> Frame<'_> {
let count = self.frame_count;
Frame {
cursor_position: None,
viewport_area: self.viewport_area,
buffer: self.current_buffer_mut(),
count,
}
}
/// Gets the current buffer as a mutable reference.
///
/// This is the buffer that the next [`Frame`] will render into (see [`Terminal::get_frame`]).
/// Most applications should render inside [`Terminal::draw`] and access the buffer via
/// [`Frame::buffer_mut`] instead.
pub const fn current_buffer_mut(&mut self) -> &mut Buffer {
&mut self.buffers[self.current]
}
/// Writes the current buffer to the backend using a diff against the previous buffer.
///
/// This is one of the building blocks used by [`Terminal::draw`] / [`Terminal::try_draw`]. It
/// does not swap buffers or flush the backend; see [`Terminal::swap_buffers`] and
/// [`Backend::flush`].
///
/// Implementation note: when there are updates, Ratatui records the position of the last
/// updated cell as the "last known cursor position". Inline viewports use this to preserve the
/// cursor's relative position within the viewport across resizes.
///
/// [`Backend::flush`]: crate::backend::Backend::flush
pub fn flush(&mut self) -> Result<(), B::Error> {
let previous_buffer = &self.buffers[1 - self.current];
let current_buffer = &self.buffers[self.current];
let updates = previous_buffer.diff(current_buffer);
if let Some((col, row, _)) = updates.last() {
self.last_known_cursor_pos = Position { x: *col, y: *row };
}
self.backend.draw(updates.into_iter())
}
/// Clears the inactive buffer and swaps it with the current buffer.
///
/// This is part of the standard rendering flow (see [`Terminal::try_draw`]). If you render
/// manually using [`Terminal::get_frame`] and [`Terminal::flush`], call this afterward so the
/// next flush can compute diffs against the correct "previous" buffer.
pub fn swap_buffers(&mut self) {
self.buffers[1 - self.current].reset();
self.current = 1 - self.current;
}
/// Clear the terminal and force a full redraw on the next draw call.
///
/// What gets cleared depends on the active [`Viewport`]:
///
/// - [`Viewport::Fullscreen`]: clears the entire terminal.
/// - [`Viewport::Fixed`]: clears only the viewport region.
/// - [`Viewport::Inline`]: clears after the viewport's origin, leaving any content above the
/// viewport untouched.
///
/// This also resets the "previous" buffer so the next [`Terminal::flush`] redraws the full
/// viewport. [`Terminal::resize`] calls this internally.
///
/// Implementation note: this uses [`ClearType::AfterCursor`] starting at the viewport origin.
pub fn clear(&mut self) -> Result<(), B::Error> {
match self.viewport {
Viewport::Fullscreen => self.backend.clear_region(ClearType::All)?,
Viewport::Inline(_) => {
self.backend
.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)?;
}
Viewport::Fixed(_) => {
let area = self.viewport_area;
for y in area.top()..area.bottom() {
// 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)?;
}
}
}
// Reset the back buffer to make sure the next update will redraw everything.
self.buffers[1 - self.current].reset();
Ok(())
}
}
#[cfg(test)]
mod tests {
use crate::backend::{Backend, TestBackend};
use crate::buffer::{Buffer, Cell};
use crate::layout::{Position, Rect};
use crate::terminal::{Terminal, TerminalOptions, Viewport};
#[test]
fn get_frame_uses_current_viewport_and_frame_count() {
let backend = TestBackend::new(5, 3);
let mut terminal = Terminal::new(backend).unwrap();
let frame = terminal.get_frame();
assert_eq!(frame.count, 0);
assert_eq!(frame.area().width, 5);
assert_eq!(frame.area().height, 3);
assert_eq!(frame.buffer.area, frame.area());
}
#[test]
fn flush_writes_updates_and_tracks_last_updated_cell() {
let backend = TestBackend::new(3, 2);
let mut terminal = Terminal::new(backend).unwrap();
{
let frame = terminal.get_frame();
frame.buffer[(1, 0)].set_symbol("x");
}
terminal.flush().unwrap();
terminal.backend().assert_buffer_lines([" x ", " "]);
assert_eq!(terminal.last_known_cursor_pos, Position { x: 1, y: 0 });
}
#[test]
fn flush_with_no_updates_does_not_change_last_known_cursor_pos() {
let backend = TestBackend::new(3, 2);
let mut terminal = Terminal::new(backend).unwrap();
terminal.set_cursor_position((2, 1)).unwrap();
terminal.flush().unwrap();
assert_eq!(terminal.last_known_cursor_pos, Position { x: 2, y: 1 });
}
#[test]
fn swap_buffers_resets_new_current_buffer() {
let backend = TestBackend::new(3, 2);
let mut terminal = Terminal::new(backend).unwrap();
terminal.buffers[1][(0, 0)].set_symbol("x");
terminal.swap_buffers();
assert_eq!(terminal.current, 1);
assert_eq!(
terminal.buffers[terminal.current],
Buffer::empty(terminal.viewport_area)
);
}
#[test]
fn clear_fullscreen_clears_backend_and_resets_back_buffer() {
let backend = TestBackend::new(3, 2);
let mut terminal = Terminal::new(backend).unwrap();
{
let frame = terminal.get_frame();
frame.buffer[(0, 0)] = Cell::new("x");
}
terminal.flush().unwrap();
terminal.backend().assert_buffer_lines(["x ", " "]);
terminal.buffers[1][(2, 1)] = Cell::new("y");
terminal.clear().unwrap();
terminal.backend().assert_buffer_lines([" ", " "]);
assert_eq!(
terminal.buffers[1 - terminal.current],
Buffer::empty(terminal.viewport_area)
);
}
#[test]
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:
// 1) move the backend cursor to the viewport origin
// 2) call ClearType::AfterCursor once
//
// Note: TestBackend's ClearType::AfterCursor clears *after the cursor position*, keeping
// the cell at the cursor intact, and clears through the end of the screen buffer.
let mut backend = TestBackend::with_lines(["aaa", "bbb", "ccc"]);
backend.set_cursor_position((0, 1)).unwrap();
let mut terminal = Terminal::with_options(
backend,
TerminalOptions {
viewport: Viewport::Inline(1),
},
)
.unwrap();
terminal.buffers[1][(2, 1)] = Cell::new("x");
terminal.clear().unwrap();
terminal
.backend()
.assert_buffer_lines(["aaa", "b ", " "]);
assert_eq!(
terminal.buffers[1 - terminal.current],
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!(
terminal.backend().cursor_position(),
Position { x: 0, y: 1 }
);
}
#[test]
fn clear_fixed_clears_viewport_rows_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 (each row's first cell is left
// untouched, and TestBackend clears through the end of the screen). We'll fix the clear
// semantics later; this test locks down current behavior.
//
// Fixed clear is implemented as: for each viewport row, set the cursor to the start of
// the row (x = 0) and call ClearType::AfterCursor.
//
// 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
.backend()
.assert_buffer_lines(["aaa", "b ", " "]);
assert_eq!(
terminal.buffers[1 - terminal.current],
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!(
terminal.backend().cursor_position(),
Position { x: 0, y: 2 }
);
}
}

View File

@@ -0,0 +1,151 @@
use crate::backend::Backend;
use crate::layout::Position;
use crate::terminal::Terminal;
impl<B: Backend> Terminal<B> {
/// Hides the cursor.
///
/// When using [`Terminal::draw`], prefer controlling the cursor with
/// [`Frame::set_cursor_position`]. Mixing the APIs can lead to surprising results.
///
/// [`Frame::set_cursor_position`]: crate::terminal::Frame::set_cursor_position
/// [`Terminal::draw`]: crate::terminal::Terminal::draw
pub fn hide_cursor(&mut self) -> Result<(), B::Error> {
self.backend.hide_cursor()?;
self.hidden_cursor = true;
Ok(())
}
/// Shows the cursor.
///
/// When using [`Terminal::draw`], prefer controlling the cursor with
/// [`Frame::set_cursor_position`]. Mixing the APIs can lead to surprising results.
///
/// [`Frame::set_cursor_position`]: crate::terminal::Frame::set_cursor_position
/// [`Terminal::draw`]: crate::terminal::Terminal::draw
pub fn show_cursor(&mut self) -> Result<(), B::Error> {
self.backend.show_cursor()?;
self.hidden_cursor = false;
Ok(())
}
/// Gets the current cursor position.
///
/// This is the position of the cursor after the last draw call and is returned as a tuple of
/// `(x, y)` coordinates.
#[deprecated = "use `get_cursor_position()` instead which returns `Result<Position>`"]
pub fn get_cursor(&mut self) -> Result<(u16, u16), B::Error> {
let Position { x, y } = self.get_cursor_position()?;
Ok((x, y))
}
/// Sets the cursor position.
#[deprecated = "use `set_cursor_position((x, y))` instead which takes `impl Into<Position>`"]
pub fn set_cursor(&mut self, x: u16, y: u16) -> Result<(), B::Error> {
self.set_cursor_position(Position { x, y })
}
/// Gets the current cursor position.
///
/// This queries the backend for the current cursor position.
///
/// When using [`Terminal::draw`], prefer controlling the cursor with
/// [`Frame::set_cursor_position`]. For direct control, see [`Terminal::set_cursor_position`].
///
/// [`Frame::set_cursor_position`]: crate::terminal::Frame::set_cursor_position
/// [`Terminal::draw`]: crate::terminal::Terminal::draw
pub fn get_cursor_position(&mut self) -> Result<Position, B::Error> {
self.backend.get_cursor_position()
}
/// Sets the cursor position.
///
/// This updates the backend cursor and Ratatui's internal cursor tracking. Inline viewports
/// use that tracking when recomputing the viewport on resize.
///
/// When using [`Terminal::draw`], consider using [`Frame::set_cursor_position`] instead so the
/// cursor is updated as part of the normal rendering flow.
///
/// [`Frame::set_cursor_position`]: crate::terminal::Frame::set_cursor_position
/// [`Terminal::draw`]: crate::terminal::Terminal::draw
pub fn set_cursor_position<P: Into<Position>>(&mut self, position: P) -> Result<(), B::Error> {
let position = position.into();
self.backend.set_cursor_position(position)?;
self.last_known_cursor_pos = position;
Ok(())
}
}
#[cfg(test)]
mod tests {
use crate::backend::{Backend, TestBackend};
use crate::layout::Position;
use crate::terminal::Terminal;
#[test]
fn hide_cursor_updates_terminal_state() {
let backend = TestBackend::new(10, 5);
let mut terminal = Terminal::new(backend).unwrap();
terminal.hide_cursor().unwrap();
assert!(terminal.hidden_cursor);
assert!(!terminal.backend().cursor_visible());
}
#[test]
fn show_cursor_updates_terminal_state() {
let backend = TestBackend::new(10, 5);
let mut terminal = Terminal::new(backend).unwrap();
terminal.hide_cursor().unwrap();
terminal.show_cursor().unwrap();
assert!(!terminal.hidden_cursor);
assert!(terminal.backend().cursor_visible());
}
#[test]
fn set_cursor_position_updates_backend_and_tracking() {
let backend = TestBackend::new(10, 5);
let mut terminal = Terminal::new(backend).unwrap();
terminal.set_cursor_position((3, 4)).unwrap();
assert_eq!(terminal.last_known_cursor_pos, Position { x: 3, y: 4 });
terminal
.backend_mut()
.assert_cursor_position(Position { x: 3, y: 4 });
}
#[test]
fn get_cursor_position_queries_backend() {
let backend = TestBackend::new(10, 5);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.backend_mut()
.set_cursor_position(Position { x: 7, y: 2 })
.unwrap();
assert_eq!(
terminal.get_cursor_position().unwrap(),
Position { x: 7, y: 2 }
);
}
#[test]
#[allow(deprecated)]
fn deprecated_cursor_wrappers_delegate_to_position_apis() {
let backend = TestBackend::new(10, 5);
let mut terminal = Terminal::new(backend).unwrap();
terminal.set_cursor(4, 1).unwrap();
assert_eq!(terminal.get_cursor().unwrap(), (4, 1));
assert_eq!(terminal.last_known_cursor_pos, Position { x: 4, y: 1 });
terminal
.backend_mut()
.assert_cursor_position(Position { x: 4, y: 1 });
}
}

View File

@@ -0,0 +1,236 @@
use crate::backend::Backend;
use crate::buffer::Buffer;
use crate::layout::Position;
use crate::terminal::inline::compute_inline_size;
use crate::terminal::{Terminal, TerminalOptions, Viewport};
impl<B: Backend> Terminal<B> {
/// Creates a new [`Terminal`] with the given [`Backend`] with a full screen viewport.
///
/// This is a convenience for [`Terminal::with_options`] with [`Viewport::Fullscreen`].
///
/// After creating a terminal, call [`Terminal::draw`] (or [`Terminal::try_draw`]) in a loop to
/// render your UI.
///
/// Note that unlike [`ratatui::init`], this does not install a panic hook, so it is
/// recommended to do that manually when using this function, otherwise any panic messages will
/// be printed to the alternate screen and the terminal may be left in an unusable state.
///
/// See [how to set up panic hooks](https://ratatui.rs/recipes/apps/panic-hooks/) and
/// [`better-panic` example](https://ratatui.rs/recipes/apps/better-panic/) for more
/// information.
///
/// # Example
///
/// ```rust,no_run
/// # #![allow(unexpected_cfgs)]
/// # #[cfg(feature = "crossterm")]
/// # {
/// use std::io::stdout;
///
/// use ratatui::Terminal;
/// use ratatui::backend::CrosstermBackend;
///
/// let backend = CrosstermBackend::new(stdout());
/// let _terminal = Terminal::new(backend)?;
///
/// // Optionally set up a panic hook to restore the terminal on panic.
/// let old_hook = std::panic::take_hook();
/// std::panic::set_hook(Box::new(move |info| {
/// ratatui::restore();
/// old_hook(info);
/// }));
/// # }
/// # #[cfg(not(feature = "crossterm"))]
/// # {
/// # use ratatui_core::{backend::TestBackend, terminal::Terminal};
/// # let backend = TestBackend::new(10, 10);
/// # let _terminal = Terminal::new(backend)?;
/// # }
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
///
/// [`ratatui::init`]: https://docs.rs/ratatui/latest/ratatui/fn.init.html
pub fn new(backend: B) -> Result<Self, B::Error> {
Self::with_options(
backend,
TerminalOptions {
viewport: Viewport::Fullscreen,
},
)
}
/// Creates a new [`Terminal`] with the given [`Backend`] and [`TerminalOptions`].
///
/// The viewport determines what area is exposed to widgets via [`Frame::area`]. See
/// [`Viewport`] for an overview of the available modes.
///
/// [`Frame::area`]: crate::terminal::Frame::area
///
/// After creating a terminal, call [`Terminal::draw`] (or [`Terminal::try_draw`]) in a loop to
/// render your UI.
///
/// Resize behavior depends on the selected viewport:
///
/// - [`Viewport::Fullscreen`] and [`Viewport::Inline`] are automatically resized during
/// [`Terminal::draw`] (via [`Terminal::autoresize`]).
/// - [`Viewport::Fixed`] is not automatically resized; call [`Terminal::resize`] if the region
/// should change.
///
/// # Example
///
/// ```rust,no_run
/// # #![allow(unexpected_cfgs)]
/// # #[cfg(feature = "crossterm")]
/// # {
/// use std::io::stdout;
///
/// use ratatui::backend::CrosstermBackend;
/// use ratatui::layout::Rect;
/// use ratatui::{Terminal, TerminalOptions, Viewport};
///
/// let backend = CrosstermBackend::new(stdout());
/// let viewport = Viewport::Fixed(Rect::new(0, 0, 10, 10));
/// let _terminal = Terminal::with_options(backend, TerminalOptions { viewport })?;
/// # }
/// # #[cfg(not(feature = "crossterm"))]
/// # {
/// # use ratatui_core::{
/// # backend::TestBackend,
/// # layout::Rect,
/// # terminal::{Terminal, TerminalOptions, Viewport},
/// # };
/// # let backend = TestBackend::new(10, 10);
/// # let viewport = Viewport::Fixed(Rect::new(0, 0, 10, 10));
/// # let _terminal = Terminal::with_options(backend, TerminalOptions { viewport })?;
/// # }
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
///
/// When the viewport is [`Viewport::Inline`], Ratatui anchors the viewport to the current
/// cursor row at initialization time (always starting at column 0). Ratatui may scroll the
/// terminal to make enough room for the requested height so the viewport stays fully visible.
pub fn with_options(mut backend: B, options: TerminalOptions) -> Result<Self, B::Error> {
let area = match options.viewport {
Viewport::Fullscreen | Viewport::Inline(_) => backend.size()?.into(),
Viewport::Fixed(area) => area,
};
let (viewport_area, cursor_pos) = match options.viewport {
Viewport::Fullscreen => (area, Position::ORIGIN),
Viewport::Inline(height) => {
compute_inline_size(&mut backend, height, area.as_size(), 0)?
}
Viewport::Fixed(area) => (area, area.as_position()),
};
Ok(Self {
backend,
buffers: [Buffer::empty(viewport_area), Buffer::empty(viewport_area)],
current: 0,
hidden_cursor: false,
viewport: options.viewport,
viewport_area,
last_known_area: area,
last_known_cursor_pos: cursor_pos,
frame_count: 0,
})
}
}
#[cfg(test)]
mod tests {
use crate::backend::{Backend, TestBackend};
use crate::layout::{Position, Rect};
use crate::terminal::{Terminal, TerminalOptions, Viewport};
#[test]
fn new_fullscreen_initializes_state() {
let backend = TestBackend::new(10, 5);
let terminal = Terminal::new(backend).unwrap();
assert_eq!(terminal.viewport, Viewport::Fullscreen);
assert_eq!(terminal.viewport_area, Rect::new(0, 0, 10, 5));
assert_eq!(terminal.last_known_area, Rect::new(0, 0, 10, 5));
assert_eq!(terminal.last_known_cursor_pos, Position::ORIGIN);
assert_eq!(terminal.current, 0);
assert!(!terminal.hidden_cursor);
assert_eq!(terminal.frame_count, 0);
assert_eq!(terminal.buffers[0].area, terminal.viewport_area);
assert_eq!(terminal.buffers[1].area, terminal.viewport_area);
}
#[test]
fn with_options_fixed_uses_fixed_area() {
let backend = TestBackend::new(10, 10);
let viewport = Viewport::Fixed(Rect::new(2, 3, 5, 4));
let terminal = Terminal::with_options(
backend,
TerminalOptions {
viewport: viewport.clone(),
},
)
.unwrap();
assert_eq!(terminal.viewport, viewport);
assert_eq!(terminal.viewport_area, Rect::new(2, 3, 5, 4));
assert_eq!(terminal.last_known_area, Rect::new(2, 3, 5, 4));
assert_eq!(terminal.last_known_cursor_pos, Position { x: 2, y: 3 });
assert_eq!(terminal.buffers[0].area, terminal.viewport_area);
assert_eq!(terminal.buffers[1].area, terminal.viewport_area);
}
#[test]
fn with_options_inline_anchors_to_cursor_when_space_available() {
let mut backend = TestBackend::new(10, 10);
backend
.set_cursor_position(Position { x: 0, y: 3 })
.unwrap();
let terminal = Terminal::with_options(
backend,
TerminalOptions {
viewport: Viewport::Inline(4),
},
)
.unwrap();
assert_eq!(terminal.viewport_area, Rect::new(0, 3, 10, 4));
assert_eq!(terminal.last_known_cursor_pos, Position { x: 0, y: 3 });
}
#[test]
fn with_options_inline_shifts_up_when_near_bottom() {
let mut backend = TestBackend::new(10, 10);
backend
.set_cursor_position(Position { x: 0, y: 8 })
.unwrap();
let terminal = Terminal::with_options(
backend,
TerminalOptions {
viewport: Viewport::Inline(4),
},
)
.unwrap();
assert_eq!(terminal.viewport_area, Rect::new(0, 6, 10, 4));
assert_eq!(terminal.last_known_cursor_pos, Position { x: 0, y: 8 });
}
#[test]
fn with_options_inline_clamps_height_to_terminal() {
let mut backend = TestBackend::new(10, 3);
backend
.set_cursor_position(Position { x: 0, y: 0 })
.unwrap();
let terminal = Terminal::with_options(
backend,
TerminalOptions {
viewport: Viewport::Inline(10),
},
)
.unwrap();
assert_eq!(terminal.viewport_area, Rect::new(0, 0, 10, 3));
}
}

View File

@@ -0,0 +1,927 @@
use crate::backend::Backend;
use crate::buffer::{Buffer, Cell};
use crate::layout::{Position, Rect, Size};
use crate::terminal::{Terminal, Viewport};
impl<B: Backend> Terminal<B> {
/// Insert some content before the current inline viewport. This has no effect when the
/// viewport is not inline.
///
/// This is intended for inline UIs that want to print output (e.g. logs or status messages)
/// above the UI without breaking it. See [`Viewport::Inline`] for how inline viewports are
/// anchored.
///
/// The `draw_fn` closure will be called to draw into a writable `Buffer` that is `height`
/// lines tall. The content of that `Buffer` will then be inserted before the viewport.
///
/// When Ratatui is built with the `scrolling-regions` feature, this can be done without
/// clearing and redrawing the viewport. Without `scrolling-regions`, Ratatui falls back to a
/// more portable approach and clears the viewport so the next [`Terminal::draw`] repaints it.
///
/// 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:
/// ```text
/// +---------------------+
/// | pre-existing line 1 |
/// | pre-existing line 2 |
/// +---------------------+
/// | viewport |
/// +---------------------+
/// | |
/// | |
/// +---------------------+
/// ```
///
/// After inserting 2 lines:
/// ```text
/// +---------------------+
/// | pre-existing line 1 |
/// | pre-existing line 2 |
/// | inserted line 1 |
/// | inserted line 2 |
/// +---------------------+
/// | viewport |
/// +---------------------+
/// +---------------------+
/// ```
///
/// After inserting 2 more lines:
/// ```text
/// +---------------------+
/// | 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
///
/// ## Insert a single line before the current viewport
///
/// ```rust,no_run
/// # mod ratatui {
/// # pub use ratatui_core::backend;
/// # pub use ratatui_core::layout;
/// # pub use ratatui_core::style;
/// # pub use ratatui_core::terminal::{Terminal, TerminalOptions, Viewport};
/// # pub use ratatui_core::text;
/// # pub use ratatui_core::widgets;
/// # }
/// use ratatui::backend::{Backend, TestBackend};
/// use ratatui::layout::Position;
/// use ratatui::style::{Color, Style};
/// use ratatui::text::{Line, Span};
/// use ratatui::widgets::Widget;
/// use ratatui::{Terminal, TerminalOptions, Viewport};
///
/// let mut backend = TestBackend::new(10, 10);
/// // Simulate existing output above the inline UI.
/// backend.set_cursor_position(Position::new(0, 3))?;
/// let mut terminal = Terminal::with_options(
/// backend,
/// TerminalOptions {
/// viewport: Viewport::Inline(4),
/// },
/// )?;
///
/// terminal.insert_before(1, |buf| {
/// Line::from(vec![
/// Span::raw("This line will be added "),
/// Span::styled("before", Style::default().fg(Color::Blue)),
/// Span::raw(" the current viewport"),
/// ])
/// .render(buf.area, buf);
/// })?;
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
pub fn insert_before<F>(&mut self, height: u16, draw_fn: F) -> Result<(), B::Error>
where
F: FnOnce(&mut Buffer),
{
match self.viewport {
#[cfg(feature = "scrolling-regions")]
Viewport::Inline(_) => self.insert_before_scrolling_regions(height, draw_fn),
#[cfg(not(feature = "scrolling-regions"))]
Viewport::Inline(_) => self.insert_before_no_scrolling_regions(height, draw_fn),
_ => Ok(()),
}
}
/// Implement `Self::insert_before` using standard backend capabilities.
///
/// This is the fallback implementation when the `scrolling-regions` feature is disabled. It
/// renders the inserted lines into a temporary [`Buffer`], then draws them directly to the
/// backend in chunks, scrolling the terminal as needed.
///
/// See [`Terminal::insert_before`] for the public API contract.
#[cfg(not(feature = "scrolling-regions"))]
fn insert_before_no_scrolling_regions(
&mut self,
height: u16,
draw_fn: impl FnOnce(&mut Buffer),
) -> Result<(), B::Error> {
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();
// Use i32 variables so we don't have worry about overflowed u16s when adding, or about
// negative results when subtracting.
let mut drawn_height: i32 = self.viewport_area.top().into();
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();
// The algorithm here is to loop, drawing large chunks of text (up to a screen-full at a
// 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
// with just one call to Self::draw_lines().
while buffer_height + viewport_height > screen_height {
// We will draw as much of the buffer as possible on this iteration in order to make
// forward progress. So we have:
//
// to_draw = min(buffer_height, screen_height)
//
// We may need to scroll the screen up to make room to draw. We choose the minimal
// possible scroll amount so we don't end up with the viewport sitting in the middle of
// the screen when this function is done. The amount to scroll by is:
//
// 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(())
}
/// 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),
) -> Result<(), B::Error> {
let area = Rect {
x: 0,
y: 0,
width: self.viewport_area.width,
height,
};
let mut buffer = Buffer::empty(area);
draw_fn(&mut buffer);
let mut buffer = buffer.content.as_slice();
// Handle the special case where the viewport takes up the whole screen.
if self.viewport_area.height == self.last_known_area.height {
// "Borrow" the top line of the viewport. Draw over it, then immediately scroll it into
// scrollback. Do this repeatedly until the whole buffer has been put into scrollback.
let mut first = true;
while !buffer.is_empty() {
buffer = if first {
self.draw_lines(0, 1, buffer)?
} else {
self.draw_lines_over_cleared(0, 1, buffer)?
};
first = false;
self.backend.scroll_region_up(0..1, 1)?;
}
// Redraw the top line of the viewport.
let width = self.viewport_area.width as usize;
let top_line = self.buffers[1 - self.current].content[0..width].to_vec();
self.draw_lines_over_cleared(0, 1, &top_line)?;
return Ok(());
}
// Handle the case where the viewport isn't yet at the bottom of the screen.
{
let viewport_top = self.viewport_area.top();
let viewport_bottom = self.viewport_area.bottom();
let screen_bottom = self.last_known_area.bottom();
if viewport_bottom < screen_bottom {
let to_draw = height.min(screen_bottom - viewport_bottom);
self.backend
.scroll_region_down(viewport_top..viewport_bottom + to_draw, to_draw)?;
buffer = self.draw_lines_over_cleared(viewport_top, to_draw, buffer)?;
self.set_viewport_area(Rect {
y: viewport_top + to_draw,
..self.viewport_area
});
height -= to_draw;
}
}
let viewport_top = self.viewport_area.top();
while height > 0 {
let to_draw = height.min(viewport_top);
self.backend.scroll_region_up(0..viewport_top, to_draw)?;
buffer = self.draw_lines_over_cleared(viewport_top - to_draw, to_draw, buffer)?;
height -= to_draw;
}
Ok(())
}
/// Draw lines at the given vertical offset. The slice of cells must contain enough cells
/// for the requested lines. A slice of the unused cells are returned.
///
/// This is a small internal helper used by [`Terminal::insert_before`]. It writes cells
/// directly to the backend in terminal coordinates (not viewport coordinates).
fn draw_lines<'a>(
&mut self,
y_offset: u16,
lines_to_draw: u16,
cells: &'a [Cell],
) -> Result<&'a [Cell], B::Error> {
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)
}
/// 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.
///
/// This is used by the `scrolling-regions` implementation of [`Terminal::insert_before`] to
/// avoid relying on a full-screen clear while updating only part of the terminal.
#[cfg(feature = "scrolling-regions")]
fn draw_lines_over_cleared<'a>(
&mut self,
y_offset: u16,
lines_to_draw: u16,
cells: &'a [Cell],
) -> Result<&'a [Cell], B::Error> {
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.
///
/// This is used by [`Terminal::insert_before`] when the `scrolling-regions` feature is
/// disabled.
/// It scrolls by moving the cursor to the last row and calling [`Backend::append_lines`].
#[cfg(not(feature = "scrolling-regions"))]
fn scroll_up(&mut self, lines_to_scroll: u16) -> Result<(), B::Error> {
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(())
}
}
/// Compute the on-screen area for an inline viewport.
///
/// This helper is used by [`Terminal::with_options`] (initialization) and [`Terminal::resize`]
/// (after a terminal resize) to translate `Viewport::Inline(height)` into a concrete [`Rect`].
///
/// This returns the computed viewport area and the cursor position observed at the start of the
/// call.
///
/// Inline viewports always start at column 0, span the full terminal width, and are anchored to the
/// backend cursor row at the time of the call. The requested height is clamped to the current
/// terminal height.
///
/// Ratatui reserves vertical space for the requested height by calling [`Backend::append_lines`].
/// If the cursor is close enough to the bottom that appending would run past the last row,
/// terminals scroll; in that case we shift the computed `y` upward by the number of rows scrolled
/// so the viewport remains fully visible.
///
/// `offset_in_previous_viewport` is used by [`Terminal::resize`] to keep the cursor at the same
/// relative row within the viewport across resizes.
///
/// Related viewport code lives in:
///
/// - [`Terminal::with_options`] (selects the viewport and computes the initial area)
/// - [`Terminal::autoresize`] (detects backend size changes during [`Terminal::draw`])
/// - [`Terminal::resize`] (recomputes the viewport and clears before the next draw)
pub(crate) fn compute_inline_size<B: Backend>(
backend: &mut B,
height: u16,
size: Size,
offset_in_previous_viewport: u16,
) -> Result<(Rect, Position), B::Error> {
let pos = backend.get_cursor_position()?;
let mut row = pos.y;
let max_height = size.height.min(height);
let lines_after_cursor = height
.saturating_sub(offset_in_previous_viewport)
.saturating_sub(1);
backend.append_lines(lines_after_cursor)?;
let available_lines = size.height.saturating_sub(row).saturating_sub(1);
let missing_lines = lines_after_cursor.saturating_sub(available_lines);
if missing_lines > 0 {
row = row.saturating_sub(missing_lines);
}
row = row.saturating_sub(offset_in_previous_viewport);
Ok((
Rect {
x: 0,
y: row,
width: size.width,
height: max_height,
},
pos,
))
}
#[cfg(test)]
mod tests {
use crate::backend::{Backend, TestBackend};
use crate::layout::{Position, Rect, Size};
use crate::style::Style;
use crate::terminal::inline::compute_inline_size;
use crate::terminal::{Terminal, TerminalOptions, Viewport};
#[test]
fn compute_inline_size_uses_cursor_offset_when_space_available() {
// Diagram (terminal height = 10, requested viewport height = 4):
//
// Cursor at y=6, previous cursor offset within viewport = 1.
//
// Before (conceptually):
// 0
// 1
// 2
// 3
// 4
// 5 <- viewport top (expected)
// 6 <- cursor row (observed_pos.y)
// 7
// 8
// 9
//
// After: viewport top y = 5 (6 - 1), height = 4 => rows 5..9 (exclusive).
let mut backend = TestBackend::new(10, 10);
backend
.set_cursor_position(Position { x: 0, y: 6 })
.unwrap();
let (area, observed_pos) =
compute_inline_size(&mut backend, 4, Size::new(10, 10), 1).unwrap();
assert_eq!(observed_pos, Position { x: 0, y: 6 });
assert_eq!(area, Rect::new(0, 5, 10, 4));
}
#[test]
fn compute_inline_size_saturates_when_offset_exceeds_cursor_row() {
// Diagram (terminal height = 10, requested viewport height = 4):
//
// Cursor at y=0, previous cursor offset within viewport = 5 (nonsensical but possible if
// callers pass a stale/oversized offset).
//
// We saturate so the computed viewport top cannot go negative:
// top = cursor_y.saturating_sub(offset) = 0.saturating_sub(5) = 0
//
// Expected viewport area:
// y=0..4 (fully pinned to the top)
let mut backend = TestBackend::new(10, 10);
backend
.set_cursor_position(Position { x: 0, y: 0 })
.unwrap();
let (area, _observed_pos) =
compute_inline_size(&mut backend, 4, Size::new(10, 10), 5).unwrap();
assert_eq!(area, Rect::new(0, 0, 10, 4));
}
#[cfg(not(feature = "scrolling-regions"))]
mod no_scrolling_regions {
use super::*;
#[test]
fn insert_before_is_noop_for_non_inline_viewports() {
// Diagram:
//
// Viewport is fullscreen (not inline), so insert_before() is a no-op.
//
// Screen before:
// x..
// ...
//
// Screen after:
// x..
// ...
let mut terminal = Terminal::new(TestBackend::new(3, 2)).unwrap();
{
let frame = terminal.get_frame();
frame.buffer[(0, 0)].set_symbol("x");
}
terminal.flush().unwrap();
let viewport_area = terminal.viewport_area;
terminal
.insert_before(1, |buf| {
buf.set_string(0, 0, "zzz", Style::default());
})
.unwrap();
assert_eq!(terminal.viewport_area, viewport_area);
terminal.backend().assert_buffer_lines(["x ", " "]);
}
#[test]
fn insert_before_pushes_viewport_down_when_space_available() {
// Diagram (screen height = 10, viewport height = 4, cursor row = 3):
//
// Before:
// 0: 0000000000
// 1: 1111111111
// 2: 2222222222
// 3: [viewport top] 3333333333
// 4: 4444444444
// 5: 5555555555
// 6: 6666666666
// 7: 7777777777
// 8: 8888888888
// 9: 9999999999
//
// After inserting 1 line above an inline viewport (no scrolling regions):
// - A line is drawn at the old viewport top (y=3)
// - The viewport moves down by 1 row (new top y=4)
// - The viewport is cleared so it will be redrawn on the next draw()
let mut backend = TestBackend::with_lines([
"0000000000",
"1111111111",
"2222222222",
"3333333333",
"4444444444",
"5555555555",
"6666666666",
"7777777777",
"8888888888",
"9999999999",
]);
backend
.set_cursor_position(Position { x: 0, y: 3 })
.unwrap();
let mut terminal = Terminal::with_options(
backend,
TerminalOptions {
viewport: Viewport::Inline(4),
},
)
.unwrap();
terminal
.insert_before(1, |buf| {
buf.set_string(0, 0, "INSERTLINE", Style::default());
})
.unwrap();
assert_eq!(terminal.viewport_area, Rect::new(0, 4, 10, 4));
terminal.backend().assert_buffer_lines([
"0000000000",
"1111111111",
"2222222222",
"INSERTLINE",
"4 ",
" ",
" ",
" ",
" ",
" ",
]);
}
#[test]
fn insert_before_scrolls_when_viewport_is_at_bottom() {
// Diagram (screen height = 10, viewport height = 4, cursor row = 6):
//
// Before:
// 0: 0000000000
// 1: 1111111111
// 2: 2222222222
// 3: 3333333333
// 4: 4444444444
// 5: 5555555555
// 6: [viewport top] 6666666666
// 7: 7777777777
// 8: 8888888888
// 9: 9999999999
//
// After inserting 2 lines:
// - The area above the viewport scrolls up to make room
// - Inserted lines appear immediately above the viewport
// - The viewport is cleared so it will be redrawn on the next draw()
let mut backend = TestBackend::with_lines([
"0000000000",
"1111111111",
"2222222222",
"3333333333",
"4444444444",
"5555555555",
"6666666666",
"7777777777",
"8888888888",
"9999999999",
]);
backend
.set_cursor_position(Position { x: 0, y: 6 })
.unwrap();
let mut terminal = Terminal::with_options(
backend,
TerminalOptions {
viewport: Viewport::Inline(4),
},
)
.unwrap();
terminal
.insert_before(2, |buf| {
buf.set_string(0, 0, "INSERTED1", Style::default());
buf.set_string(0, 1, "INSERTED2", Style::default());
})
.unwrap();
assert_eq!(terminal.viewport_area, Rect::new(0, 6, 10, 4));
terminal.backend().assert_buffer_lines([
"2222222222",
"3333333333",
"4444444444",
"5555555555",
"INSERTED1 ",
"INSERTED2 ",
"8 ",
" ",
" ",
" ",
]);
}
#[test]
fn insert_before_then_draw_repaints_cleared_viewport() {
// Diagram (screen height = 10, viewport height = 4, cursor row = 6):
//
// 1) Draw a frame into the inline viewport at the bottom:
// 6..9: AAAAAAAAAA
//
// 2) Insert 2 lines above the viewport:
// - Inserts appear at rows 4..5
// - Viewport is cleared (so it is blank on-screen until the next draw)
//
// 3) Draw again:
// 6..9: BBBBBBBBBB
//
// Expected final screen:
// 4: INSERTED00
// 5: INSERTED01
// 6..9: BBBBBBBBBB
let mut backend = TestBackend::new(10, 10);
backend
.set_cursor_position(Position { x: 0, y: 6 })
.unwrap();
let mut terminal = Terminal::with_options(
backend,
TerminalOptions {
viewport: Viewport::Inline(4),
},
)
.unwrap();
terminal
.draw(|frame| {
let area = frame.area();
for y in area.top()..area.bottom() {
frame
.buffer
.set_string(area.x, y, "AAAAAAAAAA", Style::default());
}
})
.unwrap();
terminal
.insert_before(2, |buf| {
buf.set_string(0, 0, "INSERTED00", Style::default());
buf.set_string(0, 1, "INSERTED01", Style::default());
})
.unwrap();
terminal
.draw(|frame| {
let area = frame.area();
for y in area.top()..area.bottom() {
frame
.buffer
.set_string(area.x, y, "BBBBBBBBBB", Style::default());
}
})
.unwrap();
terminal.backend().assert_buffer_lines([
" ",
" ",
" ",
" ",
"INSERTED00",
"INSERTED01",
"BBBBBBBBBB",
"BBBBBBBBBB",
"BBBBBBBBBB",
"BBBBBBBBBB",
]);
}
}
#[cfg(feature = "scrolling-regions")]
mod scrolling_regions {
use super::*;
#[test]
fn insert_before_moves_viewport_down_without_clearing() {
// Diagram (screen height = 10, viewport height = 4, cursor row = 3):
//
// With scrolling regions enabled, we can create a gap and draw the inserted line
// without clearing the viewport content.
//
// Before:
// 2: 2222222222
// 3: [viewport top] 3333333333
// 4: 4444444444
//
// After:
// 3: INSERTLINE
// 4: 3333333333 (viewport content preserved)
let mut backend = TestBackend::with_lines([
"0000000000",
"1111111111",
"2222222222",
"3333333333",
"4444444444",
"5555555555",
"6666666666",
"7777777777",
"8888888888",
"9999999999",
]);
backend
.set_cursor_position(Position { x: 0, y: 3 })
.unwrap();
let mut terminal = Terminal::with_options(
backend,
TerminalOptions {
viewport: Viewport::Inline(4),
},
)
.unwrap();
terminal
.insert_before(1, |buf| {
buf.set_string(0, 0, "INSERTLINE", Style::default());
})
.unwrap();
assert_eq!(terminal.viewport_area, Rect::new(0, 4, 10, 4));
terminal.backend().assert_buffer_lines([
"0000000000",
"1111111111",
"2222222222",
"INSERTLINE",
"3333333333",
"4444444444",
"5555555555",
"6666666666",
"8888888888",
"9999999999",
]);
}
#[test]
fn insert_before_when_viewport_is_at_bottom_preserves_viewport() {
// Diagram (screen height = 10, viewport height = 4, viewport top = 6):
//
// With scrolling regions enabled and the viewport already at the bottom:
// - The region above the viewport (rows 0..6) scrolls up to make room.
// - Inserted lines are drawn into the cleared space immediately above the viewport.
// - The viewport itself is not cleared and stays on-screen.
//
// Before (after drawing V into the viewport):
// 0: 0000000000
// 1: 1111111111
// 2: 2222222222
// 3: 3333333333
// 4: 4444444444
// 5: 5555555555
// 6..9: VVVVVVVVVV
//
// After inserting 2 lines:
// 0..3: previous 2..5
// 4: AAAAAAAAAA
// 5: BBBBBBBBBB
// 6..9: VVVVVVVVVV
//
// The scrolled-off lines are appended to scrollback (previous 0 and 1).
let mut backend = TestBackend::with_lines([
"0000000000",
"1111111111",
"2222222222",
"3333333333",
"4444444444",
"5555555555",
"6666666666",
"7777777777",
"8888888888",
"9999999999",
]);
backend
.set_cursor_position(Position { x: 0, y: 6 })
.unwrap();
let mut terminal = Terminal::with_options(
backend,
TerminalOptions {
viewport: Viewport::Inline(4),
},
)
.unwrap();
terminal
.draw(|frame| {
let area = frame.area();
for y in area.top()..area.bottom() {
frame
.buffer
.set_string(area.x, y, "VVVVVVVVVV", Style::default());
}
})
.unwrap();
terminal
.insert_before(2, |buf| {
buf.set_string(0, 0, "AAAAAAAAAA", Style::default());
buf.set_string(0, 1, "BBBBBBBBBB", Style::default());
})
.unwrap();
terminal.backend().assert_buffer_lines([
"2222222222",
"3333333333",
"4444444444",
"5555555555",
"AAAAAAAAAA",
"BBBBBBBBBB",
"VVVVVVVVVV",
"VVVVVVVVVV",
"VVVVVVVVVV",
"VVVVVVVVVV",
]);
terminal
.backend()
.assert_scrollback_lines(["0000000000", "1111111111"]);
}
#[test]
fn insert_before_when_viewport_is_fullscreen_appends_to_scrollback() {
// Diagram (screen height = 4, viewport height = 4):
//
// When the viewport takes the whole screen, there is no visible "area above" it.
// The scrolling-regions implementation handles this by repeatedly:
// - drawing one line over the top row
// - immediately scrolling that row into scrollback
//
// The viewport content stays on-screen; inserted lines end up in scrollback.
let mut backend = TestBackend::new(10, 4);
backend
.set_cursor_position(Position { x: 0, y: 0 })
.unwrap();
let mut terminal = Terminal::with_options(
backend,
TerminalOptions {
viewport: Viewport::Inline(4),
},
)
.unwrap();
terminal
.draw(|frame| {
let area = frame.area();
frame
.buffer
.set_string(area.x, area.y, "VIEWLINE00", Style::default());
frame
.buffer
.set_string(area.x, area.y + 1, "VIEWLINE01", Style::default());
frame
.buffer
.set_string(area.x, area.y + 2, "VIEWLINE02", Style::default());
frame
.buffer
.set_string(area.x, area.y + 3, "VIEWLINE03", Style::default());
})
.unwrap();
terminal
.insert_before(2, |buf| {
buf.set_string(0, 0, "INSERTED00", Style::default());
buf.set_string(0, 1, "INSERTED01", Style::default());
})
.unwrap();
terminal.backend().assert_buffer_lines([
"VIEWLINE00",
"VIEWLINE01",
"VIEWLINE02",
"VIEWLINE03",
]);
terminal
.backend()
.assert_scrollback_lines(["INSERTED00", "INSERTED01"]);
}
}
}

View File

@@ -0,0 +1,737 @@
use crate::backend::Backend;
use crate::terminal::{CompletedFrame, Frame, Terminal};
impl<B: Backend> Terminal<B> {
/// Draws a single frame to the terminal.
///
/// Returns a [`CompletedFrame`] if successful, otherwise a backend error (`B::Error`).
///
/// If the render callback passed to this method can fail, use [`try_draw`] instead.
///
/// Applications should call `draw` or [`try_draw`] in a loop to continuously render the
/// terminal. These methods are the main entry points for drawing to the terminal.
///
/// [`try_draw`]: Terminal::try_draw
///
/// The [`Frame`] passed to the render callback represents the currently configured
/// [`Viewport`] (see [`Frame::area`] and [`Terminal::with_options`]).
///
/// Build layout relative to the [`Rect`] returned by [`Frame::area`] rather than assuming the
/// origin is `(0, 0)`, so the same rendering code works for fixed and inline viewports.
///
/// [`Frame::area`]: crate::terminal::Frame::area
/// [`Rect`]: crate::layout::Rect
/// [`Viewport`]: crate::terminal::Viewport
///
/// This method will:
///
/// - call [`Terminal::autoresize`] if necessary
/// - call the render callback, passing it a [`Frame`] reference to render to
/// - call [`Terminal::flush`] to write changes to the backend
/// - show/hide the cursor based on [`Frame::set_cursor_position`]
/// - call [`Terminal::swap_buffers`] to prepare for the next render pass
/// - call [`Backend::flush`]
/// - return a [`CompletedFrame`] with the current buffer and the area used for rendering
///
/// The [`CompletedFrame`] returned by this method can be useful for debugging or testing
/// purposes, but it is often not used in regular applications.
///
/// The render callback should fully render the entire frame when called, including areas that
/// are unchanged from the previous frame. This is because each frame is compared to the
/// previous frame to determine what has changed, and only the changes are written to the
/// terminal. If the render callback does not fully render the frame, the terminal will not be
/// in a consistent state.
///
/// # Examples
///
/// ```rust,no_run
/// # mod ratatui {
/// # pub use ratatui_core::backend;
/// # pub use ratatui_core::layout;
/// # pub use ratatui_core::terminal::{Frame, Terminal};
/// # }
/// use ratatui::backend::TestBackend;
/// use ratatui::layout::Position;
/// use ratatui::{Frame, Terminal};
///
/// let backend = TestBackend::new(10, 10);
/// let mut terminal = Terminal::new(backend)?;
///
/// // With a closure.
/// terminal.draw(|frame| {
/// let area = frame.area();
/// frame.render_widget("Hello World!", area);
/// frame.set_cursor_position(Position { x: 0, y: 0 });
/// })?;
///
/// // Or with a function.
/// terminal.draw(render)?;
///
/// fn render(frame: &mut Frame<'_>) {
/// frame.render_widget("Hello World!", frame.area());
/// }
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
///
/// [`Backend::flush`]: crate::backend::Backend::flush
pub fn draw<F>(&mut self, render_callback: F) -> Result<CompletedFrame<'_>, B::Error>
where
F: FnOnce(&mut Frame),
{
self.try_draw(|frame| {
render_callback(frame);
Ok::<(), B::Error>(())
})
}
/// Tries to draw a single frame to the terminal.
///
/// Returns [`Result::Ok`] containing a [`CompletedFrame`] if successful, otherwise
/// [`Result::Err`] containing the backend error (`B::Error`) that caused the failure.
///
/// This is the equivalent of [`Terminal::draw`] but the render callback is a function or
/// closure that returns a `Result` instead of nothing.
///
/// Applications should call `try_draw` or [`draw`] in a loop to continuously render the
/// terminal. These methods are the main entry points for drawing to the terminal.
///
/// [`draw`]: Terminal::draw
///
/// The [`Frame`] passed to the render callback represents the currently configured
/// [`Viewport`] (see [`Frame::area`] and [`Terminal::with_options`]).
///
/// Build layout relative to the [`Rect`] returned by [`Frame::area`] rather than assuming the
/// origin is `(0, 0)`, so the same rendering code works for fixed and inline viewports.
///
/// [`Frame::area`]: crate::terminal::Frame::area
/// [`Rect`]: crate::layout::Rect
/// [`Viewport`]: crate::terminal::Viewport
///
/// This method will:
///
/// - call [`Terminal::autoresize`] if necessary
/// - call the render callback, passing it a [`Frame`] reference to render to
/// - call [`Terminal::flush`] to write changes to the backend
/// - show/hide the cursor based on [`Frame::set_cursor_position`]
/// - call [`Terminal::swap_buffers`] to prepare for the next render pass
/// - call [`Backend::flush`]
/// - return a [`CompletedFrame`] with the current buffer and the area used for rendering
///
/// The render callback passed to `try_draw` can return any [`Result`] with an error type that
/// can be converted into `B::Error` using the [`Into`] trait. This makes it possible to use the
/// `?` operator to propagate errors that occur during rendering. If the render callback returns
/// an error, the error will be returned from `try_draw` and the terminal will not be updated.
///
/// The [`CompletedFrame`] returned by this method can be useful for debugging or testing
/// purposes, but it is often not used in regular applications.
///
/// The render callback should fully render the entire frame when called, including areas that
/// are unchanged from the previous frame. This is because each frame is compared to the
/// previous frame to determine what has changed, and only the changes are written to the
/// terminal. If the render function does not fully render the frame, the terminal will not be
/// in a consistent state.
///
/// # Examples
///
/// ```rust,no_run
/// # #![allow(unexpected_cfgs)]
/// # #[cfg(feature = "crossterm")]
/// # {
/// use std::io;
///
/// use ratatui::backend::CrosstermBackend;
/// use ratatui::layout::Position;
/// use ratatui::{Frame, Terminal};
///
/// let backend = CrosstermBackend::new(std::io::stdout());
/// let mut terminal = Terminal::new(backend)?;
///
/// // With a closure that returns `Result`.
/// terminal.try_draw(|frame| -> io::Result<()> {
/// let _value: u8 = "42".parse().map_err(io::Error::other)?;
/// let area = frame.area();
/// frame.render_widget("Hello World!", area);
/// frame.set_cursor_position(Position { x: 0, y: 0 });
/// Ok(())
/// })?;
///
/// // Or with a function.
/// terminal.try_draw(render)?;
///
/// fn render(frame: &mut Frame<'_>) -> io::Result<()> {
/// frame.render_widget("Hello World!", frame.area());
/// Ok(())
/// }
/// # }
/// # #[cfg(not(feature = "crossterm"))]
/// # {
/// # use ratatui_core::{backend::TestBackend, terminal::Terminal};
/// # let backend = TestBackend::new(10, 10);
/// # let mut terminal = Terminal::new(backend)?;
/// # terminal
/// # .try_draw(|frame| {
/// # frame.render_widget("Hello World!", frame.area());
/// # Ok::<(), core::convert::Infallible>(())
/// # })
/// # ?;
/// # }
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
///
/// [`Backend::flush`]: crate::backend::Backend::flush
pub fn try_draw<F, E>(&mut self, render_callback: F) -> Result<CompletedFrame<'_>, B::Error>
where
F: FnOnce(&mut Frame) -> Result<(), E>,
E: Into<B::Error>,
{
// Autoresize - otherwise we get glitches if shrinking or potential desync between widgets
// and the terminal (if growing), which may OOB.
self.autoresize()?;
let mut frame = self.get_frame();
render_callback(&mut frame).map_err(Into::into)?;
// We can't change the cursor position right away because we have to flush the frame to
// stdout first. But we also can't keep the frame around, since it holds a &mut to
// Buffer. Thus, we're taking the important data out of the Frame and dropping it.
let cursor_position = frame.cursor_position;
// Apply the buffer diff to the backend (this is the terminal's "flush" step, distinct
// from `Backend::flush` below which flushes the backend's output).
self.flush()?;
match cursor_position {
None => self.hide_cursor()?,
Some(position) => {
self.show_cursor()?;
self.set_cursor_position(position)?;
}
}
self.swap_buffers();
// Flush any buffered backend output.
self.backend.flush()?;
let completed_frame = CompletedFrame {
buffer: &self.buffers[1 - self.current],
area: self.last_known_area,
count: self.frame_count,
};
// increment frame count before returning from draw
self.frame_count = self.frame_count.wrapping_add(1);
Ok(completed_frame)
}
}
#[cfg(test)]
mod tests {
use core::fmt;
use crate::backend::{Backend, ClearType, TestBackend, WindowSize};
use crate::buffer::{Buffer, Cell};
use crate::layout::{Position, Rect};
use crate::terminal::{Terminal, TerminalOptions, Viewport};
#[derive(Debug, Clone, Eq, PartialEq)]
struct TestError(&'static str);
impl fmt::Display for TestError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl core::error::Error for TestError {}
/// A thin wrapper around [`TestBackend`] with a fallible error type.
///
/// [`TestBackend`] uses [`core::convert::Infallible`] as its associated `Backend::Error`, which
/// is ideal for most tests but makes it impossible to write a `try_draw` callback that returns
/// an error (because `E: Into<B::Error>` would require converting a real error into
/// `Infallible`). This wrapper keeps the same observable backend behavior (buffer + cursor)
/// while allowing tests to exercise `Terminal::try_draw`'s error path.
#[derive(Debug, Clone, Eq, PartialEq)]
struct FallibleTestBackend {
inner: TestBackend,
}
impl FallibleTestBackend {
fn new(inner: TestBackend) -> Self {
Self { inner }
}
}
impl Backend for FallibleTestBackend {
type Error = TestError;
fn draw<'a, I>(&mut self, content: I) -> Result<(), Self::Error>
where
I: Iterator<Item = (u16, u16, &'a crate::buffer::Cell)>,
{
self.inner.draw(content).map_err(|err| match err {})
}
fn append_lines(&mut self, n: u16) -> Result<(), Self::Error> {
self.inner.append_lines(n).map_err(|err| match err {})
}
fn hide_cursor(&mut self) -> Result<(), Self::Error> {
self.inner.hide_cursor().map_err(|err| match err {})
}
fn show_cursor(&mut self) -> Result<(), Self::Error> {
self.inner.show_cursor().map_err(|err| match err {})
}
fn get_cursor_position(&mut self) -> Result<Position, Self::Error> {
self.inner.get_cursor_position().map_err(|err| match err {})
}
fn set_cursor_position<P: Into<Position>>(
&mut self,
position: P,
) -> Result<(), Self::Error> {
self.inner
.set_cursor_position(position)
.map_err(|err| match err {})
}
fn clear(&mut self) -> Result<(), Self::Error> {
self.inner.clear().map_err(|err| match err {})
}
fn clear_region(&mut self, clear_type: ClearType) -> Result<(), Self::Error> {
self.inner
.clear_region(clear_type)
.map_err(|err| match err {})
}
fn size(&self) -> Result<crate::layout::Size, Self::Error> {
self.inner.size().map_err(|err| match err {})
}
fn window_size(&mut self) -> Result<WindowSize, Self::Error> {
self.inner.window_size().map_err(|err| match err {})
}
fn flush(&mut self) -> Result<(), Self::Error> {
self.inner.flush().map_err(|err| match err {})
}
#[cfg(feature = "scrolling-regions")]
fn scroll_region_up(
&mut self,
region: core::ops::Range<u16>,
line_count: u16,
) -> Result<(), Self::Error> {
self.inner
.scroll_region_up(region, line_count)
.map_err(|err| match err {})
}
#[cfg(feature = "scrolling-regions")]
fn scroll_region_down(
&mut self,
region: core::ops::Range<u16>,
line_count: u16,
) -> Result<(), Self::Error> {
self.inner
.scroll_region_down(region, line_count)
.map_err(|err| match err {})
}
}
/// `draw` hides the cursor when the frame does not request a cursor position.
///
/// This asserts the end-to-end effect on the backend (buffer contents + cursor state) as well
/// as internal frame counting.
#[test]
fn draw_hides_cursor_when_frame_cursor_is_not_set() {
let backend = TestBackend::new(3, 2);
let mut terminal = Terminal::new(backend).unwrap();
terminal.show_cursor().unwrap();
let completed = terminal
.draw(|frame| {
// Ensure the frame produces updates so `Terminal::flush` writes to the backend.
frame.buffer_mut()[(0, 0)] = Cell::new("x");
})
.unwrap();
assert_eq!(completed.count, 0, "first draw returns count 0");
assert_eq!(
completed.area,
Rect::new(0, 0, 3, 2),
"completed area matches terminal size in fullscreen mode"
);
assert_eq!(
completed.buffer,
&Buffer::with_lines(["x ", " "]),
"completed buffer contains the rendered content"
);
assert!(terminal.hidden_cursor);
assert!(!terminal.backend().cursor_visible());
assert_eq!(
terminal.frame_count, 1,
"successful draw increments frame_count"
);
}
/// `draw` applies the cursor requested by `Frame::set_cursor_position`.
///
/// The cursor is updated after rendering has been flushed, so it appears on top of the drawn
/// UI.
#[test]
fn draw_shows_and_positions_cursor_when_frame_cursor_is_set() {
let backend = TestBackend::new(3, 2);
let mut terminal = Terminal::new(backend).unwrap();
terminal.hide_cursor().unwrap();
terminal
.draw(|frame| {
// The cursor is applied after the frame is flushed.
frame.set_cursor_position(Position { x: 2, y: 1 });
frame.buffer_mut()[(1, 0)] = Cell::new("y");
})
.unwrap();
assert!(!terminal.hidden_cursor);
assert!(terminal.backend().cursor_visible());
assert_eq!(
terminal.backend().cursor_position(),
Position { x: 2, y: 1 },
"backend cursor is positioned after flushing"
);
assert_eq!(
terminal.last_known_cursor_pos,
Position { x: 2, y: 1 },
"terminal cursor tracking matches the final cursor position"
);
}
/// When the render callback returns an error, `try_draw` does not update the terminal.
///
/// This is a characterization of the "no partial updates" behavior: backend contents and
/// cursor state are unchanged and `frame_count` does not advance.
#[test]
fn try_draw_propagates_render_errors_without_updating_backend() {
let backend = FallibleTestBackend::new(TestBackend::with_lines(["aaa", "bbb"]));
let mut terminal = Terminal::new(backend).unwrap();
terminal.show_cursor().unwrap();
let was_hidden = terminal.hidden_cursor;
let cursor_visible = terminal.backend().inner.cursor_visible();
let cursor_position = terminal.backend().inner.cursor_position();
let result = terminal.try_draw(|_frame| Err::<(), _>(TestError("render failed")));
assert_eq!(
result.unwrap_err(),
TestError("render failed"),
"try_draw returns the render callback error"
);
assert_eq!(terminal.frame_count, 0, "frame_count is unchanged on error");
assert_eq!(
terminal.backend().inner.buffer(),
&Buffer::with_lines(["aaa", "bbb"]),
"backend buffer is unchanged on error"
);
assert_eq!(
terminal.hidden_cursor, was_hidden,
"terminal cursor state is unchanged on error"
);
assert_eq!(
terminal.backend().inner.cursor_visible(),
cursor_visible,
"backend cursor visibility is unchanged on error"
);
assert_eq!(
terminal.backend().inner.cursor_position(),
cursor_position,
"backend cursor position is unchanged on error"
);
}
/// `draw` autoresizes fullscreen terminals and clears before rendering.
///
/// This simulates the backend resizing between draw calls; `draw` runs `autoresize()` first
/// (which calls `resize()` and clears) so the frame renders into a fresh, correctly-sized
/// region.
#[test]
fn draw_clears_on_fullscreen_resize_before_rendering() {
let backend = TestBackend::with_lines(["xxx", "yyy"]);
let mut terminal = Terminal::new(backend).unwrap();
terminal.backend_mut().resize(4, 3);
terminal
.draw(|frame| {
// Render a marker to show we rendered after the clear.
frame.buffer_mut()[(0, 0)] = Cell::new("x");
})
.unwrap();
assert_eq!(
terminal.viewport_area,
Rect::new(0, 0, 4, 3),
"viewport area tracks the resized terminal size"
);
assert_eq!(
terminal.last_known_area,
Rect::new(0, 0, 4, 3),
"last_known_area tracks the resized terminal size"
);
terminal
.backend()
.assert_buffer_lines(["x ", " ", " "]);
}
/// In fixed viewports, `Frame::area` is an absolute terminal rectangle.
///
/// This asserts that rendering at `frame.area().x/y` updates the backend at that absolute
/// position.
#[test]
fn draw_uses_fixed_viewport_coordinates() {
let backend = TestBackend::new(5, 3);
let mut terminal = Terminal::with_options(
backend,
TerminalOptions {
viewport: Viewport::Fixed(Rect::new(2, 1, 2, 1)),
},
)
.unwrap();
terminal
.draw(|frame| {
assert_eq!(
frame.area(),
Rect::new(2, 1, 2, 1),
"frame area matches the configured fixed viewport"
);
let area = frame.area();
frame.buffer_mut()[(area.x, area.y)] = Cell::new("z");
})
.unwrap();
terminal
.backend()
.assert_buffer_lines([" ", " z ", " "]);
}
/// Inline viewports render into a sub-rectangle, but `CompletedFrame::area` reports terminal
/// size.
///
/// This asserts that the `CompletedFrame` returned from `draw` reports the full terminal
/// size while its buffer is sized to the inline viewport, and that rendering uses the inline
/// viewport's absolute origin.
#[test]
fn draw_inline_completed_frame_reports_terminal_size() {
let mut inner = TestBackend::new(6, 5);
inner.set_cursor_position((0, 2)).unwrap();
let mut terminal = Terminal::with_options(
inner,
TerminalOptions {
viewport: Viewport::Inline(3),
},
)
.unwrap();
let viewport_area = terminal.viewport_area;
{
// `CompletedFrame` borrows the terminal, so backend assertions happen after it drops.
let completed = terminal
.draw(|frame| {
assert_eq!(
frame.area(),
viewport_area,
"inline frame area matches the computed viewport"
);
frame.buffer_mut()[(viewport_area.x, viewport_area.y)] = Cell::new("i");
})
.unwrap();
assert_eq!(
completed.area,
Rect::new(0, 0, 6, 5),
"completed area reports the full terminal size"
);
assert_eq!(
completed.buffer.area, viewport_area,
"completed buffer is sized to the inline viewport"
);
}
assert_eq!(
terminal.backend().buffer()[(viewport_area.x, viewport_area.y)].symbol(),
"i"
);
}
/// Inline viewports are autoresized during `draw`.
///
/// This asserts that when the backend reports a different terminal size, `draw` recomputes the
/// inline viewport rectangle and renders into the new viewport area.
#[test]
fn draw_inline_autoresize_recomputes_viewport_on_grow() {
let mut backend = TestBackend::new(6, 5);
backend
.set_cursor_position(Position { x: 0, y: 2 })
.unwrap();
let mut terminal = Terminal::with_options(
backend,
TerminalOptions {
viewport: Viewport::Inline(3),
},
)
.unwrap();
terminal
.draw(|frame| {
let area = frame.area();
frame.set_cursor_position(Position {
x: area.x,
y: area.y.saturating_add(1),
});
frame.buffer_mut()[(area.x, area.y)] = Cell::new("a");
})
.unwrap();
terminal.backend_mut().resize(8, 7);
let new_area = Rect::new(0, 0, 8, 7);
let previous_viewport = terminal.viewport_area;
terminal
.draw(|frame| {
let area = frame.area();
frame.buffer_mut()[(area.x, area.y)] = Cell::new("g");
})
.unwrap();
assert_eq!(
terminal.last_known_area, new_area,
"inline last_known_area tracks the resized terminal size"
);
assert_eq!(
terminal.viewport_area.width, 8,
"inline viewport width tracks the resized terminal width"
);
assert_eq!(
terminal.viewport_area.height, 3,
"inline viewport height is capped by the configured inline height"
);
assert_eq!(
terminal.viewport_area.y, previous_viewport.y,
"inline viewport stays anchored relative to the cursor across a grow"
);
assert_eq!(
terminal.backend().buffer()[(terminal.viewport_area.x, terminal.viewport_area.y)]
.symbol(),
"g",
"render output lands at the recomputed viewport origin"
);
}
/// Inline viewports are autoresized during `draw`.
///
/// This asserts that shrinking the backend terminal size causes `draw` to recompute the inline
/// viewport origin so it stays visible, and that rendering uses the new viewport origin.
#[test]
fn draw_inline_autoresize_recomputes_viewport_on_shrink() {
let mut backend = TestBackend::new(6, 6);
backend
.set_cursor_position(Position { x: 0, y: 4 })
.unwrap();
let mut terminal = Terminal::with_options(
backend,
TerminalOptions {
viewport: Viewport::Inline(4),
},
)
.unwrap();
terminal
.draw(|frame| {
let area = frame.area();
frame.set_cursor_position(Position {
x: area.x,
y: area.y.saturating_add(2),
});
frame.buffer_mut()[(area.x, area.y)] = Cell::new("a");
})
.unwrap();
terminal.backend_mut().resize(6, 5);
let new_area = Rect::new(0, 0, 6, 5);
terminal
.draw(|frame| {
let area = frame.area();
frame.buffer_mut()[(area.x, area.y)] = Cell::new("s");
})
.unwrap();
assert_eq!(
terminal.last_known_area, new_area,
"inline last_known_area tracks the resized terminal size"
);
assert_eq!(
terminal.viewport_area,
Rect::new(0, 1, 6, 4),
"inline viewport is recomputed to stay visible after a shrink"
);
assert_eq!(
terminal.backend().buffer()[(terminal.viewport_area.x, terminal.viewport_area.y)]
.symbol(),
"s",
"render output lands at the recomputed viewport origin"
);
}
/// `CompletedFrame` is only valid until the next draw call.
///
/// This asserts that each `draw` returns the buffer for the frame that was just rendered
/// and that the count increments after each successful draw.
#[test]
fn draw_returns_completed_frame_for_current_render_pass() {
let backend = TestBackend::new(3, 2);
let mut terminal = Terminal::new(backend).unwrap();
{
// `CompletedFrame` borrows the terminal, and is only valid until the next draw call.
let first = terminal
.draw(|frame| {
frame.buffer_mut()[(0, 0)] = Cell::new("a");
})
.unwrap();
assert_eq!(first.count, 0, "first CompletedFrame has count 0");
assert_eq!(
first.buffer,
&Buffer::with_lines(["a ", " "]),
"first frame's buffer contains the first render output"
);
}
let second = terminal
.draw(|frame| {
frame.buffer_mut()[(0, 0)] = Cell::new("b");
})
.unwrap();
assert_eq!(second.count, 1, "second CompletedFrame has count 1");
assert_eq!(
second.buffer,
&Buffer::with_lines(["b ", " "]),
"second frame's buffer contains the second render output"
);
}
}

View File

@@ -0,0 +1,255 @@
use crate::backend::Backend;
use crate::layout::Rect;
use crate::terminal::inline::compute_inline_size;
use crate::terminal::{Terminal, Viewport};
impl<B: Backend> Terminal<B> {
/// Updates the Terminal so that internal buffers match the requested area.
///
/// This updates the buffer size used for rendering and triggers a full clear so the next
/// [`Terminal::draw`] paints into a consistent area.
///
/// When the viewport is [`Viewport::Inline`], the `area` argument is treated as the new
/// terminal size and the viewport origin is recomputed relative to the current cursor position.
/// Ratatui attempts to keep the cursor at the same relative row within the viewport across
/// resizes.
///
/// See also: [`Terminal::autoresize`] (automatic resizing during [`Terminal::draw`]).
pub fn resize(&mut self, area: Rect) -> Result<(), B::Error> {
let next_area = match self.viewport {
Viewport::Inline(height) => {
let offset_in_previous_viewport = self
.last_known_cursor_pos
.y
.saturating_sub(self.viewport_area.top());
compute_inline_size(
&mut self.backend,
height,
area.as_size(),
offset_in_previous_viewport,
)?
.0
}
Viewport::Fixed(_) | Viewport::Fullscreen => area,
};
self.set_viewport_area(next_area);
self.clear()?;
self.last_known_area = area;
Ok(())
}
/// Queries the backend for size and resizes if it doesn't match the previous size.
///
/// This is called automatically during [`Terminal::draw`] for fullscreen and inline viewports.
/// Fixed viewports are not automatically resized.
///
/// If the size changed, this calls [`Terminal::resize`] (which clears the screen).
pub fn autoresize(&mut self) -> Result<(), B::Error> {
// fixed viewports do not get autoresized
if matches!(self.viewport, Viewport::Fullscreen | Viewport::Inline(_)) {
let area = self.size()?.into();
if area != self.last_known_area {
self.resize(area)?;
}
}
Ok(())
}
/// Resize internal buffers and update the current viewport area.
///
/// This is an internal helper used by [`Terminal::with_options`] and [`Terminal::resize`].
pub(crate) fn set_viewport_area(&mut self, area: Rect) {
self.buffers[self.current].resize(area);
self.buffers[1 - self.current].resize(area);
self.viewport_area = area;
}
}
#[cfg(test)]
mod tests {
use crate::backend::{Backend, TestBackend};
use crate::buffer::Buffer;
use crate::layout::{Position, Rect};
use crate::terminal::{Terminal, TerminalOptions, Viewport};
#[test]
fn resize_fullscreen_updates_viewport_and_buffer_areas() {
let backend = TestBackend::new(3, 2);
let mut terminal = Terminal::new(backend).unwrap();
terminal.backend_mut().resize(4, 3);
let new_area = Rect::new(0, 0, 4, 3);
terminal.resize(new_area).unwrap();
assert_eq!(terminal.viewport_area, new_area);
assert_eq!(terminal.last_known_area, new_area);
assert_eq!(terminal.buffers[terminal.current].area, new_area);
assert_eq!(terminal.buffers[1 - terminal.current].area, new_area);
}
#[test]
fn resize_fullscreen_triggers_clear_and_resets_back_buffer() {
// This test is specifically about the side effects of `resize`:
// - it calls `clear` to force a full redraw
// - it resets the "previous" buffer
let backend = TestBackend::new(3, 2);
let mut terminal = Terminal::new(backend).unwrap();
// Put visible content on the backend so we can tell whether a clear happened.
{
let frame = terminal.get_frame();
frame.buffer[(0, 0)].set_symbol("x");
}
terminal.flush().unwrap();
terminal.backend().assert_buffer_lines(["x ", " "]);
terminal.backend_mut().resize(4, 3);
let new_area = Rect::new(0, 0, 4, 3);
terminal.resize(new_area).unwrap();
terminal
.backend()
.assert_buffer_lines([" ", " ", " "]);
assert_eq!(
terminal.buffers[1 - terminal.current],
Buffer::empty(new_area)
);
}
#[test]
fn autoresize_fullscreen_uses_backend_size_when_changed() {
let backend = TestBackend::new(3, 2);
let mut terminal = Terminal::new(backend).unwrap();
{
let frame = terminal.get_frame();
frame.buffer[(0, 0)].set_symbol("x");
}
terminal.flush().unwrap();
terminal.backend_mut().resize(4, 3);
terminal.autoresize().unwrap();
assert_eq!(terminal.viewport_area, Rect::new(0, 0, 4, 3));
assert_eq!(terminal.last_known_area, Rect::new(0, 0, 4, 3));
terminal
.backend()
.assert_buffer_lines([" ", " ", " "]);
}
#[test]
fn autoresize_fixed_does_not_change_viewport() {
let backend = TestBackend::with_lines(["xxx", "yyy"]);
let mut terminal = Terminal::with_options(
backend,
TerminalOptions {
viewport: Viewport::Fixed(Rect::new(1, 0, 2, 2)),
},
)
.unwrap();
terminal.autoresize().unwrap();
assert_eq!(terminal.viewport_area, Rect::new(1, 0, 2, 2));
assert_eq!(terminal.last_known_area, Rect::new(1, 0, 2, 2));
terminal.backend().assert_buffer_lines(["xxx", "yyy"]);
}
#[test]
fn resize_fixed_changes_viewport_area_and_buffer_sizes() {
let backend = TestBackend::new(5, 3);
let mut terminal = Terminal::with_options(
backend,
TerminalOptions {
viewport: Viewport::Fixed(Rect::new(1, 1, 2, 1)),
},
)
.unwrap();
terminal.resize(Rect::new(0, 0, 3, 2)).unwrap();
assert_eq!(terminal.viewport_area, Rect::new(0, 0, 3, 2));
assert_eq!(terminal.last_known_area, Rect::new(0, 0, 3, 2));
assert_eq!(
terminal.buffers[terminal.current].area,
terminal.viewport_area
);
assert_eq!(
terminal.buffers[1 - terminal.current].area,
terminal.viewport_area
);
}
#[test]
fn resize_inline_recomputes_origin_using_previous_cursor_offset() {
let mut backend = TestBackend::new(10, 10);
backend
.set_cursor_position(Position { x: 0, y: 4 })
.unwrap();
let mut terminal = Terminal::with_options(
backend,
TerminalOptions {
viewport: Viewport::Inline(4),
},
)
.unwrap();
assert_eq!(terminal.viewport_area, Rect::new(0, 4, 10, 4));
// Characterization test:
// This test simulates a terminal resize (increasing the terminal height) while an inline
// viewport is active. The key behavior being exercised is that the viewport remains
// anchored to the backend cursor row and preserves the cursor's relative offset within the
// previous viewport.
//
// For inline viewports, `Terminal::resize(area)` interprets `area` as the *new terminal
// size*, then recomputes the viewport origin based on:
// - the backend cursor position at the time of the call
// - the cursor offset within the *previous* viewport (`last_known_cursor_pos -
// viewport_top`)
//
// This means `resize(Rect { .. })` can update `viewport_area.y` even when the passed-in
// `area.y` is 0, because `viewport_area` is anchored to the cursor row, not the terminal
// origin.
terminal.last_known_cursor_pos = Position { x: 0, y: 5 };
terminal
.backend_mut()
.set_cursor_position(Position { x: 0, y: 6 })
.unwrap();
terminal.backend_mut().resize(10, 12);
let new_terminal_area = Rect::new(0, 0, 10, 12);
terminal.resize(new_terminal_area).unwrap();
// Previous viewport top was y=4, and last_known_cursor_pos was y=5, so the cursor offset
// within the viewport is 1 row. At the time of resize the backend cursor is at y=6, so the
// new viewport top becomes 6 - 1 = 5.
assert_eq!(terminal.viewport_area, Rect::new(0, 5, 10, 4));
assert_eq!(terminal.last_known_area, new_terminal_area);
}
#[test]
fn resize_inline_clamps_height_to_terminal_height() {
// Characterization test:
// This test simulates a terminal resize that *reduces* the terminal height. Inline
// viewports clamp their height to the new terminal size so the viewport remains fully
// visible.
let mut backend = TestBackend::new(10, 10);
backend
.set_cursor_position(Position { x: 0, y: 0 })
.unwrap();
let mut terminal = Terminal::with_options(
backend,
TerminalOptions {
viewport: Viewport::Inline(10),
},
)
.unwrap();
terminal.backend_mut().resize(10, 3);
terminal.resize(Rect::new(0, 0, 10, 3)).unwrap();
assert_eq!(terminal.viewport_area, Rect::new(0, 0, 10, 3));
}
}

File diff suppressed because it is too large Load Diff