From 19ab55c2ef7a763c88fbf43881d8a281bd6c157d Mon Sep 17 00:00:00 2001 From: Chris Guidry Date: Sat, 1 Aug 2026 08:06:33 -0400 Subject: [PATCH] Pin the prompt to the bottom of the screen The inline viewport used to anchor wherever the shell's cursor sat at launch and drift down as narration streamed in, so a fresh terminal showed the prompt floating mid-screen with dead space under it. terminal.rs now moves the cursor to the pinned bottom row before the terminal is built, scrolling existing shell output up first if it already reaches that far down, the same as a program whose output ran past the last row. CrosstermViewport::resize anchors the same way on every resize: growing scrolls the transcript above out of the way, shrinking clears the rows it gives back, and the viewport's bottom row never leaves the screen's last row. The row arithmetic lives as pure, unit-tested functions in viewport.rs; terminal.rs stays real-terminal glue, excluded from coverage. Verified against the ratatui 0.30 and crossterm 0.29 sources in ~/.cargo/registry: Viewport::Inline's compute_inline_size reads the cursor row, reserves height-1 more rows below it via append_lines, and only shifts the computed top up when that overruns the screen, which is why parking the cursor at the pinned top beforehand lands the viewport there with zero further scroll. insert_before's own already-at-bottom branch already scrolls the transcript up past a viewport pinned there, so no change was needed to get that half of the pinning for free. Task 8 of plan 0008, a sandbox addendum greenlit by Chris after the test drive. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012zSZW6bFzUQTWG37wErsH6 --- plans/0008-streaming-markdown.md | 21 ++++++++ src/play/terminal.rs | 92 +++++++++++++++++++++++++------- src/play/viewport.rs | 55 +++++++++++++++---- src/play/viewport_tests.rs | 35 ++++++++++++ 4 files changed, 172 insertions(+), 31 deletions(-) create mode 100644 src/play/viewport_tests.rs 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); +} -- 2.51.2