//! The seam that changes how many rows the inline viewport holds, and the //! row arithmetic that keeps it pinned to the bottom of the screen. use ratatui::Terminal; use ratatui::backend::Backend; /// The row where a bottom-pinned viewport's top belongs, on a screen /// `screen_height` rows tall holding a viewport `viewport_height` rows /// tall. /// /// The viewport's last row always lands on the screen's last row. A /// viewport taller than the screen clamps to row 0 instead of going /// negative. pub fn pinned_top(screen_height: u16, viewport_height: u16) -> u16 { screen_height.saturating_sub(viewport_height) } /// The row the viewport's top belongs on when it changes to /// `viewport_height` rows, on a screen `screen_height` rows tall with its /// top on `current_top` now. /// /// A viewport that grows moves its top up to [`pinned_top`], taking rows /// the transcript above it holds. A viewport that shrinks leaves its top /// where it is and gives its rows back below itself, off the bottom of /// the screen for as long as it takes the transcript to fill them: rows /// inserted above an inline viewport that no longer reaches the last row /// land in the rows it gave back and push it down to the bottom again. /// /// Moving the top down to [`pinned_top`] instead would leave those rows /// blank above the viewport, where no row inserted later can reach them. /// Every insert scrolls the whole region above the viewport, blank rows /// and all, so that band would ride up through the transcript and into /// the scrollback, once for every shrink. pub fn next_top(screen_height: u16, viewport_height: u16, current_top: u16) -> u16 { pinned_top(screen_height, viewport_height).min(current_top) } /// How many rows to scroll the screen up before the viewport's top can /// move from `current_top` to `pinned_top` without writing over content /// that is still there. /// /// `current_top` is the row content already reaches: the shell's cursor /// row at startup, or the old viewport's top row on a resize. Moving the /// top up past that row, which happens when the viewport grows, needs /// the difference scrolled out of the way first. Moving it down, or /// leaving it where it is, needs no scroll. pub fn scroll_up_needed(current_top: u16, pinned_top: u16) -> u16 { current_top.saturating_sub(pinned_top) } /// The row to clear from, down to the last row of the screen, when a /// viewport with its top on `current_top` moves to `pinned_top`. /// /// Everything from the higher of the two rows down either belongs to the /// viewport after the move or belonged to it before. A viewport that /// moves down the screen leaves its old rows behind it, and nothing /// repaints them, so the clear has to start at the row it came from /// rather than the row it lands on. pub fn clear_from(current_top: u16, pinned_top: u16) -> u16 { current_top.min(pinned_top) } /// The writes one change of the viewport's height takes, in the order /// they go out. /// /// An implementation of [`ViewportRows`] does these three writes and then /// builds the new terminal, and nothing else decides how far to scroll, /// where to clear from, or where the new top belongs. The test double and /// the real terminal both take their rows from here, so a screen a test /// leaves behind is the screen a terminal is left holding. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Move { /// How many rows to scroll the screen up first. Zero means the screen /// stays where it is. pub scroll: u16, /// The row to clear from, down to the last row of the screen. pub clear: u16, /// The row the new viewport's top lands on. pub top: u16, /// How many rows the new viewport holds. pub rows: u16, } impl Move { /// The move that gives the viewport `rows` rows on a screen /// `screen_height` rows tall, with its top on `current_top` now. /// /// The top follows [`next_top`], so a viewport that grows takes rows /// from the transcript above and one that shrinks gives its rows back /// below itself. pub fn resize(screen_height: u16, rows: u16, current_top: u16) -> Self { Self::to( next_top(screen_height, rows, current_top), rows, current_top, ) } /// The move that puts a viewport of `rows` rows back on the last row /// of a screen `screen_height` rows tall, with its top on /// `current_top` now. /// /// The top follows [`pinned_top`], so the viewport lands flush with /// the last row whether that moves it up or down. pub fn repin(screen_height: u16, rows: u16, current_top: u16) -> Self { Self::to(pinned_top(screen_height, rows), rows, current_top) } /// The move from `current_top` to `target`, with the scroll and the /// clear that take it there. /// /// The scroll comes first, and it carries the old top up with the /// rest of the screen, so the clear starts from where that row sits /// after the scroll rather than from where it sat before. fn to(target: u16, rows: u16, current_top: u16) -> Self { let scroll = scroll_up_needed(current_top, target); Self { scroll, clear: clear_from(current_top.saturating_sub(scroll), target), top: target, rows, } } } /// Changes the height of the inline viewport pinned to the bottom of the /// screen. /// /// ratatui builds an inline viewport at a fixed height and offers no way /// to change it, so an implementation builds a new `Terminal` over the /// same output and puts it in place of the old one. /// `Terminal::with_options` reads the cursor position, takes that row as /// the new viewport's top, and reserves the rows below it for the /// viewport's height, scrolling the screen if they run past the last /// row. /// /// Four rules govern an implementation of this trait, and its caller. /// /// - Park the cursor on [`next_top`] before you build the new terminal, /// so `Terminal::with_options` lands the new viewport there without any /// further scroll. Growing the viewport moves that row up, into rows /// the transcript above still owns; scroll those up out of the way /// first with [`scroll_up_needed`], the same as a program whose output /// ran past the last row. Shrinking leaves that row where it is and /// ends the viewport short of the last row of the screen, so clear from /// it down to take the rows the viewport gave back off the screen. /// [`Move`] works out all three rows, so an implementation does its /// writes in order and decides none of them itself. /// - A viewport that shrank stops short of the bottom of the screen until /// the rows the transcript puts above it push it back down. Ask for a /// shrink with rows waiting to go out, and put them out before the next /// draw, and the prompt stays on the last row. Ask for one with nothing /// waiting and the prompt sits off the bottom of the screen until rows /// go out that fill the rows the viewport gave back. /// - Every resize costs exactly one cursor-position query, `ESC [ 6 n`, /// and crossterm blocks up to two seconds for the reply. Call this only /// when the number of rows really changes, never on every keystroke, /// and never query the cursor a second time to build the new terminal. /// - A resize writes to the terminal outside of ratatui's own drawing, so /// it must not run inside a draw. Call it between draws, and keep it /// inside the synchronized update that brackets the repaint after it: /// the new terminal starts with empty buffers, so that repaint covers /// the whole viewport. pub trait ViewportRows { /// Makes the viewport `rows` rows tall and leaves `terminal` ready to /// repaint every one of them. fn resize(&mut self, terminal: &mut Terminal, rows: u16) -> Result<(), B::Error>; /// Puts the viewport back on the last row of the screen, `rows` rows /// tall, and leaves `terminal` ready to repaint every one of them. /// /// This is [`ViewportRows::resize`] with [`pinned_top`] in place of /// [`next_top`]: the viewport lands flush with the last row of the /// screen whether that moves it up or down. The rules above hold /// here too, except the second: a viewport this puts back on the last /// row needs no rows from the transcript to push it there, so it is /// safe to call with nothing waiting to go out. /// /// A screen that lost rows has already scrolled its own content up /// by the rows it lost, and this scrolls it again by as much as the /// viewport's top moves. Those rows go into the scrollback, where /// the rest of the transcript is, rather than being overwritten. The /// alternative is a second cursor-position query to find out where /// content really ends now, and it costs up to two seconds. fn repin(&mut self, terminal: &mut Terminal, rows: u16) -> Result<(), B::Error>; } #[cfg(test)] #[path = "viewport_tests.rs"] mod tests;