diff --git a/src/play/keys.rs b/src/play/keys.rs index cf1ecfe..ef84495 100644 --- a/src/play/keys.rs +++ b/src/play/keys.rs @@ -56,6 +56,10 @@ pub enum Key { Quit, /// Stop the reply streaming in, if one is running. Cancel, + /// Put the viewport back on the last row of the screen and paint + /// every row of it again. A terminal that changed size means this, + /// and so does Ctrl+L. + Redraw, } /// Where the play loop gets its keys. The game reads them from crossterm. @@ -69,13 +73,15 @@ pub trait Keys { /// /// A paste arrives as one event carrying the whole pasted text, not one /// key press per character; it decodes on its own, outside `decode_key`. -/// Anything else this has no use for, a resize or a key release, returns -/// `None`. The loop redraws on every pass, so a resize needs no key of -/// its own. +/// A resize decodes on its own too, and drops the size it carries: the +/// terminal already reports that size, and what the loop has to do about +/// it is what Ctrl+L does. Anything else this has no use for, a key +/// release, returns `None`. pub fn decode(event: &Event) -> Option { match event { Event::Key(key) if key.kind == KeyEventKind::Press => decode_key(key), Event::Paste(text) => Some(Key::Paste(text.clone())), + Event::Resize(_, _) => Some(Key::Redraw), _ => None, } } @@ -100,6 +106,7 @@ fn decode_key(key: &KeyEvent) -> Option { KeyCode::Char('n') if control => Some(Key::Down), KeyCode::Char('h') if control => Some(Key::Backspace), KeyCode::Char('d') if control => Some(Key::Eof), + KeyCode::Char('l') if control => Some(Key::Redraw), KeyCode::Char(_) if control => None, KeyCode::Char('b') if alt => Some(Key::WordLeft), KeyCode::Char('f') if alt => Some(Key::WordRight), @@ -333,8 +340,13 @@ mod tests { } #[test] - fn a_resize_is_ignored() { - assert_eq!(decode(&Event::Resize(80, 24)), None); + fn a_resize_redraws() { + assert_eq!(decode(&Event::Resize(80, 24)), Some(Key::Redraw)); + } + + #[test] + fn control_l_redraws() { + assert_eq!(decode(&control(KeyCode::Char('l'))), Some(Key::Redraw)); } #[test] diff --git a/src/play/mod.rs b/src/play/mod.rs index 20a9c45..9c0e1e7 100644 --- a/src/play/mod.rs +++ b/src/play/mod.rs @@ -16,6 +16,7 @@ mod editor; mod history; pub mod keys; +pub mod panics; mod prompt; #[cfg(not(coverage))] pub mod sandbox; diff --git a/src/play/panics.rs b/src/play/panics.rs new file mode 100644 index 0000000..b411e1e --- /dev/null +++ b/src/play/panics.rs @@ -0,0 +1,73 @@ +//! What storied does about a panic while the game is running. +//! +//! ratatui installs a panic hook of its own that restores the terminal: +//! it disables raw mode and leaves the alternate screen, whichever thread +//! panicked. That covers neither of the two modes storied turns on for +//! itself, bracketed paste and the synchronized update around a render +//! pass. It is also the wrong thing to do for a panic on the worker +//! thread, which the player sees as a failed turn while the game carries +//! on. The play loop's own hook covers both, and `terminal` installs it +//! over ratatui's. + +use std::io::{self, Write}; + +use crossterm::event::DisableBracketedPaste; +use crossterm::execute; +use crossterm::terminal::EndSynchronizedUpdate; + +use super::worker; + +/// Whether a panic on the thread named `thread` restores the terminal. +/// +/// The worker catches its own panics and reports them as a failed turn, +/// so the game is still running and the terminal still belongs to it. +/// Restoring the terminal there, or printing the panic into the rows +/// under the viewport, would wreck a session that has not ended. +pub fn restores_the_terminal(thread: Option<&str>) -> bool { + thread != Some(worker::THREAD_NAME) +} + +/// Puts back the two terminal modes storied turns on for itself, so a +/// panic leaves a terminal the shell can use. +/// +/// The synchronized update ends first. From `BeginSynchronizedUpdate` on, +/// the terminal shows the last frame it painted and holds every write +/// after it, so the panic message would sit unseen until the terminal's +/// own timeout expired. Bracketed paste goes off next: a shell that +/// still has it on wraps every paste in `ESC [ 200 ~` and `ESC [ 201 ~`, +/// and a shell that does not read those codes prints them. +pub fn leave_modes(out: &mut impl Write) -> io::Result<()> { + execute!(out, EndSynchronizedUpdate, DisableBracketedPaste) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_panic_on_the_main_thread_restores_the_terminal() { + assert!(restores_the_terminal(Some("main"))); + } + + #[test] + fn a_panic_on_a_thread_with_no_name_restores_the_terminal() { + assert!(restores_the_terminal(None)); + } + + #[test] + fn a_panic_on_the_worker_thread_leaves_the_terminal_alone() { + assert!(!restores_the_terminal(Some(worker::THREAD_NAME))); + } + + #[test] + fn leaving_the_modes_ends_the_update_and_then_the_paste_mode() { + let mut written = Vec::new(); + + leave_modes(&mut written).unwrap(); + + assert_eq!( + String::from_utf8(written).unwrap(), + "\x1b[?2026l\x1b[?2004l" + ); + } +} diff --git a/src/play/screen.rs b/src/play/screen.rs index c852436..5f7c3e4 100644 --- a/src/play/screen.rs +++ b/src/play/screen.rs @@ -196,6 +196,7 @@ pub fn play>( Some(Key::Cancel) if screen.busy => worker.cancel.store(true, Ordering::Relaxed), Some(Key::Cancel) => {} Some(Key::Tab) => tab_complete(&mut screen), + Some(Key::Redraw) => redraw(&screen, terminal, guard, viewport)?, } } } @@ -218,6 +219,37 @@ fn fit>( viewport.resize(terminal, wanted) } +/// Puts the viewport back on the last row of the screen and paints every +/// row of it again. +/// +/// A terminal that changes size moves the viewport off the bottom: an +/// inline viewport takes its top from where the cursor sits, and ratatui +/// puts it on row 0 outright when the screen narrows. Nothing later +/// walks it back down, so without this the prompt strands where the +/// resize left it. Ctrl+L comes here too, as the way out of a screen +/// that something else wrote over. +/// +/// The row count stays as it is. `fit` works out on the next pass +/// whether the screen that is there now wants a different one, which is +/// the same path a prompt that grows or shrinks takes. +/// +/// The repin sits inside a synchronized update with the draw after it, +/// the same as `fit`'s resize: the terminal it hands back has empty +/// buffers, so every row of the viewport has to go out again before the +/// terminal paints. +fn redraw>( + screen: &Screen, + terminal: &mut Terminal, + guard: &mut G, + viewport: &mut V, +) -> Result<(), B::Error> { + guard.begin(); + viewport.repin(terminal, screen.rows)?; + terminal.draw(|frame| render(screen, frame))?; + guard.end(); + Ok(()) +} + /// The fewest rows the viewport may hold now, with `rows` of them now and /// `waiting` rows of the transcript about to go out. /// @@ -599,3 +631,7 @@ mod pinned_tests; #[cfg(test)] #[path = "screen_slash_tests.rs"] mod slash_tests; + +#[cfg(test)] +#[path = "screen_resize_tests.rs"] +mod resize_tests; diff --git a/src/play/screen_pinned_tests.rs b/src/play/screen_pinned_tests.rs index e1fcfaf..22f9302 100644 --- a/src/play/screen_pinned_tests.rs +++ b/src/play/screen_pinned_tests.rs @@ -38,6 +38,7 @@ use super::*; pub(super) struct PinnedViewport { pub(super) rows: Rc>>, pub(super) timeline: Rc>>, + pub(super) resized: Resized, } impl ViewportRows for PinnedViewport { @@ -51,22 +52,59 @@ impl ViewportRows for PinnedViewport { 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)?; + move_viewport(terminal, rows, old_top, target) + } + + /// Changes the size of the screen first, when a `Resize` step asked + /// for one. + /// + /// A terminal is already the size it reports by the time its resize + /// event reaches the loop, and the loop reads that size here, so this + /// is where the harness makes the change. Nothing between the key + /// poll and this call looks at the screen. + fn repin(&mut self, terminal: &mut Terminal, rows: u16) -> Result<(), Infallible> { + if let Some((width, height)) = self.resized.borrow_mut().take() { + terminal.backend_mut().resize(width, height); } - 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(()) + 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::pinned_top(height, rows); + move_viewport(terminal, rows, old_top, target) + } +} + +/// The size a `Resize` step asks for, shared with the harness's `Script` +/// keys fake, which is where a test schedules one. +pub(super) type Resized = Rc>>; + +/// Puts a viewport of `rows` rows on `target`, with its old top on +/// `old_top`, the way `terminal::move_viewport` does on a real screen. +fn move_viewport( + terminal: &mut Terminal, + rows: u16, + old_top: u16, + target: u16, +) -> Result<(), Infallible> { + let height = terminal.size()?.height; + let scroll = viewport::scroll_up_needed(old_top, target); + let backend = terminal.backend_mut(); + if scroll > 0 { + backend.scroll_region_up(0..height, scroll)?; } + let clear = viewport::clear_from(old_top.saturating_sub(scroll), target); + backend.set_cursor_position(Position::new(0, clear))?; + backend.clear_region(ClearType::AfterCursor)?; + backend.set_cursor_position(Position::new(0, target))?; + 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 diff --git a/src/play/screen_prompt_tests.rs b/src/play/screen_prompt_tests.rs index 7ee3ecf..dd67b40 100644 --- a/src/play/screen_prompt_tests.rs +++ b/src/play/screen_prompt_tests.rs @@ -34,6 +34,10 @@ impl ViewportRows for RecordingViewport { self.timeline.borrow_mut().push(Recorded::Resize); Ok(()) } + + fn repin(&mut self, terminal: &mut Terminal, rows: u16) -> Result<(), B::Error> { + self.resize(terminal, rows) + } } /// An input of `rows` rows, one letter each, as one paste. diff --git a/src/play/screen_resize_tests.rs b/src/play/screen_resize_tests.rs new file mode 100644 index 0000000..f5031f4 --- /dev/null +++ b/src/play/screen_resize_tests.rs @@ -0,0 +1,97 @@ +//! Tests for the screen changing size under the game, and for the redraw +//! key that asks for the same thing by hand. The harness lives in +//! `screen_tests.rs`, and these run through its pinned entry point, whose +//! `Resize` step changes the size of the screen the way a terminal does. + +use super::sync_tests::brackets_hold; +use super::tests::{ROOMY, Step, play_pinned, play_script_on, press, typing}; +use super::*; + +/// The screen the resize tests narrow to. +const NARROW: u16 = 24; + +/// The screen the resize tests widen to. +const WIDE: u16 = 60; + +/// Types `hi`, then reports a screen `width` columns wide and `height` +/// rows tall. +fn typing_then_resize(width: u16, height: u16) -> Vec { + let mut steps = typing("hi"); + steps.push(Step::Resize(width, height)); + steps +} + +#[test] +fn the_prompt_sits_on_the_last_row_after_the_screen_narrows() { + let mut played = play_pinned(ROOMY, typing_then_resize(NARROW, ROOMY)); + + assert_eq!(played.viewport_bottom(), ROOMY); +} + +#[test] +fn the_whole_viewport_paints_again_at_the_narrower_width() { + let played = play_pinned(ROOMY, typing_then_resize(NARROW, ROOMY)); + + let (rule, dim) = played.row(ROOMY - 2); + assert_eq!(rule, "─".repeat(NARROW as usize)); + assert!(dim); + assert_eq!(played.prompt(), "> hi"); +} + +#[test] +fn the_prompt_sits_on_the_last_row_after_the_screen_widens() { + let mut played = play_pinned(ROOMY, typing_then_resize(WIDE, ROOMY)); + + assert_eq!(played.viewport_bottom(), ROOMY); +} + +#[test] +fn the_whole_viewport_paints_again_at_the_wider_width() { + let played = play_pinned(ROOMY, typing_then_resize(WIDE, ROOMY)); + + let (rule, dim) = played.row(ROOMY - 2); + assert_eq!(rule, "─".repeat(WIDE as usize)); + assert!(dim); + assert_eq!(played.prompt(), "> hi"); +} + +#[test] +fn the_prompt_follows_the_last_row_when_the_screen_loses_rows() { + let mut played = play_pinned(ROOMY, typing_then_resize(40, 12)); + + assert_eq!(played.viewport_bottom(), 12); + assert_eq!(played.prompt(), "> hi"); +} + +#[test] +fn the_prompt_follows_the_last_row_when_the_screen_gains_rows() { + let mut played = play_pinned(ROOMY, typing_then_resize(40, 30)); + + assert_eq!(played.viewport_bottom(), 30); + assert_eq!(played.prompt(), "> hi"); +} + +#[test] +fn the_redraw_key_puts_the_prompt_back_on_the_last_row() { + let mut steps = typing("hi"); + steps.push(press(Key::Redraw)); + + let mut played = play_pinned(ROOMY, steps); + + assert_eq!(played.viewport_bottom(), ROOMY); + assert_eq!(played.prompt(), "> hi"); +} + +#[test] +fn a_resize_paints_inside_a_synchronized_update_of_its_own() { + let played = play_pinned(ROOMY, typing_then_resize(NARROW, ROOMY)); + + assert!(brackets_hold(&played.guard_timeline())); +} + +#[test] +fn the_redraw_key_pins_the_viewport_at_the_height_it_already_has() { + let played = play_script_on(12, vec![press(Key::Redraw)]); + + assert_eq!(played.requested(), vec![VIEWPORT_HEIGHT]); +} diff --git a/src/play/screen_tests.rs b/src/play/screen_tests.rs index ff80bb5..44c8b41 100644 --- a/src/play/screen_tests.rs +++ b/src/play/screen_tests.rs @@ -20,17 +20,23 @@ use ratatui::layout::Position; use ratatui::style::Modifier; use ratatui::{Terminal, TerminalOptions, Viewport}; -use super::pinned_tests::PinnedViewport; +use super::pinned_tests::{PinnedViewport, Resized}; use super::prompt_tests::RecordingViewport; use super::sync_tests::{Recorded, RecordingGuard}; use super::*; /// One thing that happens while the loop runs: the player presses a key, -/// the worker reports on the turn, or the worker thread disconnects. +/// the worker reports on the turn, the worker thread disconnects, or the +/// terminal changes size. pub(in crate::play) enum Step { Press(Key), Turn(TurnEvent), Disconnect, + /// The screen becomes this many columns wide and rows tall, and + /// crossterm reports the resize. Only [`play_pinned`] changes the + /// screen: the fake behind [`play_script_on`] leaves its terminal as + /// it is, whatever it is asked for. + Resize(u16, u16), } pub(in crate::play) fn press(key: Key) -> Step { @@ -79,6 +85,7 @@ struct Script { steps: VecDeque, turns: Option>, timeline: Rc>>, + resized: Resized, } impl Keys for Script { @@ -94,6 +101,10 @@ impl Keys for Script { self.turns = None; None } + Some(Step::Resize(width, height)) => { + *self.resized.borrow_mut() = Some((width, height)); + Some(Key::Redraw) + } None => Some(Key::Quit), } } @@ -263,7 +274,14 @@ pub(in crate::play) fn play_script_on(height: u16, steps: Vec) -> Played { timeline: Rc::clone(&guard.timeline), }; let requested = Rc::clone(&viewport.rows); - run_script(terminal, &mut viewport, &mut guard, requested, steps) + run_script( + terminal, + &mut viewport, + &mut guard, + requested, + Resized::default(), + steps, + ) } /// Runs the loop over `steps` on a terminal 40 columns wide and `height` @@ -290,9 +308,18 @@ pub(in crate::play) fn play_pinned(height: u16, steps: Vec) -> Played { let mut viewport = PinnedViewport { rows: Rc::default(), timeline: Rc::clone(&guard.timeline), + resized: Resized::default(), }; let requested = Rc::clone(&viewport.rows); - run_script(terminal, &mut viewport, &mut guard, requested, steps) + let resized = Rc::clone(&viewport.resized); + run_script( + terminal, + &mut viewport, + &mut guard, + requested, + resized, + steps, + ) } /// Plays `steps` through `terminal`, one step per pass of the render @@ -304,6 +331,7 @@ fn run_script>( viewport: &mut V, guard: &mut RecordingGuard, requested: Rc>>, + resized: Resized, steps: Vec, ) -> Played { let (input_sender, inputs) = mpsc::channel(); @@ -312,6 +340,7 @@ fn run_script>( steps: steps.into(), turns: Some(turn_sender), timeline: Rc::clone(&guard.timeline), + resized, }; let mut history = History::in_memory(); let cancel = Arc::new(AtomicBool::new(false)); diff --git a/src/play/terminal.rs b/src/play/terminal.rs index 298e020..82e259e 100644 --- a/src/play/terminal.rs +++ b/src/play/terminal.rs @@ -28,6 +28,7 @@ use crate::knowledge::Mount; use super::history::History; use super::keys::{self, Key, Keys}; +use super::panics; use super::screen::{self, VIEWPORT_HEIGHT}; use super::sync::SyncGuard; use super::viewport::{self, ViewportRows}; @@ -68,6 +69,7 @@ pub fn run(overrides: &Overrides, layers: &[PathBuf], world_root: &Path) -> Resu // event with the pasted text kept whole; not worth failing the game // over. let _ = execute!(std::io::stdout(), EnableBracketedPaste); + install_panic_hook(); let worker = Worker::spawn(dm); let history_path = world_root.join("terminal_history"); let mut history = History::load(history_path); @@ -90,6 +92,28 @@ pub fn run(overrides: &Overrides, layers: &[PathBuf], world_root: &Path) -> Resu played.map_err(|error| error.to_string()) } +/// Installs the play loop's panic hook over ratatui's. +/// +/// `try_init_with_options` installs a hook that restores the terminal and +/// then calls whatever hook was there before it, which prints the panic. +/// Neither of those knows about bracketed paste or about a synchronized +/// update left open mid-pass, so this one runs first and puts both back, +/// then chains to ratatui's for the restore and the message. +/// +/// A panic on the worker thread runs none of it. That thread catches its +/// own panics and reports them as a failed turn, so the game is still +/// running, the terminal is still its own, and both the restore and the +/// message would land in the middle of a live screen. +fn install_panic_hook() { + let ratatui_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + if panics::restores_the_terminal(std::thread::current().name()) { + let _ = panics::leave_modes(&mut io::stdout()); + ratatui_hook(info); + } + })); +} + /// Puts the cursor on the line right below the inline viewport, at column /// 0, so the shell's next prompt starts on a fresh line under the /// transcript instead of wherever the terminal last left it. @@ -162,9 +186,8 @@ impl Keys for CrosstermKeys { struct CrosstermViewport; impl ViewportRows> for CrosstermViewport { - /// Scrolls the screen up if the resize grows past the old viewport's - /// top, clears from the new viewport's top down, and builds the new - /// terminal there. + /// Moves the viewport's top up to the pinned row when it grows, and + /// leaves that row where it is when it shrinks. /// /// The old viewport's top row is where content above it currently /// ends, since the transcript fills the screen down to that row. @@ -176,26 +199,6 @@ impl ViewportRows> for CrosstermViewport { /// 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 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 - /// `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 - /// 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( &mut self, terminal: &mut Terminal>, @@ -204,20 +207,81 @@ impl ViewportRows> for CrosstermViewport { let old_top = terminal.get_frame().area().y; let screen_height = terminal.size()?.height; 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))?; - } - execute!(stdout, MoveTo(0, target), Clear(ClearType::FromCursorDown))?; - *terminal = Terminal::with_options( - CrosstermBackend::new(io::stdout()), - TerminalOptions { - viewport: Viewport::Inline(rows), - }, - )?; - Ok(()) + move_viewport(terminal, rows, old_top, target) } + + /// Puts the viewport back on the last row of the screen, wherever a + /// resize of the terminal left it. + /// + /// This takes the screen's height as it stands now, so a terminal + /// that gained or lost rows lands the viewport on its new last row. + /// A screen that lost rows has already scrolled its own content up, + /// and the scroll this asks for takes more of the transcript into + /// the scrollback rather than overwriting any of it. + fn repin( + &mut self, + terminal: &mut Terminal>, + rows: u16, + ) -> io::Result<()> { + let old_top = terminal.get_frame().area().y; + let screen_height = terminal.size()?.height; + let target = viewport::pinned_top(screen_height, rows); + move_viewport(terminal, rows, old_top, target) + } +} + +/// Puts a viewport of `rows` rows on `target`, with its old top on +/// `old_top`, and builds the terminal that draws it. +/// +/// Rows above `target` that content still owns scroll up out of the way +/// first, the same as a program whose output ran past the last row, so +/// they go into the scrollback intact rather than getting overwritten. +/// The clear then takes every row the viewport holds now, and every row +/// it held before, off the screen. +/// +/// `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 +/// 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 `with_options` already spent the one this 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 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 move_viewport( + terminal: &mut Terminal>, + rows: u16, + old_top: u16, + target: u16, +) -> io::Result<()> { + let scroll = viewport::scroll_up_needed(old_top, target); + let mut stdout = io::stdout(); + if scroll > 0 { + execute!(stdout, ScrollUp(scroll))?; + } + let clear = viewport::clear_from(old_top.saturating_sub(scroll), target); + execute!( + stdout, + MoveTo(0, clear), + Clear(ClearType::FromCursorDown), + MoveTo(0, target) + )?; + *terminal = Terminal::with_options( + CrosstermBackend::new(io::stdout()), + TerminalOptions { + viewport: Viewport::Inline(rows), + }, + )?; + Ok(()) } /// The play loop's synchronized-update guard, backed by stdout. diff --git a/src/play/transcript.rs b/src/play/transcript.rs index 00c7216..3c17a98 100644 --- a/src/play/transcript.rs +++ b/src/play/transcript.rs @@ -91,8 +91,8 @@ pub struct Transcript { /// written. narration: MarkdownStream, /// The width the round in progress renders at, taken from the - /// terminal the first time the round needs it. `None` between - /// rounds. + /// terminal the first time the round has something to render. `None` + /// between rounds. /// /// A `MarkdownStream` counts the rows it has handed out, and that /// count only points at the same text while the render behind it @@ -182,11 +182,20 @@ impl Transcript { /// that safe for a paragraph, which only appends. An ordered list /// that reaches ten items widens every marker, so a row of one that /// spilled early keeps a marker one column too narrow. + /// + /// A round with nothing in it yet returns without taking a width. + /// The play loop calls this on every pass, several times a second, + /// and a round that took its width from the first of those passes + /// would render at whatever the terminal was between turns rather + /// than at what it is when the DM starts to speak. pub fn stream( &mut self, terminal: &Terminal, tail_rows: u16, ) -> Result<(), B::Error> { + if self.narration.is_empty() { + return Ok(()); + } let width = self.round_width(terminal)?; for row in self.narration.committable(width) { self.narrate(terminal, row)?; @@ -267,7 +276,8 @@ impl Transcript { /// The width this round renders at: the terminal's own width the /// first time the round asks for it, and that same width every time - /// after, until [`Transcript::flush`] ends the round. + /// after, until [`Transcript::flush`] ends the round. Only a round + /// with something to render asks. fn round_width(&mut self, terminal: &Terminal) -> Result { match self.width { Some(width) => Ok(width), diff --git a/src/play/transcript_width_tests.rs b/src/play/transcript_width_tests.rs index 4822fdd..3334ab3 100644 --- a/src/play/transcript_width_tests.rs +++ b/src/play/transcript_width_tests.rs @@ -91,6 +91,34 @@ fn a_round_that_widens_mid_stream_still_says_what_it_has_left() { assert_eq!(scrollback(&wide), ["", "thirteen fourteen", "fifteen"]); } +#[test] +fn a_round_takes_the_width_it_first_has_something_to_say_at() { + let wide = terminal(WIDE); + let mut narrow = terminal(NARROW); + let mut transcript = Transcript::default(); + + // The play loop streams several times a second, whether or not the + // DM has said anything yet. + transcript.stream(&wide, DEEP_TAIL).unwrap(); + transcript.delta(PARAGRAPH); + transcript.stream(&narrow, 1).unwrap(); + transcript.place(&mut narrow).unwrap(); + + // The paragraph takes four rows at twenty columns, and a tail area + // of one row spills the first three of them. A round holding the + // eighty columns of the idle pass would render one row and spill + // none of it. + assert_eq!( + scrollback(&narrow), + [ + "", + "one two three four", + "five six seven eight", + "nine ten eleven", + ] + ); +} + #[test] fn a_round_that_narrows_mid_stream_says_nothing_a_second_time() { let mut wide = terminal(WIDE); diff --git a/src/play/viewport.rs b/src/play/viewport.rs index 3de8c75..b3b5010 100644 --- a/src/play/viewport.rs +++ b/src/play/viewport.rs @@ -48,6 +48,18 @@ pub fn scroll_up_needed(current_top: u16, pinned_top: u16) -> u16 { current_top.saturating_sub(pinned_top) } +/// The row to clear from, down to the last row of the screen, when a +/// viewport with its top on `current_top` moves to `pinned_top`. +/// +/// Everything from the higher of the two rows down either belongs to the +/// viewport after the move or belonged to it before. A viewport that +/// moves down the screen leaves its old rows behind it, and nothing +/// repaints them, so the clear has to start at the row it came from +/// rather than the row it lands on. +pub fn clear_from(current_top: u16, pinned_top: u16) -> u16 { + current_top.min(pinned_top) +} + /// Changes the height of the inline viewport pinned to the bottom of the /// screen. /// @@ -86,6 +98,24 @@ pub trait ViewportRows { /// Makes the viewport `rows` rows tall and leaves `terminal` ready to /// repaint every one of them. fn resize(&mut self, terminal: &mut Terminal, rows: u16) -> Result<(), B::Error>; + + /// Puts the viewport back on the last row of the screen, `rows` rows + /// tall, and leaves `terminal` ready to repaint every one of them. + /// + /// This is [`ViewportRows::resize`] with [`pinned_top`] in place of + /// [`next_top`]: the viewport lands flush with the last row of the + /// screen whether that moves it up or down. The rules above hold + /// here too, except the second: a viewport this puts back on the last + /// row needs no rows from the transcript to push it there, so it is + /// safe to call with nothing waiting to go out. + /// + /// A screen that lost rows has already scrolled its own content up + /// by the rows it lost, and this scrolls it again by as much as the + /// viewport's top moves. Those rows go into the scrollback, where + /// the rest of the transcript is, rather than being overwritten. The + /// alternative is a second cursor-position query to find out where + /// content really ends now, and it costs up to two seconds. + fn repin(&mut self, terminal: &mut Terminal, rows: u16) -> Result<(), B::Error>; } #[cfg(test)] diff --git a/src/play/viewport_tests.rs b/src/play/viewport_tests.rs index 6210fd1..a5882a8 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::{next_top, pinned_top, scroll_up_needed}; +use super::{clear_from, next_top, pinned_top, scroll_up_needed}; #[test] fn a_viewport_shorter_than_the_screen_sits_flush_with_the_last_row() { @@ -48,3 +48,13 @@ fn a_pinned_top_exactly_at_the_current_row_needs_no_scroll() { fn a_pinned_top_above_the_current_row_scrolls_the_difference() { assert_eq!(scroll_up_needed(15, 5), 10); } + +#[test] +fn a_viewport_that_moves_down_clears_from_the_row_it_came_from() { + assert_eq!(clear_from(15, 25), 15); +} + +#[test] +fn a_viewport_that_moves_up_clears_from_the_row_it_lands_on() { + assert_eq!(clear_from(15, 5), 5); +} diff --git a/src/play/worker.rs b/src/play/worker.rs index 6e18771..237da59 100644 --- a/src/play/worker.rs +++ b/src/play/worker.rs @@ -1,14 +1,29 @@ //! The worker thread. It owns the `Dm` and turns player input into a //! stream of `TurnEvent`s, so the render loop never waits on the network. +use std::any::Any; use std::ops::ControlFlow; +use std::panic::{self, AssertUnwindSafe}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{self, Receiver, Sender}; use ratatui::text::Text; -use crate::dm::{Dm, Turn, TurnDelta}; +use crate::dm::{Dm, Turn, TurnDelta, TurnError}; + +/// The name the worker thread runs under, which the play loop's panic +/// hook reads. A panic here reaches the player as a failed turn, so the +/// hook leaves the terminal alone rather than restoring it under a game +/// that is still running. +pub const THREAD_NAME: &str = "storied-worker"; + +/// What the player is told when a turn panics. +const PANICKED: &str = "the storyteller hit a bug and dropped this turn"; + +/// What the player is told when a turn panics with a payload that is not +/// text, which nothing in storied raises. +const NO_MESSAGE: &str = "no message"; /// What one turn tells the render loop while it runs. #[derive(Debug, Clone, PartialEq, Eq)] @@ -44,12 +59,20 @@ pub struct Worker { impl Worker { /// Starts a thread that runs `dm`, one turn per line sent to `inputs`. + /// + /// # Panics + /// + /// Panics if the operating system will not start the thread, which + /// leaves nothing to play the game with. pub fn spawn(dm: Dm) -> Self { let (inputs, requests) = mpsc::channel(); let (replies, events) = mpsc::channel(); let cancel = Arc::new(AtomicBool::new(false)); let worker_cancel = Arc::clone(&cancel); - std::thread::spawn(move || run(dm, &requests, &replies, &worker_cancel)); + std::thread::Builder::new() + .name(THREAD_NAME.to_string()) + .spawn(move || run(dm, &requests, &replies, &worker_cancel)) + .expect("the storyteller thread could not start"); Self { inputs, events, @@ -81,15 +104,47 @@ fn run(mut dm: Dm, requests: &Receiver, replies: &Sender, can ControlFlow::Continue(()) } }; - let event = match dm.turn(&input, &mut on_delta) { - Ok(Turn::Reply(reply)) => TurnEvent::Done(reply), - Ok(Turn::Cancelled(partial)) => TurnEvent::Cancelled(partial), - Err(error) => TurnEvent::Failed(error.to_string()), - }; - let _ = replies.send(event); + let _ = replies.send(run_turn(&mut || dm.turn(&input, &mut on_delta))); } } +/// Runs one turn and reports what it produced, a panic included. +/// +/// A panic anywhere in a turn, in the JSON, the event stream, or a tool, +/// would otherwise take the whole thread with it and end the session. +/// Catching it here reports the turn as failed, the same as any other +/// error, and the game plays on. +/// +/// `AssertUnwindSafe` is what lets the caller keep using the `Dm` after +/// a caught panic. A turn builds its messages in locals and appends them +/// to the history only once it has a reply, so a turn that panicked part +/// way through leaves the history as it was before it. The one field it +/// writes early is the system message at the head of the history, which +/// the next turn composes again from the context stack. +/// +/// `turn` arrives as a trait object rather than as a type parameter, so +/// every caller runs the same copy of this function. +fn run_turn(turn: &mut dyn FnMut() -> Result) -> TurnEvent { + match panic::catch_unwind(AssertUnwindSafe(turn)) { + Ok(Ok(Turn::Reply(reply))) => TurnEvent::Done(reply), + Ok(Ok(Turn::Cancelled(partial))) => TurnEvent::Cancelled(partial), + Ok(Err(error)) => TurnEvent::Failed(error.to_string()), + Err(payload) => { + TurnEvent::Failed(format!("{PANICKED}: {}", panic_message(payload.as_ref()))) + } + } +} + +/// What a caught panic said. `panic!` and `assert!` raise a `String` or a +/// `&str`; a payload of any other type has no text to show. +fn panic_message(payload: &(dyn Any + Send)) -> &str { + payload + .downcast_ref::<&str>() + .copied() + .or_else(|| payload.downcast_ref::().map(String::as_str)) + .unwrap_or(NO_MESSAGE) +} + #[cfg(test)] mod tests { use super::*; @@ -268,6 +323,38 @@ mod tests { server.join().unwrap(); } + #[test] + fn a_turn_that_panics_reports_the_panic_as_a_failed_turn() { + let event = run_turn(&mut || panic!("the roll tool split a die")); + + assert_eq!( + event, + TurnEvent::Failed(format!("{PANICKED}: the roll tool split a die")) + ); + } + + #[test] + fn a_panic_with_a_formatted_message_keeps_its_text() { + let tool = "roll"; + + let event = run_turn(&mut || panic!("the {tool} tool split a die")); + + assert_eq!( + event, + TurnEvent::Failed(format!("{PANICKED}: the roll tool split a die")) + ); + } + + #[test] + fn a_panic_carrying_something_other_than_text_still_fails_the_turn() { + let event = run_turn(&mut || panic::panic_any(7u8)); + + assert_eq!( + event, + TurnEvent::Failed(format!("{PANICKED}: {NO_MESSAGE}")) + ); + } + #[test] fn a_failed_turn_sends_the_error_text() { let worker = Worker::spawn(dm_for(dead_address()));