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);