diff --git a/src/play/prompt.rs b/src/play/prompt.rs index f9eb37b..b13bed0 100644 --- a/src/play/prompt.rs +++ b/src/play/prompt.rs @@ -5,23 +5,44 @@ use ratatui::layout::{Position, Rect}; use ratatui::text::{Line, Text}; use unicode_width::UnicodeWidthStr; +use crate::campaign::GameTime; + use super::editor::Editor; -/// The marker in front of the input's first row and the player's -/// transcript lines. +/// The marker the prefix ends with, and the whole prefix while the +/// campaign clock is unknown. pub(super) const PLAYER_MARKER: &str = "> "; -/// What goes in front of every prompt row after the first: two spaces, so -/// a wrapped input stays in one column under the marker. A marker on -/// every row would read as more than one input, and a punctuated +/// What leads the input's first row and the player's transcript lines: +/// the campaign's day and time of day ahead of the marker when the clock +/// is known, the bare marker when it is not. +/// +/// The clock only moves when the DM marks an event, so the prefix a +/// player types under names the moment their line happens in. +pub(super) fn prefix(time: Option) -> String { + match time { + Some(time) => format!( + "Day {}, {:02}:{:02} {PLAYER_MARKER}", + time.day, time.hour, time.minute + ), + None => PLAYER_MARKER.to_string(), + } +} + +/// What goes in front of every prompt row after the first: spaces as wide +/// as `prefix`, so a wrapped input stays in one column under it. A prefix +/// on every row would read as more than one input, and a punctuated /// continuation would be hard to tell from the text the player typed. -const CONTINUATION: &str = " "; +fn continuation(prefix: &str) -> String { + " ".repeat(prefix.width()) +} /// The columns a prompt row has for the player's own text, on a viewport -/// `width` columns wide. The marker and the continuation are the same -/// width, so every row of the input wraps at the same column. -pub(super) fn text_width(width: u16) -> usize { - (width as usize).saturating_sub(PLAYER_MARKER.width()) +/// `width` columns wide behind `prefix`. The prefix and the continuation +/// are the same width, so every row of the input wraps at the same +/// column. +pub(super) fn text_width(width: u16, prefix: &str) -> usize { + (width as usize).saturating_sub(prefix.width()) } /// How many rows of `area` the prompt takes: one for each row the input @@ -30,56 +51,50 @@ pub(super) fn text_width(width: u16) -> usize { /// normally already the right height for the whole input, so the limit /// only bites while the input is taller than the cap on the viewport /// itself. -pub(super) fn prompt_rows(input: &Editor, area: Rect, fixed_rows: u16) -> u16 { - let rows = input.row_count(text_width(area.width)) as u16; +pub(super) fn prompt_rows(input: &Editor, area: Rect, fixed_rows: u16, prefix: &str) -> u16 { + let rows = input.row_count(text_width(area.width, prefix)) as u16; rows.clamp(1, area.height.saturating_sub(fixed_rows).max(1)) } /// The prompt's rows and where the cursor goes among them. /// /// The rows shown are the ones ending at the cursor's row, so the cursor -/// stays in sight on an input too tall for the room it has. The marker +/// stays in sight on an input too tall for the room it has. The prefix /// leads the input's first row and the continuation leads every other, /// which is why the column the cursor lands on is the same either way. -pub(super) fn prompt_widget(input: &Editor, area: Rect) -> (Text<'static>, Position) { - let width = text_width(area.width); +pub(super) fn prompt_widget(input: &Editor, area: Rect, prefix: &str) -> (Text<'static>, Position) { + let width = text_width(area.width, prefix); let (row, column) = input.cursor_row_column(width); // A cramped terminal can squeeze this to zero height. Treat it as one // row so the math below never underflows; render_widget on the real // zero-height area still draws nothing. let height = (area.height as usize).max(1); let first = (row + 1).saturating_sub(height); + let continuation = continuation(prefix); let lines: Vec = input .wrapped_rows(width) .into_iter() .enumerate() .skip(first) .take(height) - .map(|(index, text)| Line::raw(format!("{}{text}", marker(index)))) + .map(|(index, text)| { + let lead = if index == 0 { prefix } else { &continuation }; + Line::raw(format!("{lead}{text}")) + }) .collect(); ( Text::from(lines), - cursor(row.saturating_sub(first), column, area), + cursor(row.saturating_sub(first), column, area, prefix), ) } -/// What goes in front of the prompt row that holds the input's `row`th -/// row. -fn marker(row: usize) -> &'static str { - if row == 0 { - PLAYER_MARKER - } else { - CONTINUATION - } -} - /// Where the cursor goes: at screen `column` of the prompt's `row`th -/// row, past the marker, never past the last column. `column` counts the +/// row, past the prefix, never past the last column. `column` counts the /// columns the row prints in ahead of the cursor, which is what /// [`Editor::cursor_row_column`] hands back, so a cursor after a wide /// character lands past all of it. -fn cursor(row: usize, column: usize, prompt: Rect) -> Position { - let column = PLAYER_MARKER.width() + column; +fn cursor(row: usize, column: usize, prompt: Rect, prefix: &str) -> Position { + let column = prefix.width() + column; let last = prompt.width.saturating_sub(1); Position::new(prompt.x + (column as u16).min(last), prompt.y + row as u16) } @@ -105,25 +120,53 @@ pub(super) fn on_screen(cursor: Position, area: Rect) -> Position { mod tests { use super::*; + #[test] + fn a_known_clock_gives_the_day_and_the_time_of_day_ahead_of_the_marker() { + let time = GameTime { + day: 1, + hour: 8, + minute: 30, + }; + + assert_eq!(prefix(Some(time)), "Day 1, 08:30 > "); + } + + #[test] + fn an_unknown_clock_gives_the_bare_marker() { + assert_eq!(prefix(None), "> "); + } + #[test] fn the_cursor_lands_past_every_column_the_input_prints_in() { let mut input = Editor::default(); input.set_text("日本"); let area = Rect::new(0, 0, 20, 2); - let (_, cursor) = prompt_widget(&input, area); + let (_, cursor) = prompt_widget(&input, area, PLAYER_MARKER); // Two characters, four columns, behind the two-column marker. assert_eq!(cursor, Position::new(6, 0)); } + #[test] + fn the_cursor_lands_past_a_timed_prefix() { + let mut input = Editor::default(); + input.set_text("hi"); + let area = Rect::new(0, 0, 40, 2); + + let (_, cursor) = prompt_widget(&input, area, "Day 1, 08:30 > "); + + // Fifteen columns of prefix, then the two the input prints in. + assert_eq!(cursor, Position::new(17, 0)); + } + #[test] fn an_input_of_wide_characters_wraps_at_the_columns_it_prints_in() { let mut input = Editor::default(); input.set_text("日本語"); let area = Rect::new(0, 0, 6, 2); - let (text, _) = prompt_widget(&input, area); + let (text, _) = prompt_widget(&input, area, PLAYER_MARKER); assert_eq!( text, @@ -131,6 +174,25 @@ mod tests { ); } + #[test] + fn a_wrapped_input_pads_its_later_rows_to_the_width_of_the_prefix() { + let mut input = Editor::default(); + input.set_text("abcdef"); + let area = Rect::new(0, 0, 19, 2); + + let (text, _) = prompt_widget(&input, area, "Day 1, 08:30 > "); + + // Nineteen columns behind a fifteen-column prefix leave four for + // the text, and the second row lines up under the first. + assert_eq!( + text, + Text::from(vec![ + Line::raw("Day 1, 08:30 > abcd"), + Line::raw(" ef"), + ]) + ); + } + #[test] fn an_in_bounds_cursor_below_the_top_of_the_screen_passes_through() { let area = Rect::new(0, 20, 80, 4); diff --git a/src/play/screen.rs b/src/play/screen.rs index 54c432b..d9e10e5 100644 --- a/src/play/screen.rs +++ b/src/play/screen.rs @@ -25,10 +25,12 @@ use ratatui::layout::{Constraint, Layout, Size}; use ratatui::style::Modifier; use ratatui::text::Text; +use crate::campaign::GameTime; + use super::editor::Editor; use super::history::History; use super::keys::{Key, Keys}; -use super::prompt::{self, PLAYER_MARKER}; +use super::prompt; use super::sync::SyncGuard; use super::thinking; use super::transcript::{Kind, Transcript, aside, failure, player}; @@ -104,14 +106,28 @@ struct Screen { /// because a resize costs a cursor-position query the terminal can /// take up to two seconds to answer. rows: u16, + /// What leads the input's first row and the line the player submits. + /// It holds the campaign clock as of the last turn that ended, so + /// the whole prompt keeps one shape while the player types. + prefix: String, } -/// The prose a session composes before the loop starts. -pub struct SessionText<'a> { +/// Where the screen reads the campaign clock. `None` leaves the prompt +/// with the bare marker. +pub type Clock<'a> = &'a mut dyn FnMut() -> Option; + +/// What a session brings to the loop besides the terminal, the player's +/// keys, and the worker: the prose it composes before the loop starts, +/// the prompts it remembers, and where it reads the campaign clock. +pub struct Session<'a> { /// What the transcript opens with. pub banner: &'a str, /// What `/context` prints: the DM's system prompt, frozen at startup. pub slash_context: &'a str, + /// The prompts the player recalls with Up and Down. + pub history: &'a mut History, + /// Where the screen reads the campaign clock. + pub clock: Clock<'a>, } /// Runs the game until the player quits. @@ -119,8 +135,7 @@ pub struct SessionText<'a> { /// Reads keys from `keys`, sends submitted lines to `worker.inputs`, and /// shows what comes back on `worker.events`. Finished rows go above the /// viewport as they stream, into the terminal's own scrollback, so they -/// survive the game. `history` holds the prompts the player can recall -/// with Up and Down. Escape while a turn is running sets `worker.cancel`, +/// survive the game. Escape while a turn is running sets `worker.cancel`, /// which the worker checks to stop the turn. Ctrl+C does the same while a /// turn runs, so a reply the player no longer wants costs the turn and /// nothing else; with no turn running it ends the game. @@ -139,12 +154,16 @@ pub struct SessionText<'a> { /// 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. +/// +/// `session.clock` says what time it is in the campaign, which the +/// prompt's prefix shows. Only a turn moves that clock, so this reads it +/// once before the loop and once more after each turn ends, never on the +/// way through a keystroke. pub fn play>( terminal: &mut Terminal, keys: &mut K, worker: &Worker, - text: &SessionText<'_>, - history: &mut History, + session: &mut Session<'_>, guard: &mut G, viewport: &mut V, ) -> Result<(), B::Error> { @@ -156,17 +175,18 @@ pub fn play>( turns: 0, state: DmState::Thinking, rows: VIEWPORT_HEIGHT, + prefix: prompt::prefix((session.clock)()), }; screen .transcript - .insert(terminal, text.banner, aside(), Kind::Aside)?; + .insert(terminal, session.banner, aside(), Kind::Aside)?; loop { screen.tick = screen.tick.wrapping_add(1); guard.begin(); // An error here returns with the guard still open. That is fine: // the caller exits the program on this path, and a terminal that // never sees a matching `end` recovers on its own safety timeout. - drain(&mut screen, terminal, &worker.events)?; + drain(&mut screen, terminal, &worker.events, session.clock)?; fit(&mut screen, terminal, viewport)?; screen.transcript.place(terminal)?; terminal.draw(|frame| render(&screen, frame))?; @@ -190,11 +210,11 @@ pub fn play>( Some(Key::Yank) => screen.input.yank(), Some(Key::Eof) if screen.input.text().is_empty() => return Ok(()), Some(Key::Eof) => screen.input.delete(), - Some(Key::Up) => up(&mut screen, terminal, history)?, - Some(Key::Down) => down(&mut screen, terminal, history)?, + Some(Key::Up) => up(&mut screen, terminal, session.history)?, + Some(Key::Down) => down(&mut screen, terminal, session.history)?, Some(Key::Newline) => screen.input.insert('\n'), Some(Key::Paste(text)) => screen.input.insert_str(&text), - Some(Key::Enter) => screen.submit(terminal, worker, text, history, guard, viewport)?, + Some(Key::Enter) => screen.submit(terminal, worker, session, guard, viewport)?, Some(Key::Cancel | Key::Interrupt) if screen.busy => { worker.cancel.store(true, Ordering::Relaxed); } @@ -216,7 +236,7 @@ fn fit>( let size = terminal.size()?; let forming = screen.transcript.forming_rows(size.width).len(); let floor = floor(screen.rows, screen.transcript.waiting(), size, screen.busy); - let wanted = wanted_rows(&screen.input, size, forming).max(floor); + let wanted = wanted_rows(&screen.input, size, forming, &screen.prefix).max(floor); if wanted == screen.rows { return Ok(()); } @@ -289,26 +309,26 @@ fn floor(rows: u16, waiting: u16, size: Size, busy: bool) -> u16 { /// 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 /// the cap. -fn wanted_rows(input: &Editor, size: Size, forming: usize) -> u16 { +fn wanted_rows(input: &Editor, size: Size, forming: usize, prefix: &str) -> u16 { let cap = cap(size); let tail = forming.clamp(1, cap as usize) as u16; - (SEPARATOR_ROWS + input_rows(input, size) + tail).min(cap) + (SEPARATOR_ROWS + input_rows(input, size, prefix) + tail).min(cap) } /// How many rows the tail area has room for: what the cap leaves after the /// blank row, the rule, and the prompt's rows, and never fewer than one. /// A forming block with more rows than this spills its top rows into the /// transcript. -fn tail_rows(input: &Editor, size: Size) -> u16 { +fn tail_rows(input: &Editor, size: Size, prefix: &str) -> u16 { cap(size) - .saturating_sub(SEPARATOR_ROWS + input_rows(input, size)) + .saturating_sub(SEPARATOR_ROWS + input_rows(input, size, prefix)) .max(1) } /// How many rows the input wraps to at the prompt's width, before the cap /// on the viewport takes any of them away. -fn input_rows(input: &Editor, size: Size) -> u16 { - input.row_count(prompt::text_width(size.width)) as u16 +fn input_rows(input: &Editor, size: Size, prefix: &str) -> u16 { + input.row_count(prompt::text_width(size.width, prefix)) as u16 } /// The tallest the viewport may grow on a screen of `size`, which leaves @@ -341,7 +361,7 @@ fn up( terminal: &mut Terminal, history: &mut History, ) -> Result<(), B::Error> { - let width = prompt::text_width(terminal.size()?.width); + let width = prompt::text_width(terminal.size()?.width, &screen.prefix); let (row, _) = screen.input.cursor_row_column(width); if row == 0 { let entry = history.up(screen.input.text()); @@ -360,7 +380,7 @@ fn down( terminal: &mut Terminal, history: &mut History, ) -> Result<(), B::Error> { - let width = prompt::text_width(terminal.size()?.width); + let width = prompt::text_width(terminal.size()?.width, &screen.prefix); let (row, _) = screen.input.cursor_row_column(width); if row + 1 == screen.input.row_count(width) { let entry = history.down(); @@ -379,6 +399,10 @@ impl Screen { /// gone, which leaves nothing to do but keep drawing until the player /// quits. /// + /// The line goes out under the prefix it was typed under, so it keeps + /// the shape it had on the prompt. The clock the next turn ends on is + /// the DM's answer to this line, not the moment the player said it. + /// /// 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. @@ -394,8 +418,7 @@ impl Screen { &mut self, terminal: &mut Terminal, worker: &Worker, - text: &SessionText<'_>, - history: &mut History, + session: &mut Session<'_>, guard: &mut G, viewport: &mut V, ) -> Result<(), B::Error> { @@ -412,12 +435,12 @@ impl Screen { // Slash commands stay local. /context shows the DM's system prompt. if input == "/context" { - let line = format!("{PLAYER_MARKER}/context"); + let line = format!("{}/context", self.prefix); guard.begin(); self.transcript .insert(terminal, &line, player(), Kind::Player)?; self.transcript - .insert(terminal, text.slash_context, aside(), Kind::Aside)?; + .insert(terminal, session.slash_context, aside(), Kind::Aside)?; fit(self, terminal, viewport)?; self.transcript.place(terminal)?; terminal.draw(|frame| render(self, frame))?; @@ -425,7 +448,7 @@ impl Screen { return Ok(()); } - let line = format!("{PLAYER_MARKER}{input}"); + let line = format!("{}{input}", self.prefix); guard.begin(); self.transcript .insert(terminal, &line, player(), Kind::Player)?; @@ -436,7 +459,7 @@ impl Screen { self.busy = true; self.state = DmState::Thinking; let _ = worker.inputs.send(input.clone()); - history.record(&input); + session.history.record(&input); Ok(()) } } @@ -475,9 +498,10 @@ fn drain( screen: &mut Screen, terminal: &mut Terminal, turns: &Receiver, + clock: Clock<'_>, ) -> Result<(), B::Error> { - events(screen, terminal, turns)?; - let tail = tail_rows(&screen.input, terminal.size()?); + events(screen, terminal, turns, clock)?; + let tail = tail_rows(&screen.input, terminal.size()?, &screen.prefix); screen.transcript.stream(terminal, tail) } @@ -496,6 +520,7 @@ fn events( screen: &mut Screen, terminal: &mut Terminal, turns: &Receiver, + clock: Clock<'_>, ) -> Result<(), B::Error> { loop { let event = match turns.try_recv() { @@ -507,7 +532,7 @@ fn events( screen .transcript .insert(terminal, WORKER_GONE, failure(), Kind::Aside)?; - settle(screen); + settle(screen, clock); } return Ok(()); } @@ -522,20 +547,20 @@ fn events( screen.transcript.flush(terminal)?; screen.transcript.insert_text(terminal, text)?; } - TurnEvent::Done(_) => finish(screen, terminal)?, + TurnEvent::Done(_) => finish(screen, terminal, clock)?, TurnEvent::Cancelled(_) => { screen.transcript.flush(terminal)?; screen .transcript .insert(terminal, INTERRUPTED, aside(), Kind::Aside)?; - settle(screen); + settle(screen, clock); } TurnEvent::Failed(text) => { screen.transcript.flush(terminal)?; screen .transcript .insert(terminal, &text, failure(), Kind::Aside)?; - settle(screen); + settle(screen, clock); } } } @@ -546,24 +571,33 @@ fn events( /// Reads what streamed in rather than the event's own payload: a round /// that narrates and then calls a secret tool emits no tool event, so the /// stream is the only place that has seen every delta of the turn. -fn finish(screen: &mut Screen, terminal: &mut Terminal) -> Result<(), B::Error> { +fn finish( + screen: &mut Screen, + terminal: &mut Terminal, + clock: Clock<'_>, +) -> Result<(), B::Error> { screen.transcript.flush(terminal)?; if !screen.transcript.said() { screen .transcript .insert(terminal, NOTHING_SAID, aside(), Kind::Aside)?; } - settle(screen); + settle(screen, clock); Ok(()) } /// Ends the turn: the prompt works again, and the next turn starts with /// nothing said. The turn count advances here, so the first turn of a /// session shows the first thinking phrase. -fn settle(screen: &mut Screen) { +/// +/// The prefix takes the clock again here. A turn is the only thing that +/// moves campaign time, so this is the one point in the session where the +/// prompt can show a new day or hour. +fn settle(screen: &mut Screen, clock: Clock<'_>) { screen.busy = false; screen.transcript.next_turn(); screen.turns += 1; + screen.prefix = prompt::prefix(clock()); } /// Draws the tail area (the block still forming), a blank row, the status @@ -574,7 +608,7 @@ fn settle(screen: &mut Screen) { /// so the thinking indicator always has one clear line above it. fn render(screen: &Screen, frame: &mut Frame) { let area = frame.area(); - let rows = prompt::prompt_rows(&screen.input, area, FIXED_ROWS); + let rows = prompt::prompt_rows(&screen.input, area, FIXED_ROWS, &screen.prefix); let [tail, _blank, status, rule, prompt_area] = Layout::vertical([ Constraint::Min(0), Constraint::Length(1), @@ -589,7 +623,7 @@ fn render(screen: &Screen, frame: &mut Frame) { Text::styled(RULE.repeat(rule.width as usize), aside()), rule, ); - let (text, cursor) = prompt::prompt_widget(&screen.input, prompt_area); + let (text, cursor) = prompt::prompt_widget(&screen.input, prompt_area, &screen.prefix); frame.render_widget(text, prompt_area); frame.set_cursor_position(prompt::on_screen(cursor, area)); } @@ -654,6 +688,10 @@ mod multiline_tests; #[path = "screen_prompt_tests.rs"] mod prompt_tests; +#[cfg(test)] +#[path = "screen_clock_tests.rs"] +mod clock_tests; + #[cfg(test)] #[path = "screen_pinned_tests.rs"] mod pinned_tests; diff --git a/src/play/screen_clock_tests.rs b/src/play/screen_clock_tests.rs new file mode 100644 index 0000000..444bb1f --- /dev/null +++ b/src/play/screen_clock_tests.rs @@ -0,0 +1,92 @@ +//! Tests for the campaign clock on the prompt: what the prefix reads, +//! how a wrapped input lines up under it, where the cursor lands, and +//! when a turn moves it on. The harness lives in `screen_tests.rs`. + +use super::tests::{done, play_clocked_on, press, typing}; +use super::*; + +/// The moment the harness's clock reports first. +const MORNING: GameTime = GameTime { + day: 1, + hour: 8, + minute: 30, +}; + +/// A later moment, for a turn that moves the clock. +const EVENING: GameTime = GameTime { + day: 2, + hour: 19, + minute: 5, +}; + +/// The prefix [`MORNING`] gives the prompt. +const MORNING_PREFIX: &str = "Day 1, 08:30 > "; + +/// A clock stuck at `time`, however often the screen reads it. +fn fixed(time: GameTime) -> impl FnMut() -> Option { + move || Some(time) +} + +/// A clock that reports `times[0]` on its first read and `times[1]` on +/// every read after that, the way a turn that marks an event moves it. +fn advancing(times: [GameTime; 2]) -> impl FnMut() -> Option { + let mut reads = 0; + move || { + let time = times[reads.min(1)]; + reads += 1; + Some(time) + } +} + +#[test] +fn the_prompt_leads_with_the_day_and_the_time_of_day() { + let played = play_clocked_on(VIEWPORT_HEIGHT, &mut fixed(MORNING), typing("hi")); + + assert_eq!(played.prompt(), "Day 1, 08:30 > hi"); +} + +#[test] +fn a_wrapped_input_lines_up_under_the_timed_prefix() { + // Forty columns behind a fifteen-column prefix leave twenty-five for + // the text, and thirty characters with no space in them wrap there. + let text = "ab".repeat(15); + + let played = play_clocked_on(10, &mut fixed(MORNING), typing(&text)); + + assert_eq!(played.row(8).0, format!("{MORNING_PREFIX}{}", &text[..25])); + assert_eq!( + played.row(9).0, + format!("{}{}", " ".repeat(15), &text[25..]) + ); +} + +#[test] +fn the_cursor_lands_past_the_timed_prefix() { + let mut played = play_clocked_on(VIEWPORT_HEIGHT, &mut fixed(MORNING), typing("hi")); + + played + .terminal + .backend_mut() + .assert_cursor_position((17, 4)); +} + +#[test] +fn a_finished_turn_moves_the_prompt_to_the_new_time() { + let mut steps = typing("hi"); + steps.push(press(Key::Enter)); + steps.push(done()); + + let played = play_clocked_on(VIEWPORT_HEIGHT, &mut advancing([MORNING, EVENING]), steps); + + assert_eq!(played.prompt(), "Day 2, 19:05 >"); +} + +#[test] +fn the_submitted_line_keeps_the_prefix_it_was_typed_under() { + let mut steps = typing("hi"); + steps.push(press(Key::Enter)); + + let played = play_clocked_on(VIEWPORT_HEIGHT, &mut advancing([MORNING, EVENING]), steps); + + assert!(played.transcript().contains("Day 1, 08:30 > hi")); +} diff --git a/src/play/screen_sync_tests.rs b/src/play/screen_sync_tests.rs index db22235..970ce81 100644 --- a/src/play/screen_sync_tests.rs +++ b/src/play/screen_sync_tests.rs @@ -186,6 +186,7 @@ fn screen_with_a_two_row_input() -> Screen { turns: 0, state: DmState::Thinking, rows: VIEWPORT_HEIGHT + 1, + prefix: prompt::prefix(None), } } @@ -225,16 +226,17 @@ fn submitting_repaints_the_shrunk_viewport_inside_its_own_guard() { let mut guard = RecordingGuard::default(); let mut viewport = RecordingViewport::default(); - let text = SessionText { + let mut session = Session { banner: "", slash_context: "", + history: &mut history, + clock: &mut || None, }; screen .submit( &mut terminal, &worker, - &text, - &mut history, + &mut session, &mut guard, &mut viewport, ) diff --git a/src/play/screen_tests.rs b/src/play/screen_tests.rs index 44c8b41..cbc0ec5 100644 --- a/src/play/screen_tests.rs +++ b/src/play/screen_tests.rs @@ -259,6 +259,12 @@ pub(in crate::play) fn play_script(steps: Vec) -> Played { /// that already has the room, and `Played::requested` shows what storied /// asked for. pub(in crate::play) fn play_script_on(height: u16, steps: Vec) -> Played { + play_clocked_on(height, &mut || None, steps) +} + +/// Runs the loop the way [`play_script_on`] does, with `clock` as the +/// campaign's time of day. +pub(in crate::play) fn play_clocked_on(height: u16, clock: Clock<'_>, steps: Vec) -> Played { let mut backend = TestBackend::new(40, height); backend.set_cursor_position(Position::new(0, 0)).unwrap(); let terminal = Terminal::with_options( @@ -280,6 +286,7 @@ pub(in crate::play) fn play_script_on(height: u16, steps: Vec) -> Played { &mut guard, requested, Resized::default(), + clock, steps, ) } @@ -318,6 +325,7 @@ pub(in crate::play) fn play_pinned(height: u16, steps: Vec) -> Played { &mut guard, requested, resized, + &mut || None, steps, ) } @@ -332,6 +340,7 @@ fn run_script>( guard: &mut RecordingGuard, requested: Rc>>, resized: Resized, + clock: Clock<'_>, steps: Vec, ) -> Played { let (input_sender, inputs) = mpsc::channel(); @@ -354,11 +363,12 @@ fn run_script>( &mut terminal, &mut keys, &worker, - &SessionText { + &mut Session { banner: "a banner", slash_context: SLASH_CONTEXT, + history: &mut history, + clock, }, - &mut history, guard, viewport, ) diff --git a/src/play/terminal.rs b/src/play/terminal.rs index 793e110..aa100d7 100644 --- a/src/play/terminal.rs +++ b/src/play/terminal.rs @@ -53,6 +53,12 @@ pub fn run(overrides: &Overrides, layers: &[PathBuf], world_root: &Path) -> Resu let mount = Arc::new(Mount::open(layers)?); let campaign = Campaign::open(world_root)?; let banner = super::banner(&config); + // 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(); 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. @@ -77,11 +83,12 @@ pub fn run(overrides: &Overrides, layers: &[PathBuf], world_root: &Path) -> Resu &mut terminal, &mut CrosstermKeys, &worker, - &screen::SessionText { + &mut screen::Session { banner: &banner, slash_context: &slash_context, + history: &mut history, + clock: &mut clock, }, - &mut history, &mut CrosstermSyncGuard, &mut CrosstermViewport, );