From 402731235759745fe1984b0be1da41a97a903e93 Mon Sep 17 00:00:00 2001 From: Chris Guidry Date: Sun, 2 Aug 2026 21:08:13 -0400 Subject: [PATCH] Survive resizes and panics at the terminal A window resize now reaches the screen as a redraw: the viewport re-pins to the bottom and repaints inside its own synchronized update, instead of ratatui clearing the screen and the prompt stranding at row 0 until a whole reply walked it back. Ctrl+L runs the same repaint as the escape hatch. The transcript pins its round wrap width when a round first has content, not on idle polls, so a resize between turns no longer costs the next turn a stale width. A worker panic is caught and reported as a failed turn, and the play loop's panic hook knows the worker thread by name and leaves the terminal alone for it, since ratatui's own hook would restore raw mode under the running UI from any thread. For a genuine UI-thread panic the hook ends the synchronized update and disables bracketed paste before ratatui's restore, so the panic message is visible and the shell gets a usable terminal back. Co-Authored-By: Claude Fable 5 --- src/play/keys.rs | 22 +++-- src/play/mod.rs | 1 + src/play/panics.rs | 73 ++++++++++++++++ src/play/screen.rs | 36 ++++++++ src/play/screen_pinned_tests.rs | 66 +++++++++++--- src/play/screen_prompt_tests.rs | 4 + src/play/screen_resize_tests.rs | 97 ++++++++++++++++++++ src/play/screen_tests.rs | 37 +++++++- src/play/terminal.rs | 136 +++++++++++++++++++++-------- src/play/transcript.rs | 16 +++- src/play/transcript_width_tests.rs | 28 ++++++ src/play/viewport.rs | 30 +++++++ src/play/viewport_tests.rs | 12 ++- src/play/worker.rs | 103 ++++++++++++++++++++-- 14 files changed, 590 insertions(+), 71 deletions(-) create mode 100644 src/play/panics.rs create mode 100644 src/play/screen_resize_tests.rs 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())); -- 2.51.2