From 7007b214606f0b18e975b053de6a42b046cec5c7 Mon Sep 17 00:00:00 2001 From: Chris Guidry Date: Mon, 3 Aug 2026 20:45:46 -0400 Subject: [PATCH] Show the campaign clock on the player's prompt line The prompt reads `Day 1, 08:30 > ` when the campaign clock is known and falls back to the bare marker when it is not. The screen reads the clock through a provider closure at session start and after each turn-ending event, never per keystroke, because only a turn moves the clock. Wrapped input rows pad to the prefix's width so the whole input still wraps at one column, and the submitted line echoes into the scrollback under the prefix it was typed under. The play parameters fold into a Session struct, which keeps the argument count within clippy's limit. The on-disk transcript keeps its own player> format. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HvctyUUkzw7PcNrjCGG6dF --- src/play/prompt.rs | 126 ++++++++++++++++++++++++--------- src/play/screen.rs | 114 +++++++++++++++++++---------- src/play/screen_clock_tests.rs | 92 ++++++++++++++++++++++++ src/play/screen_sync_tests.rs | 8 ++- src/play/screen_tests.rs | 14 +++- src/play/terminal.rs | 11 ++- 6 files changed, 288 insertions(+), 77 deletions(-) create mode 100644 src/play/screen_clock_tests.rs 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, ); -- 2.51.2