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:
@@ -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,
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
75
ratatui-core/src/terminal/backend.rs
Normal file
75
ratatui-core/src/terminal/backend.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
304
ratatui-core/src/terminal/buffers.rs
Normal file
304
ratatui-core/src/terminal/buffers.rs
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
151
ratatui-core/src/terminal/cursor.rs
Normal file
151
ratatui-core/src/terminal/cursor.rs
Normal 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 });
|
||||
}
|
||||
}
|
||||
236
ratatui-core/src/terminal/init.rs
Normal file
236
ratatui-core/src/terminal/init.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
927
ratatui-core/src/terminal/inline.rs
Normal file
927
ratatui-core/src/terminal/inline.rs
Normal 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"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
737
ratatui-core/src/terminal/render.rs
Normal file
737
ratatui-core/src/terminal/render.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
255
ratatui-core/src/terminal/resize.rs
Normal file
255
ratatui-core/src/terminal/resize.rs
Normal 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
Reference in New Issue
Block a user