Sweep every first-party crate source (1956 .rs files) to the project comment guidelines: delete redundant restatements, decorative banners, change narration, and end-of-line comments; keep and tighten the crucial ones (invariants, bug rationale, SAFETY blocks, ported-source attribution). No functional code changed. Every edit is proven comment-only against the prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a separate doctest-fence check. Where removing a comment made rustfmt or clippy want to re-lay-out adjacent code, the minimal triggering comment is restored so code tokens stay byte-identical. Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy --workspace --all-targets (0 warnings). Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for these guidelines (flags banners, end-of-line comments, change narration, and commented-out code).
1453 lines
57 KiB
Rust
1453 lines
57 KiB
Rust
// Derived from ratatui's Terminal implementation (MIT / Apache-2.0 dual license).
|
||
// Upstream: https://github.com/ratatui/ratatui — Copyright (c) The Ratatui Developers.
|
||
// Modified for inline viewport support. See ../NOTICE and repository THIRD-PARTY-NOTICES.
|
||
//
|
||
#![allow(clippy::collapsible_if)]
|
||
|
||
use std::io::{self, Write};
|
||
use std::sync::Arc;
|
||
|
||
use ratatui::{
|
||
CompletedFrame, Frame, TerminalOptions, Viewport,
|
||
backend::{Backend, ClearType},
|
||
buffer::{Buffer, Cell},
|
||
layout::{Position, Rect, Size},
|
||
};
|
||
use unicode_width::UnicodeWidthStr as _;
|
||
|
||
/// A hyperlink region on a single screen row, in absolute viewport coordinates.
|
||
///
|
||
/// Handed to [`Terminal::set_frame_links`] each frame. The terminal folds these
|
||
/// into a per-cell link layer that participates in the frame diff, so OSC 8
|
||
/// sequences are emitted (and cleared) by the same machinery that draws cells.
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct LinkSpan {
|
||
pub row: u16,
|
||
pub col_start: u16,
|
||
pub col_end: u16,
|
||
pub url: Arc<str>,
|
||
pub id: Option<u32>,
|
||
}
|
||
|
||
/// Resolved hyperlink target stored in a frame's link table; `link_ids` entries
|
||
/// are 1-based indices into the matching `link_tables` vector.
|
||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||
struct LinkRef {
|
||
url: Arc<str>,
|
||
id: Option<u32>,
|
||
}
|
||
|
||
/// Resolve a per-cell link id (`0` = none) to its [`LinkRef`].
|
||
fn resolve_link<'a>(ids: &[u32], table: &'a [LinkRef], i: usize) -> Option<&'a LinkRef> {
|
||
match ids.get(i).copied().unwrap_or(0) {
|
||
0 => None,
|
||
id => table.get((id - 1) as usize),
|
||
}
|
||
}
|
||
|
||
/// Emit an OSC 8 hyperlink open sequence.
|
||
///
|
||
/// Control characters are stripped from `url` to prevent premature sequence
|
||
/// termination or escape injection. The sequence is terminated with BEL
|
||
/// (`\x07`) for broadest terminal/multiplexer support; here the BEL is
|
||
/// immediately followed by the cell draw's ESC (cursor move / SGR), which all
|
||
/// mainstream terminals parse correctly (OSC-BEL followed by CSI is ubiquitous,
|
||
/// e.g. title sets). ST would also be valid but is less widely supported.
|
||
fn write_osc8_open<W: Write>(w: &mut W, url: &str, id: Option<u32>) -> io::Result<()> {
|
||
let sanitized: std::borrow::Cow<str> = if url.chars().any(|c| c.is_control()) {
|
||
std::borrow::Cow::Owned(url.chars().filter(|c| !c.is_control()).collect())
|
||
} else {
|
||
std::borrow::Cow::Borrowed(url)
|
||
};
|
||
match id {
|
||
Some(id) => write!(w, "\x1b]8;id={id};{sanitized}\x07"),
|
||
None => write!(w, "\x1b]8;;{sanitized}\x07"),
|
||
}
|
||
}
|
||
|
||
/// Emit an OSC 8 hyperlink close sequence.
|
||
fn write_osc8_close<W: Write>(w: &mut W) -> io::Result<()> {
|
||
w.write_all(b"\x1b]8;;\x07")
|
||
}
|
||
|
||
#[derive(Debug, Hash)]
|
||
pub struct OurFrame<'a> {
|
||
/// Where should the cursor be after drawing this frame?
|
||
///
|
||
/// If `None`, the cursor is hidden and its position is controlled by the backend. If `Some((x,
|
||
/// y))`, the cursor is shown and placed at `(x, y)` after the call to `Terminal::draw()`.
|
||
pub(crate) cursor_position: Option<Position>,
|
||
|
||
/// The area of the viewport
|
||
pub(crate) viewport_area: Rect,
|
||
|
||
/// The buffer that is used to draw the current frame
|
||
pub(crate) buffer: &'a mut Buffer,
|
||
|
||
/// The frame count indicating the sequence number of this frame.
|
||
pub(crate) count: usize,
|
||
}
|
||
|
||
// `OurFrame` mirrors ratatui's `Frame` field for field so that construction
|
||
// is possible at all: every `Frame` field is private to ratatui. The
|
||
// transmute is sound only while the two layouts stay identical, which the
|
||
// size assertion checks weakly — a field reorder of equal-sized fields would
|
||
// slip through, so re-verify these against ratatui on every version bump.
|
||
impl<'a> From<OurFrame<'a>> for Frame<'a> {
|
||
fn from(value: OurFrame<'a>) -> Self {
|
||
assert_eq!(
|
||
std::mem::size_of::<Frame>(),
|
||
std::mem::size_of::<OurFrame>()
|
||
);
|
||
unsafe { std::mem::transmute(value) }
|
||
}
|
||
}
|
||
|
||
impl<'a> From<Frame<'a>> for OurFrame<'a> {
|
||
fn from(value: Frame<'a>) -> Self {
|
||
assert_eq!(
|
||
std::mem::size_of::<Frame>(),
|
||
std::mem::size_of::<OurFrame>()
|
||
);
|
||
unsafe { std::mem::transmute(value) }
|
||
}
|
||
}
|
||
|
||
/// 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.
|
||
///
|
||
/// The [`Terminal`] is generic over a [`Backend`] implementation which is used to interface with
|
||
/// the underlying terminal library. The [`Backend`] trait is implemented for three popular Rust
|
||
/// terminal libraries: [Crossterm], [Termion] and [Termwiz]. See the [`backend`] module for more
|
||
/// information.
|
||
///
|
||
/// The `Terminal` struct maintains two buffers: the current and the previous.
|
||
/// When the widgets are drawn, the changes are accumulated in the current buffer.
|
||
/// At the end of each draw pass, the two buffers are compared, and only the changes
|
||
/// between these buffers are written to the terminal, avoiding any redundant operations.
|
||
/// After flushing these changes, the buffers are swapped to prepare for the next draw cycle.
|
||
///
|
||
/// The terminal also has a viewport which is the area of the terminal that is currently visible to
|
||
/// the user. It can be either fullscreen, inline or fixed. See [`Viewport`] for more information.
|
||
///
|
||
/// 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.
|
||
///
|
||
/// # Examples
|
||
///
|
||
/// ```rust,no_run
|
||
/// use std::io::stdout;
|
||
///
|
||
/// use ratatui::{backend::CrosstermBackend, widgets::Paragraph, Terminal};
|
||
///
|
||
/// let backend = CrosstermBackend::new(stdout());
|
||
/// let mut terminal = Terminal::new(backend)?;
|
||
/// terminal.draw(|frame| {
|
||
/// let area = frame.area();
|
||
/// frame.render_widget(Paragraph::new("Hello World!"), area);
|
||
/// })?;
|
||
/// # std::io::Result::Ok(())
|
||
/// ```
|
||
///
|
||
/// [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
|
||
/// [`Buffer`]: crate::buffer::Buffer
|
||
#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)]
|
||
pub struct Terminal<B>
|
||
where
|
||
B: Backend,
|
||
{
|
||
/// The backend used to interface with the terminal
|
||
backend: B,
|
||
/// Holds the results of the current and previous draw calls. The two are compared at the end
|
||
/// of each draw pass to output the necessary updates to the terminal
|
||
buffers: [Buffer; 2],
|
||
/// Index of the current buffer in the previous array
|
||
current: usize,
|
||
/// Whether the cursor is currently hidden
|
||
hidden_cursor: bool,
|
||
viewport: Viewport,
|
||
/// Area of the viewport
|
||
viewport_area: Rect,
|
||
/// Last known area of the terminal. Used to detect if the internal buffers have to be resized.
|
||
last_known_area: Rect,
|
||
/// Last known position of the cursor. Used to find the new area when the viewport is inlined
|
||
/// and the terminal resized.
|
||
last_known_cursor_pos: Position,
|
||
/// Number of frames rendered up until current time.
|
||
frame_count: usize,
|
||
/// Per-cell hyperlink id layer (`0` = no link), one per entry in `buffers`
|
||
/// and indexed identically (row-major over `viewport_area`). Populated by
|
||
/// [`Terminal::set_frame_links`] and diffed in [`Terminal::flush_with_links`].
|
||
link_ids: [Vec<u32>; 2],
|
||
/// Per-frame hyperlink table; a `link_ids` value of `n` refers to entry
|
||
/// `n - 1` of the matching table.
|
||
link_tables: [Vec<LinkRef>; 2],
|
||
}
|
||
|
||
impl<B> Drop for Terminal<B>
|
||
where
|
||
B: Backend,
|
||
{
|
||
fn drop(&mut self) {
|
||
// Attempt to restore the cursor state
|
||
if self.hidden_cursor {
|
||
let _ = self.show_cursor();
|
||
}
|
||
}
|
||
}
|
||
|
||
impl<B> Terminal<B>
|
||
where
|
||
B: Backend,
|
||
{
|
||
/// Creates a new [`Terminal`] with the given [`Backend`] with a full screen viewport.
|
||
///
|
||
/// # Example
|
||
///
|
||
/// ```rust,no_run
|
||
/// use std::io::stdout;
|
||
///
|
||
/// use ratatui::{backend::CrosstermBackend, Terminal};
|
||
///
|
||
/// let backend = CrosstermBackend::new(stdout());
|
||
/// let terminal = Terminal::new(backend)?;
|
||
/// # std::io::Result::Ok(())
|
||
/// ```
|
||
pub fn new(backend: B) -> io::Result<Self> {
|
||
Self::with_options(
|
||
backend,
|
||
TerminalOptions {
|
||
viewport: Viewport::Fullscreen,
|
||
},
|
||
)
|
||
}
|
||
|
||
/// Creates a new [`Terminal`] with the given [`Backend`] and [`TerminalOptions`].
|
||
///
|
||
/// # Example
|
||
///
|
||
/// ```rust
|
||
/// use std::io::stdout;
|
||
///
|
||
/// use ratatui::{backend::CrosstermBackend, layout::Rect, 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 })?;
|
||
/// # std::io::Result::Ok(())
|
||
/// ```
|
||
pub fn with_options(mut backend: B, options: TerminalOptions) -> io::Result<Self> {
|
||
let area = match options.viewport {
|
||
Viewport::Fullscreen | Viewport::Inline(_) => {
|
||
Rect::from((Position::ORIGIN, backend.size()?))
|
||
}
|
||
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()),
|
||
};
|
||
let link_len = (viewport_area.width as usize) * (viewport_area.height as usize);
|
||
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,
|
||
link_ids: [vec![0; link_len], vec![0; link_len]],
|
||
link_tables: [Vec::new(), Vec::new()],
|
||
})
|
||
}
|
||
|
||
/// Get a Frame object which provides a consistent view into the terminal state for rendering.
|
||
pub fn get_frame(&mut self) -> Frame<'_> {
|
||
let count = self.frame_count;
|
||
OurFrame {
|
||
cursor_position: None,
|
||
viewport_area: self.viewport_area,
|
||
buffer: self.current_buffer_mut(),
|
||
count,
|
||
}
|
||
.into()
|
||
}
|
||
|
||
/// Gets the current buffer as a mutable reference.
|
||
pub fn current_buffer_mut(&mut self) -> &mut Buffer {
|
||
&mut self.buffers[self.current]
|
||
}
|
||
|
||
/// Gets the backend
|
||
pub const fn backend(&self) -> &B {
|
||
&self.backend
|
||
}
|
||
|
||
/// Gets the backend as a mutable reference
|
||
pub fn backend_mut(&mut self) -> &mut B {
|
||
&mut self.backend
|
||
}
|
||
|
||
/// Obtains a difference between the previous and the current buffer and passes it to the
|
||
/// current backend for drawing. Returns `true` if any cells were changed.
|
||
///
|
||
/// Uses [`diff_large`] instead of ratatui's [`Buffer::diff`] to avoid a `u16`
|
||
/// truncation bug: upstream `pos_of()` casts the flat cell index to `u16`
|
||
/// before computing `(x, y)`, which silently wraps around when
|
||
/// `width * height > 65 535`. On extra-large terminals (e.g. 420×160 = 67 200
|
||
/// cells) this causes the entire UI to be rendered into a tiny corner.
|
||
pub fn flush(&mut self) -> io::Result<bool> {
|
||
let previous_buffer = &self.buffers[1 - self.current];
|
||
let current_buffer = &self.buffers[self.current];
|
||
let updates = diff_large(previous_buffer, current_buffer);
|
||
let has_changes = !updates.is_empty();
|
||
if let Some((col, row, _)) = updates.last() {
|
||
self.last_known_cursor_pos = Position { x: *col, y: *row };
|
||
}
|
||
self.backend.draw(updates.into_iter())?;
|
||
Ok(has_changes)
|
||
}
|
||
|
||
/// Set the hyperlink spans for the frame about to be flushed.
|
||
///
|
||
/// Rebuilds the *current* link layer from `spans` (absolute viewport
|
||
/// coordinates); the previous layer is retained so [`flush_with_links`] can
|
||
/// diff it. Call once per frame, after rendering and before
|
||
/// [`flush_with_links`]. Passing an empty slice clears the frame's links
|
||
/// (so links from the previous frame are diffed away).
|
||
///
|
||
/// [`flush_with_links`]: Self::flush_with_links
|
||
pub fn set_frame_links(&mut self, spans: &[LinkSpan]) {
|
||
let area = self.viewport_area;
|
||
let width = area.width as usize;
|
||
let len = width * (area.height as usize);
|
||
|
||
let ids = &mut self.link_ids[self.current];
|
||
ids.clear();
|
||
ids.resize(len, 0);
|
||
let table = &mut self.link_tables[self.current];
|
||
table.clear();
|
||
|
||
for span in spans {
|
||
if span.row < area.y || span.row >= area.bottom() {
|
||
continue;
|
||
}
|
||
let start = span.col_start.max(area.x);
|
||
let end = span.col_end.min(area.right());
|
||
if start >= end {
|
||
continue;
|
||
}
|
||
let id = (table.len() + 1) as u32;
|
||
table.push(LinkRef {
|
||
url: span.url.clone(),
|
||
id: span.id,
|
||
});
|
||
let row = (span.row - area.y) as usize;
|
||
for col in start..end {
|
||
let idx = row * width + (col - area.x) as usize;
|
||
if idx < ids.len() {
|
||
ids[idx] = id;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Like [`flush`](Self::flush) but emits OSC 8 hyperlinks for cells covered
|
||
/// by the current link layer (see [`set_frame_links`](Self::set_frame_links)).
|
||
///
|
||
/// A cell is rewritten when its content/style changed **or** its link
|
||
/// changed, so links are cleared automatically when they disappear — no
|
||
/// out-of-band repaint. Contiguous runs of cells sharing the same link are
|
||
/// wrapped in a single OSC 8 open/close around the upstream cell draw.
|
||
pub fn flush_with_links(&mut self) -> io::Result<bool>
|
||
where
|
||
B: Write,
|
||
{
|
||
let cur = self.current;
|
||
let prev = 1 - cur;
|
||
|
||
// Fast path: no hyperlinks in either the current or previous frame. The
|
||
// link layer can't affect the diff or emission, so fall back to the
|
||
// plain cell diff + draw — byte-for-byte identical to `flush` with zero
|
||
// per-cell link resolution. This keeps the overwhelmingly common
|
||
// link-free frame (streaming output, etc.) as cheap as before.
|
||
if self.link_tables[cur].is_empty() && self.link_tables[prev].is_empty() {
|
||
return self.flush();
|
||
}
|
||
|
||
let updates = diff_large_with_links(
|
||
&self.buffers[prev],
|
||
&self.buffers[cur],
|
||
&self.link_ids[prev],
|
||
&self.link_ids[cur],
|
||
&self.link_tables[prev],
|
||
&self.link_tables[cur],
|
||
);
|
||
let has_changes = !updates.is_empty();
|
||
if let Some((col, row, _)) = updates.last() {
|
||
self.last_known_cursor_pos = Position { x: *col, y: *row };
|
||
}
|
||
let area = self.buffers[cur].area;
|
||
emit_frame_with_links(
|
||
&mut self.backend,
|
||
&updates,
|
||
&self.link_ids[cur],
|
||
&self.link_tables[cur],
|
||
area,
|
||
)?;
|
||
Ok(has_changes)
|
||
}
|
||
|
||
/// Updates the Terminal so that internal buffers match the requested area.
|
||
///
|
||
/// Requested area will be saved to remain consistent when rendering. This leads to a full clear
|
||
/// of the screen.
|
||
pub fn resize(&mut self, area: Rect) -> io::Result<()> {
|
||
let next_area = match self.viewport {
|
||
// Full-height inline viewport: the inline viewport currently spans the
|
||
// entire terminal. This is how the viewport is used when the alternate
|
||
// screen is unavailable (e.g. under Zellij or tmux control mode, or
|
||
// with `--no-alt-screen`): the whole terminal is one inline viewport
|
||
// standing in for a fullscreen app. On resize it must keep spanning
|
||
// the entire terminal, exactly like a fullscreen viewport.
|
||
//
|
||
// The generic `compute_inline_size` path below is built for a *small*
|
||
// inline viewport anchored near the cursor and is wrong here in two
|
||
// ways: (1) it clamps the height to the fixed `Viewport::Inline(height)`
|
||
// captured at startup, so enlarging the terminal never grows the
|
||
// viewport — the UI ends up truncated at the bottom even though the
|
||
// width tracks the resize; and (2) on shrink it can reposition the
|
||
// viewport partly or fully off-screen. Filling the new area avoids both.
|
||
Viewport::Inline(_)
|
||
if self.viewport_area.y == 0
|
||
&& self.viewport_area.height >= self.last_known_area.height =>
|
||
{
|
||
area
|
||
}
|
||
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.
|
||
pub fn autoresize(&mut self) -> io::Result<()> {
|
||
// fixed viewports do not get autoresized
|
||
if matches!(self.viewport, Viewport::Fullscreen | Viewport::Inline(_)) {
|
||
let area = Rect::from((Position::ORIGIN, self.size()?));
|
||
if area != self.last_known_area {
|
||
self.resize(area)?;
|
||
}
|
||
};
|
||
Ok(())
|
||
}
|
||
|
||
/// Draws a single frame to the terminal.
|
||
///
|
||
/// Returns a [`CompletedFrame`] if successful, otherwise a [`std::io::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
|
||
///
|
||
/// This method will:
|
||
///
|
||
/// - autoresize the terminal if necessary
|
||
/// - call the render callback, passing it a [`Frame`] reference to render to
|
||
/// - flush the current internal state by copying the current buffer to the backend
|
||
/// - move the cursor to the last known position if it was set during the rendering closure
|
||
/// - return a [`CompletedFrame`] with the current buffer and the area of the terminal
|
||
///
|
||
/// The [`CompletedFrame`] returned by this method can be useful for debugging or testing
|
||
/// purposes, but it is often not used in regular applicationss.
|
||
///
|
||
/// 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
|
||
///
|
||
/// ```
|
||
/// # let backend = ratatui::backend::TestBackend::new(10, 10);
|
||
/// # let mut terminal = ratatui::Terminal::new(backend)?;
|
||
/// use ratatui::{layout::Position, widgets::Paragraph};
|
||
///
|
||
/// // with a closure
|
||
/// terminal.draw(|frame| {
|
||
/// let area = frame.area();
|
||
/// frame.render_widget(Paragraph::new("Hello World!"), area);
|
||
/// frame.set_cursor_position(Position { x: 0, y: 0 });
|
||
/// })?;
|
||
///
|
||
/// // or with a function
|
||
/// terminal.draw(render)?;
|
||
///
|
||
/// fn render(frame: &mut ratatui::Frame) {
|
||
/// frame.render_widget(Paragraph::new("Hello World!"), frame.area());
|
||
/// }
|
||
/// # std::io::Result::Ok(())
|
||
/// ```
|
||
pub fn draw<F>(&mut self, render_callback: F) -> io::Result<CompletedFrame<'_>>
|
||
where
|
||
F: FnOnce(&mut Frame),
|
||
{
|
||
self.try_draw(|frame| {
|
||
render_callback(frame);
|
||
io::Result::Ok(())
|
||
})
|
||
}
|
||
|
||
/// Tries to draw a single frame to the terminal.
|
||
///
|
||
/// Returns [`Result::Ok`] containing a [`CompletedFrame`] if successful, otherwise
|
||
/// [`Result::Err`] containing the [`std::io::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
|
||
///
|
||
/// This method will:
|
||
///
|
||
/// - autoresize the terminal if necessary
|
||
/// - call the render callback, passing it a [`Frame`] reference to render to
|
||
/// - flush the current internal state by copying the current buffer to the backend
|
||
/// - move the cursor to the last known position if it was set during the rendering closure
|
||
/// - return a [`CompletedFrame`] with the current buffer and the area of the terminal
|
||
///
|
||
/// The render callback passed to `try_draw` can return any [`Result`] with an error type that
|
||
/// can be converted into an [`std::io::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` as an
|
||
/// [`std::io::Error`] 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 applicationss.
|
||
///
|
||
/// 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
|
||
///
|
||
/// ```should_panic
|
||
/// # use ratatui::layout::Position;;
|
||
/// # let backend = ratatui::backend::TestBackend::new(10, 10);
|
||
/// # let mut terminal = ratatui::Terminal::new(backend)?;
|
||
/// use std::io;
|
||
///
|
||
/// use ratatui::widgets::Paragraph;
|
||
///
|
||
/// // with a closure
|
||
/// terminal.try_draw(|frame| {
|
||
/// let value: u8 = "not a number".parse().map_err(io::Error::other)?;
|
||
/// let area = frame.area();
|
||
/// frame.render_widget(Paragraph::new("Hello World!"), area);
|
||
/// frame.set_cursor_position(Position { x: 0, y: 0 });
|
||
/// io::Result::Ok(())
|
||
/// })?;
|
||
///
|
||
/// // or with a function
|
||
/// terminal.try_draw(render)?;
|
||
///
|
||
/// fn render(frame: &mut ratatui::Frame) -> io::Result<()> {
|
||
/// let value: u8 = "not a number".parse().map_err(io::Error::other)?;
|
||
/// frame.render_widget(Paragraph::new("Hello World!"), frame.area());
|
||
/// Ok(())
|
||
/// }
|
||
/// # io::Result::Ok(())
|
||
/// ```
|
||
pub fn try_draw<F, E>(&mut self, render_callback: F) -> io::Result<CompletedFrame<'_>>
|
||
where
|
||
F: FnOnce(&mut Frame) -> Result<(), E>,
|
||
E: Into<io::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 = OurFrame::from(frame).cursor_position;
|
||
|
||
// Draw to stdout
|
||
self.flush()?;
|
||
|
||
match cursor_position {
|
||
None => self.hide_cursor()?,
|
||
Some(position) => {
|
||
self.show_cursor()?;
|
||
self.set_cursor_position(position)?;
|
||
}
|
||
}
|
||
|
||
self.swap_buffers();
|
||
|
||
// Flush
|
||
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)
|
||
}
|
||
|
||
/// Hides the cursor.
|
||
pub fn hide_cursor(&mut self) -> io::Result<()> {
|
||
self.backend.hide_cursor()?;
|
||
self.hidden_cursor = true;
|
||
Ok(())
|
||
}
|
||
|
||
/// Shows the cursor.
|
||
pub fn show_cursor(&mut self) -> io::Result<()> {
|
||
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 = "the method get_cursor_position indicates more clearly what about the cursor to get"]
|
||
pub fn get_cursor(&mut self) -> io::Result<(u16, u16)> {
|
||
let Position { x, y } = self.get_cursor_position()?;
|
||
Ok((x, y))
|
||
}
|
||
|
||
/// Sets the cursor position.
|
||
#[deprecated = "the method set_cursor_position indicates more clearly what about the cursor to set"]
|
||
pub fn set_cursor(&mut self, x: u16, y: u16) -> io::Result<()> {
|
||
self.set_cursor_position(Position { x, y })
|
||
}
|
||
|
||
/// Gets the current cursor position.
|
||
///
|
||
/// This is the position of the cursor after the last draw call.
|
||
pub fn get_cursor_position(&mut self) -> io::Result<Position> {
|
||
self.backend.get_cursor_position()
|
||
}
|
||
|
||
/// Sets the cursor position.
|
||
pub fn set_cursor_position<P: Into<Position>>(&mut self, position: P) -> io::Result<()> {
|
||
let position = position.into();
|
||
self.backend.set_cursor_position(position)?;
|
||
self.last_known_cursor_pos = position;
|
||
Ok(())
|
||
}
|
||
|
||
/// Clear the terminal and force a full redraw on the next draw call.
|
||
pub fn clear(&mut self) -> io::Result<()> {
|
||
match self.viewport {
|
||
Viewport::Fullscreen => self.backend.clear_region(ClearType::All)?,
|
||
Viewport::Inline(_) => {
|
||
self.backend
|
||
.set_cursor_position(self.viewport_area.as_position())?;
|
||
self.backend.clear_region(ClearType::AfterCursor)?;
|
||
}
|
||
Viewport::Fixed(_) => {
|
||
let area = self.viewport_area;
|
||
for y in area.top()..area.bottom() {
|
||
self.backend.set_cursor_position(Position { x: 0, y })?;
|
||
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();
|
||
self.reset_back_links();
|
||
Ok(())
|
||
}
|
||
|
||
/// Reset the inactive (back) hyperlink layer in lockstep with the back cell
|
||
/// buffer, so a stale link can never survive a buffer reset.
|
||
fn reset_back_links(&mut self) {
|
||
for id in self.link_ids[1 - self.current].iter_mut() {
|
||
*id = 0;
|
||
}
|
||
self.link_tables[1 - self.current].clear();
|
||
}
|
||
|
||
/// Resets the back buffer without clearing the screen
|
||
/// This is useful when you want to queue clear commands yourself
|
||
pub fn reset_back_buffer(&mut self) {
|
||
self.buffers[1 - self.current].reset();
|
||
self.reset_back_links();
|
||
}
|
||
|
||
/// Clears the inactive buffer and swaps it with the current buffer
|
||
pub fn swap_buffers(&mut self) {
|
||
self.buffers[1 - self.current].reset();
|
||
self.reset_back_links();
|
||
self.current = 1 - self.current;
|
||
}
|
||
|
||
/// Queries the real size of the backend.
|
||
pub fn size(&self) -> io::Result<Size> {
|
||
self.backend.size()
|
||
}
|
||
|
||
/// Insert some content before the current inline viewport. This has no effect when the
|
||
/// viewport is not inline.
|
||
///
|
||
/// 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.
|
||
///
|
||
/// 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:
|
||
/// ```ignore
|
||
/// +---------------------+
|
||
/// | pre-existing line 1 |
|
||
/// | pre-existing line 2 |
|
||
/// +---------------------+
|
||
/// | viewport |
|
||
/// +---------------------+
|
||
/// | |
|
||
/// | |
|
||
/// +---------------------+
|
||
/// ```
|
||
///
|
||
/// After inserting 2 lines:
|
||
/// ```ignore
|
||
/// +---------------------+
|
||
/// | pre-existing line 1 |
|
||
/// | pre-existing line 2 |
|
||
/// | inserted line 1 |
|
||
/// | inserted line 2 |
|
||
/// +---------------------+
|
||
/// | viewport |
|
||
/// +---------------------+
|
||
/// +---------------------+
|
||
/// ```
|
||
///
|
||
/// After inserting 2 more lines:
|
||
/// ```ignore
|
||
/// +---------------------+
|
||
/// | pre-existing line 2 |
|
||
/// | inserted line 1 |
|
||
/// | inserted line 2 |
|
||
/// | inserted line 3 |
|
||
/// | inserted line 4 |
|
||
/// +---------------------+
|
||
/// | viewport |
|
||
/// +---------------------+
|
||
/// ```
|
||
///
|
||
/// If more lines are inserted than there is space on the screen, then the top lines will go
|
||
/// directly into the terminal's scrollback buffer. At the limit, if the viewport takes up the
|
||
/// whole screen, all lines will be inserted directly into the scrollback buffer.
|
||
///
|
||
/// # Examples
|
||
///
|
||
/// ## Insert a single line before the current viewport
|
||
///
|
||
/// ```rust
|
||
/// use ratatui::{
|
||
/// backend::TestBackend,
|
||
/// style::{Color, Style},
|
||
/// text::{Line, Span},
|
||
/// widgets::{Paragraph, Widget},
|
||
/// Terminal,
|
||
/// };
|
||
/// # let backend = TestBackend::new(10, 10);
|
||
/// # let mut terminal = Terminal::new(backend).unwrap();
|
||
/// terminal.insert_before(1, |buf| {
|
||
/// Paragraph::new(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);
|
||
/// });
|
||
/// ```
|
||
pub fn insert_before<F>(&mut self, height: u16, draw_fn: F) -> io::Result<()>
|
||
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(()),
|
||
}
|
||
}
|
||
|
||
/// Sets the height of an inline viewport and resizes it accordingly.
|
||
///
|
||
/// This method only works with inline viewports. For other viewport types, it has no effect.
|
||
/// The viewport will be resized to the new height, and the buffers will be cleared and
|
||
/// reallocated to match the new size.
|
||
///
|
||
/// # Arguments
|
||
///
|
||
/// * `new_height` - The new height for the inline viewport in lines
|
||
///
|
||
/// # Examples
|
||
///
|
||
/// ```rust,ignore
|
||
/// use ratatui::{Terminal, TerminalOptions, Viewport};
|
||
///
|
||
/// let mut terminal = Terminal::with_options(backend, TerminalOptions {
|
||
/// viewport: Viewport::Inline(8),
|
||
/// })?;
|
||
///
|
||
/// // Later, resize the viewport to 12 lines
|
||
/// terminal.set_viewport_height(12)?;
|
||
/// ```
|
||
pub fn set_viewport_height(&mut self, new_height: u16) -> io::Result<()> {
|
||
if !matches!(self.viewport, Viewport::Inline(_)) {
|
||
return Ok(());
|
||
}
|
||
// Judge grow-vs-shrink against the ACTUAL current viewport height, not
|
||
// the stored `Viewport::Inline(height)`. The two can drift when a caller
|
||
// repositions or resizes the inline viewport out-of-band via
|
||
// `set_viewport_area` (minimal mode's content-anchored commit path
|
||
// shrinks the viewport that way before `insert_before`). Comparing
|
||
// against a stale stored height made a genuine grow read as a shrink,
|
||
// skipping the grow-time `scroll_up` below — so the viewport's top never
|
||
// moved up and the taller region ran off the bottom of the screen (an
|
||
// opened dropdown's items landed off-screen). Keep the stored height in
|
||
// lockstep with the area height on the way out so `resize`
|
||
// (`compute_inline_size`) also sees the real height.
|
||
let old_height = self.viewport_area.height;
|
||
if let Viewport::Inline(height) = &mut self.viewport {
|
||
*height = new_height;
|
||
}
|
||
if old_height == new_height {
|
||
return Ok(());
|
||
}
|
||
|
||
self.clear()?;
|
||
|
||
let new_y = match new_height.cmp(&old_height) {
|
||
std::cmp::Ordering::Greater => {
|
||
let overflow =
|
||
(self.viewport_area.y + new_height).saturating_sub(self.last_known_area.height);
|
||
if overflow > 0 {
|
||
// Scroll the rows the taller viewport will cover up into the
|
||
// terminal's native scrollback *before* moving the viewport
|
||
// origin up, so committed content is preserved instead of
|
||
// overwritten. Minimal mode (and any inline consumer) relies
|
||
// on this when growing the viewport for an overlay near the
|
||
// bottom of the screen. The pager builds without the
|
||
// `scrolling-regions` feature; that variant is a separate
|
||
// (unused-by-the-pager) path left as a TODO.
|
||
#[cfg(not(feature = "scrolling-regions"))]
|
||
self.scroll_up(overflow)?;
|
||
self.viewport_area.y.saturating_sub(overflow)
|
||
} else {
|
||
self.viewport_area.y
|
||
}
|
||
}
|
||
_ => self.viewport_area.y,
|
||
};
|
||
|
||
self.set_viewport_area(Rect {
|
||
height: new_height,
|
||
y: new_y,
|
||
..self.viewport_area
|
||
});
|
||
self.clear()
|
||
}
|
||
|
||
/// Implement `Self::insert_before` using standard backend capabilities.
|
||
#[cfg(not(feature = "scrolling-regions"))]
|
||
fn insert_before_no_scrolling_regions(
|
||
&mut self,
|
||
height: u16,
|
||
draw_fn: impl FnOnce(&mut Buffer),
|
||
) -> io::Result<()> {
|
||
// The approach of this function is to first render all of the lines to insert into a
|
||
// temporary buffer, and then to loop drawing chunks from the buffer to the screen. drawing
|
||
// this buffer onto the screen.
|
||
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 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 extra 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),
|
||
) -> io::Result<()> {
|
||
// The approach of this function is to first render all of the lines to insert into a
|
||
// temporary buffer, and then to loop drawing chunks from the buffer to the screen. drawing
|
||
// this buffer onto the screen.
|
||
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.
|
||
fn draw_lines<'a>(
|
||
&mut self,
|
||
y_offset: u16,
|
||
lines_to_draw: u16,
|
||
cells: &'a [Cell],
|
||
) -> io::Result<&'a [Cell]> {
|
||
let width: usize = self.last_known_area.width.into();
|
||
let (to_draw, remainder) = cells.split_at(width * lines_to_draw as usize);
|
||
if lines_to_draw > 0 {
|
||
let iter = to_draw
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(i, c)| ((i % width) as u16, y_offset + (i / width) as u16, c));
|
||
self.backend.draw(iter)?;
|
||
self.backend.flush()?;
|
||
}
|
||
Ok(remainder)
|
||
}
|
||
|
||
/// 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.
|
||
#[cfg(feature = "scrolling-regions")]
|
||
fn draw_lines_over_cleared<'a>(
|
||
&mut self,
|
||
y_offset: u16,
|
||
lines_to_draw: u16,
|
||
cells: &'a [Cell],
|
||
) -> io::Result<&'a [Cell]> {
|
||
let width: usize = self.last_known_area.width.into();
|
||
let (to_draw, remainder) = cells.split_at(width * lines_to_draw as usize);
|
||
if lines_to_draw > 0 {
|
||
let 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.
|
||
#[cfg(not(feature = "scrolling-regions"))]
|
||
fn scroll_up(&mut self, lines_to_scroll: u16) -> io::Result<()> {
|
||
if lines_to_scroll > 0 {
|
||
self.set_cursor_position(Position::new(
|
||
0,
|
||
self.last_known_area.height.saturating_sub(1),
|
||
))?;
|
||
self.backend.append_lines(lines_to_scroll)?;
|
||
}
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
/// Like [`Buffer::diff`] but safe for buffers whose `width * height > u16::MAX`.
|
||
///
|
||
/// Upstream ratatui (0.29) `Buffer::pos_of()` casts the flat index to `u16`
|
||
/// before dividing by width, silently wrapping at 65 535. This replacement
|
||
/// performs the division in `usize` so terminals with >65 535 cells render
|
||
/// correctly.
|
||
fn diff_large<'a>(prev: &Buffer, next: &'a Buffer) -> Vec<(u16, u16, &'a Cell)> {
|
||
let previous_buffer = &prev.content;
|
||
let next_buffer = &next.content;
|
||
|
||
let area = prev.area;
|
||
let width = area.width as usize;
|
||
|
||
let mut updates: Vec<(u16, u16, &Cell)> = Vec::new();
|
||
let mut invalidated: usize = 0;
|
||
let mut to_skip: usize = 0;
|
||
|
||
for (i, (current, previous)) in next_buffer.iter().zip(previous_buffer.iter()).enumerate() {
|
||
if !current.skip && (current != previous || invalidated > 0) && to_skip == 0 {
|
||
// Safe coordinate conversion: divide in usize, then narrow to u16.
|
||
let x = area.x + (i % width) as u16;
|
||
let y = area.y + (i / width) as u16;
|
||
updates.push((x, y, &next_buffer[i]));
|
||
}
|
||
|
||
to_skip = current.symbol().width().saturating_sub(1);
|
||
|
||
let affected_width = std::cmp::max(current.symbol().width(), previous.symbol().width());
|
||
invalidated = std::cmp::max(affected_width, invalidated).saturating_sub(1);
|
||
}
|
||
updates
|
||
}
|
||
|
||
/// Like [`diff_large`] but a cell is also considered dirty when its hyperlink
|
||
/// differs between the prior and current frame (even if the glyph/style is
|
||
/// identical). This is what makes OSC 8 links participate in the frame diff:
|
||
/// adding, removing, or retargeting a link forces the affected cells to be
|
||
/// rewritten so the terminal's link state stays in sync.
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn diff_large_with_links<'a>(
|
||
prev: &Buffer,
|
||
next: &'a Buffer,
|
||
prev_ids: &[u32],
|
||
next_ids: &[u32],
|
||
prev_table: &[LinkRef],
|
||
next_table: &[LinkRef],
|
||
) -> Vec<(u16, u16, &'a Cell)> {
|
||
let previous_buffer = &prev.content;
|
||
let next_buffer = &next.content;
|
||
|
||
// `prev.area == next.area` always (resized together); match `diff_large`.
|
||
let area = prev.area;
|
||
let width = area.width as usize;
|
||
|
||
let mut updates: Vec<(u16, u16, &Cell)> = Vec::new();
|
||
let mut invalidated: usize = 0;
|
||
let mut to_skip: usize = 0;
|
||
|
||
for (i, (current, previous)) in next_buffer.iter().zip(previous_buffer.iter()).enumerate() {
|
||
let link_changed =
|
||
resolve_link(next_ids, next_table, i) != resolve_link(prev_ids, prev_table, i);
|
||
if !current.skip && (current != previous || link_changed || invalidated > 0) && to_skip == 0
|
||
{
|
||
let x = area.x + (i % width) as u16;
|
||
let y = area.y + (i / width) as u16;
|
||
updates.push((x, y, &next_buffer[i]));
|
||
}
|
||
|
||
to_skip = current.symbol().width().saturating_sub(1);
|
||
|
||
let affected_width = std::cmp::max(current.symbol().width(), previous.symbol().width());
|
||
invalidated = std::cmp::max(affected_width, invalidated).saturating_sub(1);
|
||
}
|
||
updates
|
||
}
|
||
|
||
/// Emit a frame's cell updates with OSC 8 hyperlinks.
|
||
///
|
||
/// Updates are grouped into maximal runs that resolve to the same link, and the
|
||
/// upstream [`Backend::draw`] is reused per run (so all SGR / wide-char / cursor
|
||
/// handling is unchanged); each linked run is wrapped in one OSC 8 open/close.
|
||
/// Keeping a link open across `draw`'s internal cursor moves is correct because
|
||
/// OSC 8 is a sticky terminal mode — only the written cells inherit it, and
|
||
/// unchanged cells in any gap keep whatever link they already had.
|
||
fn emit_frame_with_links<B: Backend + Write>(
|
||
backend: &mut B,
|
||
updates: &[(u16, u16, &Cell)],
|
||
cur_ids: &[u32],
|
||
cur_table: &[LinkRef],
|
||
area: Rect,
|
||
) -> io::Result<()> {
|
||
let width = area.width as usize;
|
||
let resolve = |x: u16, y: u16| -> Option<&LinkRef> {
|
||
let idx = (y - area.y) as usize * width + (x - area.x) as usize;
|
||
resolve_link(cur_ids, cur_table, idx)
|
||
};
|
||
|
||
let mut i = 0;
|
||
while i < updates.len() {
|
||
// Invariant: `i < updates.len()` (loop guard) and below `i < j <=
|
||
// updates.len()`, so `updates[i]` and the slice `updates[i..j]` never
|
||
// panic. `resolve` indexes via `cur_ids.get(..)` (bounds-safe) and the
|
||
// coordinates come from `diff_large_with_links` as `area.{x,y} + ..`, so
|
||
// `(y - area.y)` / `(x - area.x)` cannot underflow.
|
||
let (x, y, _) = updates[i];
|
||
let link = resolve(x, y);
|
||
|
||
// Extend the run while the resolved link is identical.
|
||
let mut j = i + 1;
|
||
while j < updates.len() {
|
||
let (nx, ny, _) = updates[j];
|
||
if resolve(nx, ny) != link {
|
||
break;
|
||
}
|
||
j += 1;
|
||
}
|
||
|
||
if let Some(link) = link {
|
||
write_osc8_open(backend, &link.url, link.id)?;
|
||
// Always close the hyperlink, even if the cell draw errors, so a
|
||
// dangling OSC 8 open can never be flushed to the terminal.
|
||
let drawn = backend.draw(updates[i..j].iter().copied());
|
||
write_osc8_close(backend)?;
|
||
drawn?;
|
||
} else {
|
||
backend.draw(updates[i..j].iter().copied())?;
|
||
}
|
||
|
||
i = j;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn compute_inline_size<B: Backend>(
|
||
backend: &mut B,
|
||
height: u16,
|
||
size: Size,
|
||
offset_in_previous_viewport: u16,
|
||
) -> io::Result<(Rect, Position)> {
|
||
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,
|
||
))
|
||
}
|
||
|
||
impl<B: Backend> Terminal<B> {
|
||
/// HACK: this exists
|
||
pub fn viewport_area(&self) -> Rect {
|
||
self.viewport_area
|
||
}
|
||
|
||
/// The full terminal area as last seen by `autoresize` (i.e. the whole
|
||
/// screen, not just the inline viewport). This is the exact value
|
||
/// `set_viewport_height`'s grow/shrink math uses, so callers that size the
|
||
/// viewport relative to the screen (e.g. the minimal-mode overlay host)
|
||
/// should read it here for consistency.
|
||
pub fn last_known_area(&self) -> Rect {
|
||
self.last_known_area
|
||
}
|
||
|
||
/// HACK: this is made pub
|
||
pub fn set_viewport_area(&mut self, area: Rect) {
|
||
self.buffers[self.current].resize(area);
|
||
self.buffers[1 - self.current].resize(area);
|
||
let len = (area.width as usize) * (area.height as usize);
|
||
for layer in self.link_ids.iter_mut() {
|
||
layer.clear();
|
||
layer.resize(len, 0);
|
||
}
|
||
self.link_tables[0].clear();
|
||
self.link_tables[1].clear();
|
||
self.viewport_area = area;
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod inline_resize_tests {
|
||
use ratatui::backend::TestBackend;
|
||
use ratatui::{TerminalOptions, Viewport, layout::Rect};
|
||
|
||
use super::Terminal;
|
||
|
||
fn full_height_inline(width: u16, height: u16) -> Terminal<TestBackend> {
|
||
Terminal::with_options(
|
||
TestBackend::new(width, height),
|
||
TerminalOptions {
|
||
viewport: Viewport::Inline(height),
|
||
},
|
||
)
|
||
.unwrap()
|
||
}
|
||
|
||
/// A full-height inline viewport (the alt-screen-unavailable case used under
|
||
/// Zellij / tmux control mode / `--no-alt-screen`) must GROW to fill the
|
||
/// terminal when it is enlarged.
|
||
///
|
||
/// Regression test for the bug where the viewport height was clamped to the
|
||
/// startup height (truncated at the bottom) while the width still tracked the
|
||
/// resize.
|
||
#[test]
|
||
fn inline_full_height_grows_with_terminal() {
|
||
let mut terminal = full_height_inline(80, 24);
|
||
assert_eq!(terminal.viewport_area(), Rect::new(0, 0, 80, 24));
|
||
|
||
terminal.backend_mut().resize(80, 40);
|
||
terminal.autoresize().unwrap();
|
||
|
||
// Both dimensions track the new terminal size, anchored at the top.
|
||
assert_eq!(terminal.viewport_area(), Rect::new(0, 0, 80, 40));
|
||
}
|
||
|
||
/// Width-only growth keeps working (this part was never broken).
|
||
#[test]
|
||
fn inline_full_height_grows_in_width() {
|
||
let mut terminal = full_height_inline(80, 24);
|
||
|
||
terminal.backend_mut().resize(120, 24);
|
||
terminal.autoresize().unwrap();
|
||
|
||
assert_eq!(terminal.viewport_area(), Rect::new(0, 0, 120, 24));
|
||
}
|
||
|
||
/// Shrinking must also track the terminal and must not position the viewport
|
||
/// off-screen (which earlier panicked the strict `TestBackend` buffer and
|
||
/// would leave a real terminal's UI invisible/garbled).
|
||
#[test]
|
||
fn inline_full_height_shrinks_with_terminal() {
|
||
let mut terminal = full_height_inline(80, 40);
|
||
|
||
terminal.backend_mut().resize(80, 20);
|
||
terminal.autoresize().unwrap();
|
||
|
||
assert_eq!(terminal.viewport_area(), Rect::new(0, 0, 80, 20));
|
||
}
|
||
|
||
/// Growth after a shrink must expand again — the viewport tracks the live
|
||
/// terminal size in both directions, repeatedly.
|
||
#[test]
|
||
fn inline_full_height_tracks_across_shrink_then_grow() {
|
||
let mut terminal = full_height_inline(80, 30);
|
||
|
||
terminal.backend_mut().resize(80, 10);
|
||
terminal.autoresize().unwrap();
|
||
assert_eq!(terminal.viewport_area(), Rect::new(0, 0, 80, 10));
|
||
|
||
terminal.backend_mut().resize(100, 50);
|
||
terminal.autoresize().unwrap();
|
||
assert_eq!(terminal.viewport_area(), Rect::new(0, 0, 100, 50));
|
||
}
|
||
|
||
/// A *small* inline viewport (height < terminal height, anchored near the
|
||
/// bottom) must NOT be forced to full height — it keeps the standard
|
||
/// `compute_inline_size` behavior, so the full-height special-case does not
|
||
/// over-apply.
|
||
#[test]
|
||
fn small_inline_viewport_is_not_forced_full() {
|
||
let backend = TestBackend::new(80, 24);
|
||
let mut terminal = Terminal::with_options(
|
||
backend,
|
||
TerminalOptions {
|
||
viewport: Viewport::Inline(3),
|
||
},
|
||
)
|
||
.unwrap();
|
||
assert_eq!(terminal.viewport_area().height, 3);
|
||
|
||
terminal.backend_mut().resize(120, 40);
|
||
terminal.autoresize().unwrap();
|
||
|
||
// The full-height special-case keys off the viewport spanning the whole
|
||
// terminal (height >= terminal height). A small inline viewport does not,
|
||
// so its height stays clamped to the small inline target while the width
|
||
// tracks the resize — i.e. it keeps the standard `compute_inline_size`
|
||
// behavior and is not ballooned to full height.
|
||
assert_eq!(terminal.viewport_area().height, 3);
|
||
assert_eq!(terminal.viewport_area().width, 120);
|
||
}
|
||
|
||
/// Fullscreen viewports already track the full size; behavior is unchanged.
|
||
#[test]
|
||
fn fullscreen_tracks_terminal() {
|
||
let backend = TestBackend::new(80, 24);
|
||
let mut terminal = Terminal::new(backend).unwrap();
|
||
assert_eq!(terminal.viewport_area(), Rect::new(0, 0, 80, 24));
|
||
|
||
terminal.backend_mut().resize(80, 40);
|
||
terminal.autoresize().unwrap();
|
||
|
||
assert_eq!(terminal.viewport_area(), Rect::new(0, 0, 80, 40));
|
||
}
|
||
}
|