diff --git a/plans/0008-streaming-markdown.md b/plans/0008-streaming-markdown.md index f2dfcb9..750e762 100644 --- a/plans/0008-streaming-markdown.md +++ b/plans/0008-streaming-markdown.md @@ -106,6 +106,13 @@ lives in the viewport, where it repaints freely on every delta. and worker-death paths finish the stream, so partial markdown still commits styled. +- **The viewport pins to the bottom of the screen.** Sandbox addendum. + From the start of the session, the inline viewport occupies the + screen's bottom rows, so the prompt never floats mid-screen with dead + space under it. Resizes for a multi-line prompt or a tall forming + block grow the viewport upward from that pinned bottom, and a shrink + gives rows back to the transcript above; the bottom row never moves. + ## The seams The contracts the pieces meet at, so the tasks can land independently: @@ -210,6 +217,20 @@ than the viewport's share. Check off phase 6 in `plans/0000-roadmap.md`. This plan moves to `plans/completed/` after the sandbox test drive feels right. +### 8. Pin the prompt to the bottom + +Sandbox addendum, greenlit by Chris after the test drive. Before the +terminal is built, `terminal.rs` moves the cursor to where a +bottom-pinned viewport's top belongs, scrolling existing shell output +up first if it already reaches that far down, so the viewport starts +at the bottom of the screen instead of wherever the shell's cursor +happened to be. `CrosstermViewport::resize` anchors the same way on +every resize: it scrolls the transcript above out of the way for a +grow and clears the rows a shrink gives back, always keeping the +viewport's bottom row on the screen's last row. The row arithmetic is +pure functions in `viewport.rs`, unit tested; the terminal glue that +calls them stays out of coverage, same as the rest of `terminal.rs`. + ## Verification - `cargo test`, coverage at 100%, clippy clean: the pre-commit bar, diff --git a/src/play/terminal.rs b/src/play/terminal.rs index b0a0e81..0f1fab5 100644 --- a/src/play/terminal.rs +++ b/src/play/terminal.rs @@ -11,11 +11,13 @@ use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; -use crossterm::cursor::MoveTo; +use crossterm::cursor::{self, MoveTo}; use crossterm::event; use crossterm::event::{DisableBracketedPaste, EnableBracketedPaste}; use crossterm::execute; -use crossterm::terminal::{BeginSynchronizedUpdate, Clear, ClearType, EndSynchronizedUpdate}; +use crossterm::terminal::{ + BeginSynchronizedUpdate, Clear, ClearType, EndSynchronizedUpdate, ScrollUp, +}; use ratatui::backend::CrosstermBackend; use ratatui::layout::Rect; use ratatui::{Terminal, TerminalOptions, Viewport}; @@ -28,7 +30,7 @@ use super::history::History; use super::keys::{self, Key, Keys}; use super::screen::{self, VIEWPORT_HEIGHT}; use super::sync::SyncGuard; -use super::viewport::ViewportRows; +use super::viewport::{self, ViewportRows}; use super::worker::Worker; /// How long to wait for a key before looking at the worker again. @@ -41,11 +43,15 @@ const POLL_INTERVAL: Duration = Duration::from_millis(50); /// transcript still on the screen. An init that fails part way through has /// already put the terminal in raw mode, so that path restores it too. A /// mount that fails to open, the same as a config that fails to load, -/// ends the game before the terminal changes anything. +/// ends the game before the terminal changes anything. Pinning the +/// viewport to the bottom happens after both succeed and before the +/// terminal enters raw mode, so a failure there ends the game the same +/// way. pub fn run(overrides: &Overrides, layers: &[PathBuf]) -> Result<(), String> { let config = config::load(overrides).map_err(|error| error.to_string())?; let mount = Arc::new(Mount::open(layers)?); let banner = super::banner(&config); + pin_to_bottom(VIEWPORT_HEIGHT).map_err(|error| error.to_string())?; let options = TerminalOptions { viewport: Viewport::Inline(VIEWPORT_HEIGHT), }; @@ -101,6 +107,31 @@ fn park_cursor_below(viewport: Rect) { let _ = stdout.flush(); } +/// Moves the cursor to the row a fresh inline viewport of `viewport_height` +/// rows should treat as its top, so the viewport `try_init_with_options` +/// builds next lands at the bottom of the screen instead of wherever the +/// shell happened to leave the cursor. +/// +/// A fresh inline viewport anchors to whatever row the cursor sits on and +/// only scrolls if its height runs past the screen's last row, so without +/// this a cursor left near the top of an otherwise empty screen would +/// anchor the viewport there too, leaving the rows below it dead for the +/// rest of the session. When the cursor already sits past where the +/// pinned top belongs, this scrolls the screen up first, the same as a +/// program whose output ran past the last row before the next line +/// printed. +fn pin_to_bottom(viewport_height: u16) -> io::Result<()> { + let (_, screen_height) = crossterm::terminal::size()?; + let (_, cursor_row) = cursor::position()?; + let target = viewport::pinned_top(screen_height, viewport_height); + let scroll = viewport::scroll_up_needed(cursor_row, target); + let mut stdout = io::stdout(); + if scroll > 0 { + execute!(stdout, ScrollUp(scroll))?; + } + execute!(stdout, MoveTo(0, target)) +} + /// The player's keyboard, as crossterm reports it. struct CrosstermKeys; @@ -121,25 +152,38 @@ impl Keys for CrosstermKeys { struct CrosstermViewport; impl ViewportRows> for CrosstermViewport { - /// Puts the cursor on the old viewport's top row, clears everything - /// from there down, and builds the new terminal there. + /// Scrolls the screen up if the resize grows past the old viewport's + /// top, clears the rows the new viewport will occupy, puts the + /// cursor where that viewport's top belongs, and builds the new + /// terminal there. + /// + /// The old viewport's top row is where content above it currently + /// ends, since the viewport stays pinned to the bottom of the screen + /// for the whole session. Growing moves the top up past that row, + /// into rows the transcript above still owns, so this scrolls the + /// screen up by the difference first, the same as a program whose + /// output ran past the last row: the transcript's rows go up into + /// scrollback intact rather than getting overwritten. Shrinking + /// moves the top down, into rows the old viewport owned, so no + /// scroll is needed there, only a clear. /// - /// `Terminal::with_options` takes the cursor's row as the top of the - /// new viewport and prints the rows below it, so growth extends - /// downward and the screen scrolls only when the rows run past the - /// last one. The new terminal starts with empty buffers, so the draw - /// that follows repaints every row the old one had drawn; there is no - /// need to clear the new terminal too, and doing so would cost a - /// second cursor-position query for nothing, since `with_options` - /// already spent the one this resize needs. + /// `Terminal::with_options` reads the cursor back from the row this + /// just set and reserves the new viewport's height below it; since + /// that row already leaves exactly enough of the screen for the + /// requested height, it lands the new viewport there without any + /// further scroll. The new terminal starts with empty buffers, so + /// the draw that follows repaints every row the old one had drawn; + /// there is no need to clear the new terminal too, and doing so + /// would cost a second cursor-position query for nothing, since + /// `with_options` already spent the one this resize needs. /// /// Assigning over `terminal` drops the old one. Its `Drop` shows the /// cursor if the terminal hid it, and storied never does, so the old /// terminal goes away without writing anything. /// /// If `Terminal::with_options` fails, for example on a cursor-position - /// query that times out, this returns with the screen already cleared - /// from the old viewport's top row down and nothing drawn in its + /// query that times out, this returns with the screen already + /// scrolled and cleared and nothing drawn in the new viewport's /// place. That is fine: the error ends the session on the caller's /// error path, the same as any other I/O failure here. fn resize( @@ -147,12 +191,20 @@ impl ViewportRows> for CrosstermViewport { terminal: &mut Terminal>, rows: u16, ) -> io::Result<()> { - let top = terminal.get_frame().area().y; + let old_top = terminal.get_frame().area().y; + let screen_height = terminal.size()?.height; + let target = viewport::pinned_top(screen_height, rows); + let scroll = viewport::scroll_up_needed(old_top, target); + let mut stdout = io::stdout(); + if scroll > 0 { + execute!(stdout, ScrollUp(scroll))?; + } + let clear_from = old_top.min(target); execute!( - io::stdout(), - MoveTo(0, top), + stdout, + MoveTo(0, clear_from), Clear(ClearType::FromCursorDown), - MoveTo(0, top) + MoveTo(0, target) )?; *terminal = Terminal::with_options( CrosstermBackend::new(io::stdout()), diff --git a/src/play/viewport.rs b/src/play/viewport.rs index dbd0f16..26a0f15 100644 --- a/src/play/viewport.rs +++ b/src/play/viewport.rs @@ -1,26 +1,55 @@ -//! The seam that changes how many rows the inline viewport holds. +//! 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; -/// Changes the height of the inline viewport at the bottom of the screen. +/// 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) +} + +/// 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) +} + +/// 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 prints the rows below it, which is how the -/// screen scrolls when the viewport grows past the last row. +/// 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. /// /// Three rules govern an implementation of this trait, and its caller. /// -/// - Park the cursor on the old viewport's top row before you build the -/// new terminal, clear from there down, and repaint every row. The top -/// row is the anchor in both directions: growth extends downward, and -/// shrinking gives the rows back below the prompt, where the next -/// `insert_before` walks the viewport back down. Anchoring the bottom -/// edge instead opens a blank gap above the prompt, and that gap -/// scrolls into the scrollback for good. +/// - Park the cursor on [`pinned_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 moves that row down, +/// into rows the old viewport owned; clear those instead of scrolling, +/// since nothing above the old viewport needs to move for the prompt +/// to give rows 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, @@ -35,3 +64,7 @@ pub trait ViewportRows { /// repaint every one of them. fn resize(&mut self, terminal: &mut Terminal, rows: u16) -> Result<(), B::Error>; } + +#[cfg(test)] +#[path = "viewport_tests.rs"] +mod tests; diff --git a/src/play/viewport_tests.rs b/src/play/viewport_tests.rs new file mode 100644 index 0000000..a40151f --- /dev/null +++ b/src/play/viewport_tests.rs @@ -0,0 +1,35 @@ +//! Tests for the row arithmetic that pins the inline viewport to the +//! bottom of the screen: where its top belongs, and how much of the +//! screen above has to scroll to get there without overwriting anything. + +use super::{pinned_top, scroll_up_needed}; + +#[test] +fn a_viewport_shorter_than_the_screen_sits_flush_with_the_last_row() { + assert_eq!(pinned_top(24, 4), 20); +} + +#[test] +fn a_viewport_as_tall_as_the_screen_starts_at_row_zero() { + assert_eq!(pinned_top(24, 24), 0); +} + +#[test] +fn a_viewport_taller_than_the_screen_clamps_to_row_zero() { + assert_eq!(pinned_top(10, 24), 0); +} + +#[test] +fn room_below_the_current_row_needs_no_scroll() { + assert_eq!(scroll_up_needed(5, 20), 0); +} + +#[test] +fn a_pinned_top_exactly_at_the_current_row_needs_no_scroll() { + assert_eq!(scroll_up_needed(10, 10), 0); +} + +#[test] +fn a_pinned_top_above_the_current_row_scrolls_the_difference() { + assert_eq!(scroll_up_needed(15, 5), 10); +}