From 29a168f65761262fe2fd4b4bf2b0a2a903427af1 Mon Sep 17 00:00:00 2001 From: Chris Guidry Date: Sat, 1 Aug 2026 08:58:36 -0400 Subject: [PATCH] Give the viewport's rows back below it, not above it A viewport that shrank moved its top down to keep its bottom row on the screen's last row, which left the rows it gave back blank above it, in the region the transcript owns. Every insert above an inline viewport scrolls that whole region, blank rows and all, so each shrink carried a band up through the transcript and into the terminal's scrollback for good: a table that streamed to six rows left six blank rows behind it. A shrink now leaves the viewport's top where it is and ends the viewport short of the last row. ratatui's insert_before draws into the rows below a viewport that no longer reaches the bottom and pushes it back down, so the transcript's own rows are what close the gap and no band is ever made. For that to hold within a pass, the transcript gathers a pass's rows and places them after the resize, and the viewport gives back no more rows than are waiting to fill them. A shrink with nothing to write waits, so the prompt is back on the last row before the pass draws. The test fake for the resize seam only recorded the heights storied asked for, which is why 1038 tests missed this. PinnedViewport does to a TestBackend what CrosstermViewport does to a terminal, sharing viewport.rs's arithmetic, and screen_pinned_tests reads the scrollback and the screen above the viewport together. Task 9 of plan 0008. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012zSZW6bFzUQTWG37wErsH6 --- plans/0008-streaming-markdown.md | 5 + src/play/screen.rs | 45 +++++-- src/play/screen_pinned_tests.rs | 200 +++++++++++++++++++++++++++++ src/play/screen_prompt_tests.rs | 18 ++- src/play/screen_tests.rs | 84 +++++++++++- src/play/terminal.rs | 36 +++--- src/play/transcript.rs | 89 +++++++++---- src/play/transcript_width_tests.rs | 27 ++-- src/play/viewport.rs | 43 +++++-- src/play/viewport_tests.rs | 17 ++- 10 files changed, 480 insertions(+), 84 deletions(-) create mode 100644 src/play/screen_pinned_tests.rs diff --git a/plans/0008-streaming-markdown.md b/plans/0008-streaming-markdown.md index 750e762..1db18a4 100644 --- a/plans/0008-streaming-markdown.md +++ b/plans/0008-streaming-markdown.md @@ -112,6 +112,11 @@ lives in the viewport, where it repaints freely on every delta. 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. + A shrink gives those rows back below the viewport rather than above + it, and the transcript's rows are what push the viewport back down to + the bottom, so the viewport shrinks by no more rows than the pass has + waiting to go out; giving them back above would leave a blank band + that the next insert scrolls into the scrollback for good. ## The seams diff --git a/src/play/screen.rs b/src/play/screen.rs index 0845a84..c220ce6 100644 --- a/src/play/screen.rs +++ b/src/play/screen.rs @@ -97,14 +97,18 @@ struct Screen { /// /// `guard` brackets every render pass in a synchronized update, so the /// terminal paints each pass in one go instead of painting whatever has -/// landed on the wire so far. The opening banner paints alone, before the -/// loop's first pass and nothing else, so there is nothing for it to race -/// and no update to bracket it with. +/// landed on the wire so far. The opening banner queues before the loop +/// and paints with the first pass, inside that pass's update. /// /// `viewport` changes the viewport's height when the prompt needs more or /// fewer rows. Each resize sits inside the same guard as the repaint that /// follows it, because the terminal it hands back has to redraw every row /// it holds. +/// +/// A pass gathers the transcript's rows, fits the viewport around what is +/// left, and only then puts those rows out. A viewport that gave rows +/// back ends short of the last row of the screen until rows land in them, +/// so the rows have to go out after the resize and before the draw. pub fn play>( terminal: &mut Terminal, keys: &mut K, @@ -133,6 +137,7 @@ pub fn play>( // never sees a matching `end` recovers on its own safety timeout. drain(&mut screen, terminal, &worker.events)?; fit(&mut screen, terminal, viewport)?; + screen.transcript.place(terminal)?; terminal.draw(|frame| render(&screen, frame))?; guard.end(); match keys.next_key() { @@ -174,7 +179,8 @@ fn fit>( ) -> Result<(), B::Error> { let size = terminal.size()?; let forming = screen.transcript.forming_rows(size.width).len(); - let wanted = wanted_rows(&screen.input, size, forming); + let floor = floor(screen.rows, screen.transcript.waiting(), size); + let wanted = wanted_rows(&screen.input, size, forming).max(floor); if wanted == screen.rows { return Ok(()); } @@ -182,6 +188,21 @@ fn fit>( viewport.resize(terminal, wanted) } +/// The fewest rows the viewport may hold now, with `rows` of them now and +/// `waiting` rows of the transcript about to go out. +/// +/// A viewport gives its rows back below itself, and the rows the +/// transcript puts out above it are what push it back down to the bottom +/// of the screen. It may give back no more of them than those rows fill, +/// so a shrink with nothing to write waits: the viewport keeps the rows, +/// blank, until rows go out that fill them. The cap comes first, though. +/// A screen with no room for the rows the viewport is holding takes them +/// back whether or not anything fills them, because a viewport past the +/// cap leaves the transcript nothing. +fn floor(rows: u16, waiting: u16, size: Size) -> u16 { + rows.saturating_sub(waiting).min(cap(size)) +} + /// How many rows the viewport should hold on a screen of `size`: the tail /// area's own rows, `forming` of them and never fewer than one, the blank /// row and the rule, and one row for each row the input wraps to, up to @@ -275,9 +296,9 @@ fn down( /// gone, which leaves nothing to do but keep drawing until the player /// quits. /// -/// The empty prompt gives its rows back before the line goes out, so the -/// row the transcript gains is a row the viewport just released, instead -/// of a blank row left under the prompt until the reply starts. +/// The line queues before the viewport fits around the empty prompt, so +/// the rows the prompt gives back are rows the line fills as it goes out, +/// instead of blank rows left above the prompt until the reply starts. /// /// This insert happens while a key is handled, after the loop pass's own /// `end`, so it brackets itself in its own synchronized update rather than @@ -300,10 +321,11 @@ fn submit>( let input = screen.input.take(); let line = format!("{PLAYER_MARKER}{input}"); guard.begin(); - fit(screen, terminal, viewport)?; screen .transcript .insert(terminal, &line, player(), Kind::Player)?; + fit(screen, terminal, viewport)?; + screen.transcript.place(terminal)?; terminal.draw(|frame| render(screen, frame))?; guard.end(); screen.busy = true; @@ -315,7 +337,8 @@ fn submit>( /// Takes in everything the worker has reported since the last pass, then /// moves the narration's finished rows into the transcript. The rows the /// tail area has room for are what the transcript spills against, so this -/// runs before the resize that gives the viewport those rows. +/// runs before the resize that gives the viewport those rows. Nothing +/// reaches the screen here: the rows wait for [`Transcript::place`]. fn drain( screen: &mut Screen, terminal: &mut Terminal, @@ -471,3 +494,7 @@ mod multiline_tests; #[cfg(test)] #[path = "screen_prompt_tests.rs"] mod prompt_tests; + +#[cfg(test)] +#[path = "screen_pinned_tests.rs"] +mod pinned_tests; diff --git a/src/play/screen_pinned_tests.rs b/src/play/screen_pinned_tests.rs new file mode 100644 index 0000000..e1fcfaf --- /dev/null +++ b/src/play/screen_pinned_tests.rs @@ -0,0 +1,200 @@ +//! Tests for the seam between the viewport and the transcript: what the +//! screen holds where the two meet, once the viewport has given rows +//! back. The harness lives in `screen_tests.rs`, and these run through +//! its pinned entry point, which resizes the viewport the way a terminal +//! does instead of only recording what storied asked for. + +use std::cell::RefCell; +use std::convert::Infallible; +use std::rc::Rc; + +use ratatui::backend::{Backend, ClearType, TestBackend}; +use ratatui::layout::Position; +use ratatui::{Terminal, TerminalOptions, Viewport}; + +use crate::play::viewport; + +use super::sync_tests::Recorded; +use super::tests::{CRAMPED, ROOMY, Step, TALL_BLOCK, TALL_ROWS, done, play_pinned, press, typing}; +use super::*; + +/// A `ViewportRows` that does to a `TestBackend` what `CrosstermViewport` +/// does to a real terminal. +/// +/// Every step here stands for one of the real one's writes. Scrolling the +/// whole screen up stands for `ScrollUp`, and puts the rows it takes off +/// the top into the scrollback the way a terminal does. The cursor move +/// and the clear after it stand for `MoveTo` and `Clear(FromCursorDown)`. +/// The new inline terminal stands for the one the real resize builds over +/// stdout, and the backend it takes is a clone of this one, so the new +/// terminal opens on the screen and the scrollback the old one left, the +/// way stdout leaves them for the real one. +/// +/// The row arithmetic is [`crate::play::viewport`]'s, the same functions +/// the real one calls, so a screen this leaves behind is the screen a +/// terminal is left holding. Recording the heights and the resizes the +/// way `RecordingViewport` does lets the harness report both the same +/// way, whichever fake a test runs on. +pub(super) struct PinnedViewport { + pub(super) rows: Rc>>, + pub(super) timeline: Rc>>, +} + +impl ViewportRows for PinnedViewport { + fn resize( + &mut self, + terminal: &mut Terminal, + rows: u16, + ) -> Result<(), Infallible> { + self.rows.borrow_mut().push(rows); + self.timeline.borrow_mut().push(Recorded::Resize); + let old_top = terminal.get_frame().area().y; + let height = terminal.size()?.height; + let target = viewport::next_top(height, rows, old_top); + let scroll = viewport::scroll_up_needed(old_top, target); + let backend = terminal.backend_mut(); + if scroll > 0 { + backend.scroll_region_up(0..height, scroll)?; + } + backend.set_cursor_position(Position::new(0, target))?; + backend.clear_region(ClearType::AfterCursor)?; + let backend = backend.clone(); + *terminal = Terminal::with_options( + backend, + TerminalOptions { + viewport: Viewport::Inline(rows), + }, + )?; + Ok(()) + } +} + +/// A table that arrives one source row per delta, six rows tall once it +/// is drawn with its borders. +fn table_deltas() -> Vec { + [ + "| Die | Face |\n", + "|-----|------|\n", + "| d4 | 3 |\n", + "| d20 | 17 |\n", + ] + .into_iter() + .map(|row| Step::Turn(TurnEvent::Delta(row.to_string()))) + .collect() +} + +/// The rows the table of [`table_deltas`] draws to. +const TABLE_ROWS: [&str; 6] = [ + "┌─────┬──────┐", + "│ Die │ Face │", + "├─────┼──────┤", + "│ d4 │ 3 │", + "│ d20 │ 17 │", + "└─────┴──────┘", +]; + +#[test] +fn a_finished_table_leaves_no_blank_rows_behind_it() { + let mut steps = table_deltas(); + steps.push(done()); + + let mut played = play_pinned(ROOMY, steps); + + assert_eq!( + played.printed(), + [ + "a banner", + "", + TABLE_ROWS[0], + TABLE_ROWS[1], + TABLE_ROWS[2], + TABLE_ROWS[3], + TABLE_ROWS[4], + TABLE_ROWS[5] + ] + ); +} + +#[test] +fn the_prompt_sits_on_the_last_row_after_a_table_finishes() { + let mut steps = table_deltas(); + steps.push(done()); + + let mut played = play_pinned(ROOMY, steps); + + assert_eq!(played.viewport_bottom(), ROOMY); +} + +#[test] +fn a_table_that_finishes_while_the_player_types_leaves_no_blank_rows_behind_it() { + let mut steps = table_deltas(); + steps.push(done()); + steps.extend(typing("hi")); + steps.push(press(Key::Enter)); + + let mut played = play_pinned(ROOMY, steps); + + assert_eq!( + played.printed(), + [ + "a banner", + "", + TABLE_ROWS[0], + TABLE_ROWS[1], + TABLE_ROWS[2], + TABLE_ROWS[3], + TABLE_ROWS[4], + TABLE_ROWS[5], + "", + "> hi", + ] + ); +} + +#[test] +fn a_block_that_spilled_while_it_streamed_leaves_no_blank_rows_behind_it() { + let steps = vec![Step::Turn(TurnEvent::Delta(TALL_BLOCK.to_string())), done()]; + + // A screen this short leaves the transcript five rows, so the top of + // the block reaches the scrollback while the rest of it is still + // forming, and the viewport gives rows back on a screen that is + // already scrolling. + let mut played = play_pinned(CRAMPED, steps); + + assert_eq!( + played.printed(), + [ + "a banner", + "", + TALL_ROWS[0], + TALL_ROWS[1], + TALL_ROWS[2], + TALL_ROWS[3] + ] + ); + assert_eq!(played.viewport_bottom(), CRAMPED); +} + +#[test] +fn the_transcript_reaches_the_viewport_while_a_block_is_still_forming() { + let steps = vec![Step::Turn(TurnEvent::Delta(TALL_BLOCK.to_string()))]; + + let mut played = play_pinned(CRAMPED, steps); + + assert_eq!( + played.printed(), + ["a banner", "", TALL_ROWS[0], TALL_ROWS[1]] + ); +} + +#[test] +fn the_prompt_stays_on_the_last_row_when_the_input_gives_a_row_back() { + let mut steps = typing(&"ab".repeat(20)); + steps.push(press(Key::Backspace)); + steps.push(press(Key::Backspace)); + + let mut played = play_pinned(ROOMY, steps); + + assert_eq!(played.viewport_bottom(), ROOMY); + assert_eq!(played.printed(), ["a banner"]); +} diff --git a/src/play/screen_prompt_tests.rs b/src/play/screen_prompt_tests.rs index 1837a7d..626aefb 100644 --- a/src/play/screen_prompt_tests.rs +++ b/src/play/screen_prompt_tests.rs @@ -78,13 +78,29 @@ fn an_input_that_wraps_to_two_rows_asks_for_one_taller_viewport() { } #[test] -fn deleting_back_to_one_row_asks_for_the_shorter_viewport() { +fn deleting_back_to_one_row_keeps_the_taller_viewport_until_a_row_goes_out() { let mut steps = typing(&"ab".repeat(20)); steps.push(press(Key::Backspace)); steps.push(press(Key::Backspace)); let played = play_script_on(10, steps); + // The row the prompt no longer needs goes back below the viewport, + // where a row of the transcript has to land to push the prompt down + // to the bottom of the screen again. Nothing is waiting to go out + // here, so the viewport holds the row, blank, instead. + assert_eq!(played.requested(), vec![5]); +} + +#[test] +fn the_line_the_player_submits_takes_back_the_row_the_prompt_gave_up() { + let mut steps = typing(&"ab".repeat(20)); + steps.push(press(Key::Backspace)); + steps.push(press(Key::Backspace)); + steps.push(press(Key::Enter)); + + let played = play_script_on(10, steps); + assert_eq!(played.requested(), vec![5, 4]); } diff --git a/src/play/screen_tests.rs b/src/play/screen_tests.rs index 59cbd2b..1e94a9f 100644 --- a/src/play/screen_tests.rs +++ b/src/play/screen_tests.rs @@ -20,6 +20,7 @@ use ratatui::layout::Position; use ratatui::style::Modifier; use ratatui::{Terminal, TerminalOptions, Viewport}; +use super::pinned_tests::PinnedViewport; use super::prompt_tests::RecordingViewport; use super::sync_tests::{Recorded, RecordingGuard}; use super::*; @@ -118,6 +119,31 @@ impl Played { buffer_text(self.terminal.backend().buffer()) } + /// Everything the game has put above the viewport, oldest first: the + /// rows that scrolled into the terminal's own scrollback, then the + /// rows still on the screen above the viewport. + /// + /// The screen starts blank above the transcript, so the blank rows + /// ahead of the first row of it are left out. A blank row anywhere + /// after that is a row the transcript put there, and shows up here. + pub(in crate::play) fn printed(&mut self) -> Vec { + let top = self.terminal.get_frame().area().y as usize; + let scrollback = buffer_text(self.terminal.backend().scrollback()); + let screen = buffer_text(self.terminal.backend().buffer()); + scrollback + .lines() + .chain(screen.lines().take(top)) + .skip_while(|row| row.is_empty()) + .map(str::to_string) + .collect() + } + + /// The screen row the viewport ends on, which is the screen's own + /// height while the prompt stays pinned to the bottom. + pub(in crate::play) fn viewport_bottom(&mut self) -> u16 { + self.terminal.get_frame().area().bottom() + } + /// The tail row at the top of the viewport, where the reply's next /// row forms. pub(in crate::play) fn tail(&self) -> String { @@ -215,20 +241,64 @@ pub(in crate::play) fn play_script(steps: Vec) -> Played { pub(in crate::play) fn play_script_on(height: u16, steps: Vec) -> Played { let mut backend = TestBackend::new(40, height); backend.set_cursor_position(Position::new(0, 0)).unwrap(); - let mut terminal = Terminal::with_options( + let terminal = Terminal::with_options( backend, TerminalOptions { viewport: Viewport::Inline(height), }, ) .unwrap(); - let (input_sender, inputs) = mpsc::channel(); - let (turn_sender, turns) = mpsc::channel(); let mut guard = RecordingGuard::default(); let mut viewport = RecordingViewport { rows: Rc::default(), timeline: Rc::clone(&guard.timeline), }; + let requested = Rc::clone(&viewport.rows); + run_script(terminal, &mut viewport, &mut guard, requested, steps) +} + +/// Runs the loop over `steps` on a terminal 40 columns wide and `height` +/// rows tall, with the viewport pinned to the bottom of it and resized +/// the way a terminal resizes it. +/// +/// The screen opens the way `terminal::run` leaves it: blank, with the +/// cursor on the row a viewport of [`VIEWPORT_HEIGHT`] rows starts at. A +/// screen taller than the viewport is what gives the transcript rows of +/// its own to hold, so this is the harness for what the two leave on the +/// screen between them. +pub(in crate::play) fn play_pinned(height: u16, steps: Vec) -> Played { + let mut backend = TestBackend::new(40, height); + let top = crate::play::viewport::pinned_top(height, VIEWPORT_HEIGHT); + backend.set_cursor_position(Position::new(0, top)).unwrap(); + let terminal = Terminal::with_options( + backend, + TerminalOptions { + viewport: Viewport::Inline(VIEWPORT_HEIGHT), + }, + ) + .unwrap(); + let mut guard = RecordingGuard::default(); + let mut viewport = PinnedViewport { + rows: Rc::default(), + timeline: Rc::clone(&guard.timeline), + }; + let requested = Rc::clone(&viewport.rows); + run_script(terminal, &mut viewport, &mut guard, requested, steps) +} + +/// Plays `steps` through `terminal`, one step per pass of the render +/// loop, and gives back everything the run left behind. The two entry +/// points above differ only in the terminal they open and the +/// `ViewportRows` they hand it. +fn run_script>( + mut terminal: Terminal, + viewport: &mut V, + guard: &mut RecordingGuard, + requested: Rc>>, + steps: Vec, +) -> Played { + let (input_sender, inputs) = mpsc::channel(); + let (turn_sender, turns) = mpsc::channel(); let mut keys = Script { steps: steps.into(), turns: Some(turn_sender), @@ -248,8 +318,8 @@ pub(in crate::play) fn play_script_on(height: u16, steps: Vec) -> Played { &worker, "a banner", &mut history, - &mut guard, - &mut viewport, + guard, + viewport, ) .unwrap(); @@ -258,8 +328,8 @@ pub(in crate::play) fn play_script_on(height: u16, steps: Vec) -> Played { inputs, cancel, _worker: worker, - guard_timeline: guard.timeline, - requested: viewport.rows, + guard_timeline: Rc::clone(&guard.timeline), + requested, } } diff --git a/src/play/terminal.rs b/src/play/terminal.rs index 7d4b6eb..f92df48 100644 --- a/src/play/terminal.rs +++ b/src/play/terminal.rs @@ -160,25 +160,25 @@ struct CrosstermViewport; impl ViewportRows> for CrosstermViewport { /// 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 + /// top, clears from the new viewport's top down, 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. + /// ends, since the transcript fills the screen down to that row. + /// 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 leaves the top where it is and the + /// new viewport ends short of the last row, so the clear takes the + /// rows it gave back off the screen and the next rows the transcript + /// puts out push it back down to the bottom. /// /// `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 + /// that row leaves at least 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 @@ -200,19 +200,13 @@ impl ViewportRows> for CrosstermViewport { ) -> io::Result<()> { let old_top = terminal.get_frame().area().y; let screen_height = terminal.size()?.height; - let target = viewport::pinned_top(screen_height, rows); + let target = viewport::next_top(screen_height, rows, old_top); 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!( - stdout, - MoveTo(0, clear_from), - Clear(ClearType::FromCursorDown), - MoveTo(0, target) - )?; + execute!(stdout, MoveTo(0, target), Clear(ClearType::FromCursorDown))?; *terminal = Terminal::with_options( CrosstermBackend::new(io::stdout()), TerminalOptions { diff --git a/src/play/transcript.rs b/src/play/transcript.rs index 1eaa68a..a6bc75a 100644 --- a/src/play/transcript.rs +++ b/src/play/transcript.rs @@ -3,16 +3,24 @@ //! //! The DM's narration is markdown, and it reaches the transcript a block //! at a time as it streams. [`MarkdownStream`] holds back the block still -//! being written and hands out the rows of every block that is final, and -//! those rows go straight out. The terminal does all the scrolling, and -//! the viewport holds no more than the block still forming. +//! being written and hands out the rows of every block that is final. The +//! terminal does all the scrolling, and the viewport holds no more than +//! the block still forming. //! //! The player's lines, storied's own asides, and a tool's line are not //! markdown. Each is one block of plain text that wraps through //! [`crate::wrap`] and goes out whole. +//! +//! Rows wait here until [`Transcript::place`] puts them out, which the +//! play loop calls once a pass, after the viewport has taken the rows it +//! needs. A viewport that shrinks gives its rows back below itself, and +//! the rows going out above it are what push it down to the bottom of the +//! screen again, so the viewport has to know how many rows are waiting +//! before it gives any back. use ratatui::Terminal; use ratatui::backend::Backend; +use ratatui::buffer::Buffer; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Text}; @@ -55,10 +63,19 @@ pub enum Kind { Aside, } -/// The rows already in the terminal's scrollback, the block of the reply -/// still forming, and what the next row needs to space itself correctly. +/// One row on its way out, and whether a blank row goes ahead of it. +struct Queued { + blank: u16, + row: Line<'static>, +} + +/// The rows already in the terminal's scrollback, the rows waiting to go +/// there, the block of the reply still forming, and what the next row +/// needs to space itself correctly. #[derive(Default)] pub struct Transcript { + /// The rows this pass has gathered, in the order they go out. + queued: Vec, /// The narration of the round in progress. Every pass takes the rows /// of the blocks it has finished and leaves the block still being /// written. @@ -121,6 +138,31 @@ impl Transcript { self.said } + /// How many rows wait to go out, the blank rows between blocks + /// counted. The viewport gives back no more rows than these can fill. + pub fn waiting(&self) -> u16 { + self.queued.iter().map(|queued| queued.blank + 1).sum() + } + + /// Puts every waiting row above the viewport, in order, into the + /// terminal's own scrollback. + /// + /// This is the only call that writes to the screen outside a draw, so + /// the play loop can hold it until the viewport has settled. A + /// viewport that just gave rows back ends short of the last row of the + /// screen, and these rows land in the rows it gave back and push it + /// down to the bottom again. + pub fn place(&mut self, terminal: &mut Terminal) -> Result<(), B::Error> { + let width = terminal.size()?.width; + for Queued { blank, row } in std::mem::take(&mut self.queued) { + let draw = |buffer: &mut Buffer| { + buffer.set_line(0, blank, &row, width); + }; + terminal.insert_before(blank + 1, draw)?; + } + Ok(()) + } + /// Starts the next turn, which has said nothing yet. pub fn next_turn(&mut self) { self.said = false; @@ -138,7 +180,7 @@ impl Transcript { /// spilled early keeps a marker one column too narrow. pub fn stream( &mut self, - terminal: &mut Terminal, + terminal: &Terminal, tail_rows: u16, ) -> Result<(), B::Error> { let width = self.round_width(terminal)?; @@ -165,7 +207,7 @@ impl Transcript { /// /// The width goes back with the stream, so the next round takes the /// terminal's width as it stands then. - pub fn flush(&mut self, terminal: &mut Terminal) -> Result<(), B::Error> { + pub fn flush(&mut self, terminal: &Terminal) -> Result<(), B::Error> { let width = self.round_width(terminal)?; for row in self.narration.finish(width) { self.narrate(terminal, row)?; @@ -205,15 +247,16 @@ impl Transcript { /// [`Buffer::set_line`]: ratatui::buffer::Buffer::set_line fn narrate( &mut self, - terminal: &mut Terminal, + terminal: &Terminal, row: Line<'static>, ) -> Result<(), B::Error> { let width = terminal.size()?.width; if self.width == Some(width) || row.width() <= width as usize { - return self.emit(terminal, row, Kind::Narration); + self.emit(row, Kind::Narration); + return Ok(()); } for row in wrap_spans(Text::from(row), width as usize) { - self.emit(terminal, row, Kind::Narration)?; + self.emit(row, Kind::Narration); } Ok(()) } @@ -240,14 +283,14 @@ impl Transcript { /// and tool lines do not. pub fn insert( &mut self, - terminal: &mut Terminal, + terminal: &Terminal, text: &str, style: Style, kind: Kind, ) -> Result<(), B::Error> { let width = terminal.size()?.width as usize; for row in wrap(text, width) { - self.emit(terminal, Line::styled(row, style), kind)?; + self.emit(Line::styled(row, style), kind); } self.pending_blank = true; Ok(()) @@ -258,43 +301,35 @@ impl Transcript { /// own styling; this adds none of its own. pub fn insert_text( &mut self, - terminal: &mut Terminal, + terminal: &Terminal, text: Text<'static>, ) -> Result<(), B::Error> { let width = terminal.size()?.width as usize; for row in wrap_spans(text, width) { - self.emit(terminal, row, Kind::Tool)?; + self.emit(row, Kind::Tool); } Ok(()) } - /// Puts one row above the viewport, into the terminal's own - /// scrollback. + /// Queues one row for the transcript, to go out with the rest of the + /// pass's rows. /// /// An empty row is a paragraph break. It does not go out on its own: /// it only marks that one blank row belongs ahead of the next row /// that has something on it. A change of kind marks the same thing, /// which is what separates the blocks of the transcript from each /// other. - fn emit( - &mut self, - terminal: &mut Terminal, - row: Line<'static>, - kind: Kind, - ) -> Result<(), B::Error> { + fn emit(&mut self, row: Line<'static>, kind: Kind) { if row.width() == 0 { self.pending_blank = true; - return Ok(()); + return; } self.pending_blank |= self.kind != kind; self.kind = kind; self.said |= matches!(kind, Kind::Narration | Kind::Tool); let blank = u16::from(self.pending_blank); self.pending_blank = false; - let width = terminal.size()?.width; - terminal.insert_before(blank + 1, |buffer| { - buffer.set_line(0, blank, &row, width); - }) + self.queued.push(Queued { blank, row }); } } diff --git a/src/play/transcript_width_tests.rs b/src/play/transcript_width_tests.rs index 822ae39..5d6cf8c 100644 --- a/src/play/transcript_width_tests.rs +++ b/src/play/transcript_width_tests.rs @@ -33,6 +33,10 @@ const HEIGHT: u16 = 4; /// A terminal `width` columns wide. /// +/// A round gathers its rows against one of these and places them on it, +/// so the rows of a round that spans a resize land on the terminal that +/// was there when they were gathered. +/// /// A resize is a second terminal here, not `TestBackend::resize`. That /// call re-slices the scrollback to the new width, which garbles every /// row already in it and leaves nothing to assert against. The width the @@ -67,10 +71,12 @@ fn a_round_that_widens_mid_stream_still_says_what_it_has_left() { let mut transcript = Transcript::default(); transcript.delta(&format!("{PARAGRAPH}\n\n{AFTER}")); - transcript.stream(&mut narrow, DEEP_TAIL).unwrap(); + transcript.stream(&narrow, DEEP_TAIL).unwrap(); + transcript.place(&mut narrow).unwrap(); transcript.delta(" fifteen"); - transcript.stream(&mut wide, DEEP_TAIL).unwrap(); - transcript.flush(&mut wide).unwrap(); + transcript.stream(&wide, DEEP_TAIL).unwrap(); + transcript.flush(&wide).unwrap(); + transcript.place(&mut wide).unwrap(); assert_eq!( scrollback(&narrow), @@ -92,8 +98,10 @@ fn a_round_that_narrows_mid_stream_says_nothing_a_second_time() { let mut transcript = Transcript::default(); transcript.delta(&format!("{PARAGRAPH}\n\n{AFTER}")); - transcript.stream(&mut wide, DEEP_TAIL).unwrap(); - transcript.flush(&mut narrow).unwrap(); + transcript.stream(&wide, DEEP_TAIL).unwrap(); + transcript.place(&mut wide).unwrap(); + transcript.flush(&narrow).unwrap(); + transcript.place(&mut narrow).unwrap(); assert_eq!(scrollback(&wide), ["", PARAGRAPH]); assert_eq!(scrollback(&narrow), ["", AFTER]); @@ -106,8 +114,10 @@ fn a_row_too_wide_for_the_terminal_it_lands_on_keeps_every_word() { let mut transcript = Transcript::default(); transcript.delta(&format!("{AFTER}\n\n{PARAGRAPH}")); - transcript.stream(&mut wide, DEEP_TAIL).unwrap(); - transcript.flush(&mut narrow).unwrap(); + transcript.stream(&wide, DEEP_TAIL).unwrap(); + transcript.place(&mut wide).unwrap(); + transcript.flush(&narrow).unwrap(); + transcript.place(&mut narrow).unwrap(); // The paragraph rendered as one row at eighty columns. All of it is // here, over the four rows twenty columns takes, and the row that @@ -137,7 +147,8 @@ fn a_table_too_wide_for_the_screen_clips_every_row_at_the_same_column() { let mut transcript = Transcript::default(); transcript.delta(WIDE_TABLE); - transcript.flush(&mut narrow).unwrap(); + transcript.flush(&narrow).unwrap(); + transcript.place(&mut narrow).unwrap(); // One scrollback row per table row, every one of them cut at column // twenty, so the three columns that fit still line up. The content diff --git a/src/play/viewport.rs b/src/play/viewport.rs index 26a0f15..3de8c75 100644 --- a/src/play/viewport.rs +++ b/src/play/viewport.rs @@ -15,6 +15,26 @@ 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. @@ -39,17 +59,20 @@ pub fn scroll_up_needed(current_top: u16, pinned_top: u16) -> u16 { /// viewport's height, scrolling the screen if they run past the last /// row. /// -/// Three rules govern an implementation of this trait, and its caller. +/// Four rules govern an implementation of this trait, and its caller. /// -/// - 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. +/// - 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. +/// - 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 only with rows waiting to go out, and put them out before the +/// next draw, or the prompt sits off the bottom of the screen. /// - 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, diff --git a/src/play/viewport_tests.rs b/src/play/viewport_tests.rs index a40151f..6210fd1 100644 --- a/src/play/viewport_tests.rs +++ b/src/play/viewport_tests.rs @@ -2,7 +2,7 @@ //! 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}; +use super::{next_top, pinned_top, scroll_up_needed}; #[test] fn a_viewport_shorter_than_the_screen_sits_flush_with_the_last_row() { @@ -19,6 +19,21 @@ fn a_viewport_taller_than_the_screen_clamps_to_row_zero() { assert_eq!(pinned_top(10, 24), 0); } +#[test] +fn a_viewport_that_grows_moves_its_top_up_to_the_pinned_row() { + assert_eq!(next_top(24, 6, 20), 18); +} + +#[test] +fn a_viewport_that_shrinks_keeps_its_top_where_it_is() { + assert_eq!(next_top(24, 4, 18), 18); +} + +#[test] +fn a_viewport_that_keeps_its_height_keeps_its_top() { + assert_eq!(next_top(24, 4, 20), 20); +} + #[test] fn room_below_the_current_row_needs_no_scroll() { assert_eq!(scroll_up_needed(5, 20), 0); -- 2.51.2