//! The glue between the play loop and the real terminal. //! //! Every line here needs a terminal that the test suite does not have: raw //! mode, an inline viewport anchored to the cursor, and crossterm's event //! stream. Coverage builds leave the whole module out, so it holds as //! little as it can and every decision lives in a module that tests reach. use std::io::{self, Stdout, Write}; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; use crossterm::cursor::{self, MoveTo}; use crossterm::event; use crossterm::event::{DisableBracketedPaste, EnableBracketedPaste}; use crossterm::execute; use crossterm::terminal::{ BeginSynchronizedUpdate, Clear, ClearType, EndSynchronizedUpdate, ScrollUp, }; use ratatui::backend::CrosstermBackend; use ratatui::layout::Rect; use ratatui::{Terminal, TerminalOptions, Viewport}; use crate::campaign::Campaign; use crate::config::{self, Overrides}; use crate::dm::Dm; use crate::knowledge::Mount; use super::history::History; use super::keys::{self, Key, Keys}; use super::panics; use super::screen::{self, Opening, Stage, VIEWPORT_HEIGHT}; use super::sync::SyncGuard; use super::viewport::{self, ViewportRows}; use super::worker::Worker; /// How long to wait for a key before looking at the worker again. const POLL_INTERVAL: Duration = Duration::from_millis(50); /// Loads the config, opens the knowledge mount over `layers`, lowest /// first, starts the DM, and plays until the player quits. /// /// `opening` is a canned scenario's first line and, when given, always /// runs as the player's first turn before any key, with no divider. When /// `opening` is `None`, a session the DM resumed from a prior transcript /// runs its recap trigger instead, which the engine speaks: the player /// never sees it, and a divider marks where the recap ends and live play /// begins. A fresh world with no scenario opens with neither, and the /// player speaks first. /// /// The terminal goes back to how it was before the game, with the /// transcript still on the screen. An init that fails part way through has /// already put the terminal in raw mode, so that path restores it too. A /// mount that fails to open, a DM that fails to start, and a config that /// fails to load all end the game before the terminal changes anything. /// Pinning the viewport to the bottom happens after all three succeed /// and before the terminal enters raw mode, so a failure there ends the /// game the same way. pub fn run( overrides: &Overrides, layers: &[PathBuf], world_root: &Path, opening: Option, ) -> Result<(), String> { let config = config::load(overrides).map_err(|error| error.to_string())?; let mount = Arc::new(Mount::open(layers)?); let campaign = Campaign::open(world_root)?; // `/settings` shows this, composed once here where the config, the // layers, and the world root are all in hand, before the config moves // into the DM below. let settings = super::settings(&config, layers, world_root); // The prompt reads the clock from its own handle on the campaign, // because the DM owns the one it advances and lives on the worker // thread. A read that fails leaves the prompt with the bare marker // rather than ending the session over a prefix. let clock_campaign = campaign.clone(); let mut clock = move || clock_campaign.current_time().ok(); // `/play` and the unbound notice read and write the world's characters // through their own handle on the campaign too, for the same reason // the clock does: the DM owns the one it advances, and that one lives // on the worker thread. let stage_campaign = campaign.clone(); let dm = Dm::new(config, mount, layers, Some(campaign))?; // The DM answers what `/context` shows: its own prompt and its own // tools, so the report cannot drift from the session. let slash_context = dm.context_report(); // Read before the DM moves into the worker below. A scenario's opening // wins outright; otherwise a resumed session's recap trigger opens the // session with a divider, and a fresh world with nothing to recap opens // with neither. let opening = opening .map(Opening::Scenario) .or_else(|| dm.recap_trigger().map(Opening::Recap)); pin_to_bottom(VIEWPORT_HEIGHT).map_err(|error| error.to_string())?; let options = TerminalOptions { viewport: Viewport::Inline(VIEWPORT_HEIGHT), }; let mut terminal = ratatui::try_init_with_options(options).map_err(|error| { ratatui::restore(); error.to_string() })?; // A failed enable leaves paste as plain keystrokes instead of one // 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); let played = screen::play( &mut terminal, &mut CrosstermKeys, &worker, &mut screen::Session { settings: &settings, slash_context: &slash_context, history: &mut history, clock: &mut clock, stage: &stage_campaign, opening, }, &mut CrosstermSyncGuard, &mut CrosstermViewport, ); let viewport = terminal.get_frame().area(); let _ = execute!(std::io::stdout(), DisableBracketedPaste); ratatui::restore(); park_cursor_below(viewport); 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. /// /// `viewport` is the inline viewport's last-drawn rectangle in absolute /// terminal coordinates, from `Terminal::get_frame().area()`. This runs /// after `ratatui::restore()`, which leaves the alternate screen as a side /// effect of disabling raw mode and can itself move the cursor; writing /// straight to stdout here, after that, is what makes the final position /// stick. When the viewport already sits on the terminal's last row, the /// newline scrolls the screen by one line, the same as any program's /// output filling the last line before the prompt returns. fn park_cursor_below(viewport: Rect) { let last_row = viewport.y + viewport.height.saturating_sub(1); let mut stdout = std::io::stdout(); let _ = execute!(stdout, MoveTo(viewport.x, last_row)); let _ = write!(stdout, "\r\n"); let _ = stdout.flush(); } /// Moves the cursor to the row a fresh inline viewport of `viewport_height` /// rows should treat as its top, so the viewport `try_init_with_options` /// builds next lands at the bottom of the screen instead of wherever the /// shell happened to leave the cursor. /// /// A fresh inline viewport anchors to whatever row the cursor sits on and /// only scrolls if its height runs past the screen's last row, so without /// this a cursor left near the top of an otherwise empty screen would /// anchor the viewport there too, leaving the rows below it dead for the /// rest of the session. When the cursor already sits past where the /// pinned top belongs, this scrolls the screen up first, the same as a /// program whose output ran past the last row before the next line /// printed. /// /// This queries the cursor position once here, and `try_init_with_options` /// queries it again inside `Terminal::with_options`, which does not cache /// the answer this function already read. Each query is `ESC [ 6 n`, and /// crossterm blocks up to two seconds waiting for the reply, so a slow or /// unresponsive terminal makes startup wait close to four seconds, not /// two. A terminal that actually answers takes milliseconds. fn pin_to_bottom(viewport_height: u16) -> io::Result<()> { let (_, screen_height) = crossterm::terminal::size()?; let (_, cursor_row) = cursor::position()?; let target = viewport::pinned_top(screen_height, viewport_height); let scroll = viewport::scroll_up_needed(cursor_row, target); let mut stdout = io::stdout(); if scroll > 0 { execute!(stdout, ScrollUp(scroll))?; } execute!(stdout, MoveTo(0, target)) } /// The screen's handle on the world's characters, backed by a cloned /// campaign the same way the clock is. /// /// Every method here calls a `Campaign` method of the same name. /// `Campaign`'s own methods take priority over this trait's during that /// call, so each one reaches straight into the campaign rather than /// looping back into itself; this impl exists only to expose the rule /// the engine already uses for who is on stage, not to write it twice. impl Stage for Campaign { fn characters(&self) -> Vec { self.characters() } fn on_stage(&self) -> Option { let mut characters = self.characters(); match characters.len() { 1 => characters.pop(), _ => self.on_stage().ok().flatten(), } } fn take_stage(&self, slug: &str) -> Result<(), String> { let time = self.current_time()?; self.take_stage(slug, time) } } /// The player's keyboard, as crossterm reports it. struct CrosstermKeys; impl Keys for CrosstermKeys { /// Waits `POLL_INTERVAL` for a key. A terminal that can no longer /// report one has nothing more to say, so the game ends. fn next_key(&mut self) -> Option { match event::poll(POLL_INTERVAL) { Ok(true) => keys::decode(&event::read().ok()?), Ok(false) => None, Err(_) => Some(Key::Quit), } } } /// The inline viewport, resized by building a new `Terminal` over the /// same stdout. struct CrosstermViewport; impl ViewportRows> for CrosstermViewport { /// 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. /// 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. fn resize( &mut self, terminal: &mut Terminal>, rows: u16, ) -> io::Result<()> { let old_top = terminal.get_frame().area().y; let screen_height = terminal.size()?.height; move_viewport( terminal, viewport::Move::resize(screen_height, rows, old_top), ) } /// 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; move_viewport( terminal, viewport::Move::repin(screen_height, rows, old_top), ) } } /// Writes `plan` to stdout, in order, and builds the terminal that draws /// the viewport it leaves behind. /// /// The scroll is crossterm's `ScrollUp`, which the terminal treats the /// same as a program whose output ran past the last row, so the rows it /// takes off the top go into the scrollback intact rather than getting /// overwritten. /// /// `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>, plan: viewport::Move, ) -> io::Result<()> { let mut stdout = io::stdout(); if plan.scroll > 0 { execute!(stdout, ScrollUp(plan.scroll))?; } execute!( stdout, MoveTo(0, plan.clear), Clear(ClearType::FromCursorDown), MoveTo(0, plan.top) )?; *terminal = Terminal::with_options( CrosstermBackend::new(io::stdout()), TerminalOptions { viewport: Viewport::Inline(plan.rows), }, )?; Ok(()) } /// The play loop's synchronized-update guard, backed by stdout. struct CrosstermSyncGuard; impl SyncGuard for CrosstermSyncGuard { /// A failed write here means the terminal is already in trouble, and /// the next draw will fail too and end the game; there is nothing /// useful to do with the error before then. fn begin(&mut self) { let _ = execute!(std::io::stdout(), BeginSynchronizedUpdate); } fn end(&mut self) { let _ = execute!(std::io::stdout(), EndSynchronizedUpdate); } }