diff --git a/src/play/mod.rs b/src/play/mod.rs index 81a3edd..d22f4e0 100644 --- a/src/play/mod.rs +++ b/src/play/mod.rs @@ -24,7 +24,6 @@ pub mod screen; pub mod sync; #[cfg(not(coverage))] mod terminal; -mod thinking; mod transcript; pub mod viewport; pub mod worker; diff --git a/src/play/prompt.rs b/src/play/prompt.rs index b13bed0..2b98b1e 100644 --- a/src/play/prompt.rs +++ b/src/play/prompt.rs @@ -1,5 +1,6 @@ //! The prompt widget: how many rows the input takes, its text and cursor -//! position among them, and keeping that cursor on screen. +//! position among them, keeping that cursor on screen, and the sparkle +//! the marker pulses through while a turn runs. use ratatui::layout::{Position, Rect}; use ratatui::text::{Line, Text}; @@ -13,6 +14,12 @@ use super::editor::Editor; /// campaign clock is unknown. pub(super) const PLAYER_MARKER: &str = "> "; +/// The sparkle pulse the marker runs through while a turn runs, one glyph +/// per animation frame, dim to bright and back. Every glyph is one column +/// wide, the same as the `>` it stands in for. Dice glyphs stay out of +/// the pulse, because the dice tool prints one for a real roll. +pub(super) const SPARKS: [char; 8] = ['·', '✧', '✦', '✶', '✷', '✶', '✦', '✧']; + /// 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. @@ -29,6 +36,24 @@ pub(super) fn prefix(time: Option) -> String { } } +/// The sparkle for `tick`, advancing one glyph every 3 ticks and wrapping +/// after the last glyph. +fn spark(tick: usize) -> char { + SPARKS[(tick / 3) % SPARKS.len()] +} + +/// What leads the input's first row while a turn runs: `prefix` with the +/// sparkle for `tick` where the `>` sits at rest. +/// +/// The prefix a session keeps never changes here, so the line the player +/// submits still reaches the transcript under a plain `>`. The sparkle is +/// one column wide, the same as the `>`, so the input wraps in the same +/// columns and the cursor lands in the same place either way. +pub(super) fn busy_prefix(prefix: &str, tick: usize) -> String { + let head = prefix.strip_suffix(PLAYER_MARKER).unwrap_or(prefix); + format!("{head}{} ", spark(tick)) +} + /// 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 @@ -47,10 +72,9 @@ pub(super) fn text_width(width: u16, prefix: &str) -> usize { /// How many rows of `area` the prompt takes: one for each row the input /// wraps to, and never more than the rows left after `fixed_rows`, the -/// tail row, the blank row, and the rule above the prompt. The viewport is -/// 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. +/// tail row and the rule above the prompt. The viewport is 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, 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)) @@ -105,10 +129,10 @@ fn cursor(row: usize, column: usize, prompt: Rect, prefix: &str) -> Position { /// own: an inline viewport pushed down the screen has an `area.y` well /// past 0, so the clamp has to fall against `area`'s far edges, /// `right()` and `bottom()`, not against its width and height. A -/// terminal too short for the tail row, the blank row, the rule, and one -/// prompt row squeezes the prompt's area to zero height, and its `y` then -/// sits one row past the frame; clamping against the whole frame, not the -/// prompt's own area, keeps the cursor on screen regardless. +/// terminal too short for the tail row, the rule, and one prompt row +/// squeezes the prompt's area to zero height, and its `y` then sits one +/// row past the frame; clamping against the whole frame, not the prompt's +/// own area, keeps the cursor on screen regardless. pub(super) fn on_screen(cursor: Position, area: Rect) -> Position { Position::new( cursor.x.min(area.right().saturating_sub(1)), @@ -136,6 +160,43 @@ mod tests { assert_eq!(prefix(None), "> "); } + #[test] + fn a_sparkle_holds_for_three_ticks() { + assert_eq!(spark(0), spark(2)); + } + + #[test] + fn a_sparkle_advances_on_the_third_tick() { + assert_ne!(spark(2), spark(3)); + } + + #[test] + fn the_sparkles_wrap_after_eight_glyphs() { + assert_eq!(spark(0), spark(24)); + } + + #[test] + fn a_busy_prefix_puts_the_sparkle_where_the_marker_sits() { + assert_eq!(busy_prefix("Day 1, 08:30 > ", 0), "Day 1, 08:30 · "); + } + + #[test] + fn a_busy_prefix_keeps_the_bare_marker_bare() { + assert_eq!(busy_prefix(&prefix(None), 3), "✧ "); + } + + #[test] + fn every_sparkle_leaves_the_width_of_the_prefix_alone() { + let prefix = prefix(None); + let ticks = 0..SPARKS.len() * 3; + + assert!( + ticks + .map(|tick| busy_prefix(&prefix, tick).width()) + .all(|width| width == prefix.width()) + ); + } + #[test] fn the_cursor_lands_past_every_column_the_input_prints_in() { let mut input = Editor::default(); diff --git a/src/play/screen.rs b/src/play/screen.rs index 902a23d..7784ff6 100644 --- a/src/play/screen.rs +++ b/src/play/screen.rs @@ -1,8 +1,8 @@ //! The render loop and the prompt: the tail area where the reply's -//! forming block shows, a blank row, the dim rule under that, and the -//! rows the player types on. +//! forming block shows, the dim rule under it, and the rows the player +//! types on. //! -//! The viewport holds those four parts and nothing else. Its height +//! The viewport holds those three parts and nothing else. Its height //! follows the prompt and the forming block: an input that wraps to more //! rows or a block that grows makes the viewport taller and pushes the //! screen up, and either of them shrinking gives the rows back. @@ -22,7 +22,6 @@ use ratatui::Frame; use ratatui::Terminal; use ratatui::backend::Backend; use ratatui::layout::{Constraint, Layout, Size}; -use ratatui::style::Modifier; use ratatui::text::Text; use crate::campaign::GameTime; @@ -32,24 +31,21 @@ use super::history::History; use super::keys::{Key, Keys}; use super::prompt; use super::sync::SyncGuard; -use super::thinking; use super::transcript::{Kind, Transcript, aside, failure, player}; use super::viewport::ViewportRows; use super::worker::{TurnEvent, Worker}; /// The rows the inline viewport takes with a one-row prompt and a one-row -/// tail area: that tail row, the blank row and the rule under it, and that -/// one prompt row. +/// tail area: that tail row, the rule under it, and that one prompt row. pub const VIEWPORT_HEIGHT: u16 = FIXED_ROWS + 1; -/// The viewport rows that are neither the tail area nor the prompt: the -/// blank row that always clears the tail from the status row, the status -/// row itself, and the rule. -const SEPARATOR_ROWS: u16 = 3; +/// The one viewport row that is neither the tail area nor the prompt: the +/// rule between them. +const RULE_ROWS: u16 = 1; /// The viewport rows above the prompt when the tail area holds one row: -/// that row, the blank row, and the rule. -const FIXED_ROWS: u16 = SEPARATOR_ROWS + 1; +/// that row and the rule. +const FIXED_ROWS: u16 = RULE_ROWS + 1; /// How many rows of the screen the transcript keeps, whatever the prompt /// and the forming block do. @@ -72,18 +68,6 @@ const WORKER_GONE: &str = "the storyteller thread is gone; restart storied to co /// Slash commands the player can type. Tab cycles through completions. const SLASH_COMMANDS: &[&str] = &["/context", "/play"]; -/// What the DM is doing right now within a turn, for the status row's -/// phrase. -#[derive(Clone, Copy, PartialEq, Eq)] -enum DmState { - /// Just submitted; nothing has arrived yet. - Thinking, - /// Narration is streaming in. - Storytelling, - /// A tool call is being resolved behind the screen. - Working, -} - /// The input the player is typing, the reply that is still arriving, and /// how much of the screen the viewport holds while both go on. struct Screen { @@ -92,15 +76,9 @@ struct Screen { /// True from the moment the player submits until the turn ends. A turn /// at a time keeps the DM's history in order. busy: bool, - /// Counts render passes, the clock the thinking indicator animates on. + /// Counts render passes, the clock the prompt's marker animates on + /// while a turn runs. tick: usize, - /// Counts submits, so the thinking indicator's flavor phrase changes - /// turn to turn. - turns: usize, - /// What the DM is doing right now, which the status row names. Resets - /// to [`DmState::Thinking`] on submit and follows the turn as it - /// evolves. - state: DmState, /// How many rows the viewport holds now. Every pass works out how /// many the prompt needs and resizes only when the two differ, /// because a resize costs a cursor-position query the terminal can @@ -226,8 +204,6 @@ pub fn play>( transcript: Transcript::default(), busy: false, tick: 0, - turns: 0, - state: DmState::Thinking, rows: VIEWPORT_HEIGHT, prefix: prompt::prefix((session.clock)()), divider: false, @@ -372,22 +348,20 @@ fn floor(rows: u16, waiting: u16, size: Size, busy: bool) -> u16 { } /// How many rows the viewport should hold on a screen of `size`: the tail -/// 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. +/// area's own rows, `forming` of them and never fewer than one, 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, prefix: &str) -> u16 { let cap = cap(size); let tail = forming.clamp(1, cap as usize) as u16; - (SEPARATOR_ROWS + input_rows(input, size, prefix) + tail).min(cap) + (RULE_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. +/// 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, prefix: &str) -> u16 { cap(size) - .saturating_sub(SEPARATOR_ROWS + input_rows(input, size, prefix)) + .saturating_sub(RULE_ROWS + input_rows(input, size, prefix)) .max(1) } @@ -543,7 +517,6 @@ impl Screen { terminal.draw(|frame| render(self, frame))?; guard.end(); self.busy = true; - self.state = DmState::Thinking; self.divider = divider; let _ = worker.inputs.send(input.clone()); session.history.record(&input); @@ -732,12 +705,8 @@ fn events( } }; match event { - TurnEvent::Delta(text) => { - screen.state = DmState::Storytelling; - screen.transcript.delta(&text); - } + TurnEvent::Delta(text) => screen.transcript.delta(&text), TurnEvent::Tool(text) => { - screen.state = DmState::Working; screen.transcript.flush(terminal)?; screen.transcript.insert_text(terminal, text)?; } @@ -798,9 +767,8 @@ fn insert_divider( transcript.insert(terminal, &RULE.repeat(width), aside(), Kind::Aside) } -/// 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. +/// Ends the turn: the prompt works again, its marker goes back to the +/// `>`, and the next turn starts with nothing said. /// /// 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 @@ -812,67 +780,53 @@ fn insert_divider( fn settle(screen: &mut Screen, clock: Clock<'_>) { screen.busy = false; screen.transcript.next_turn(); - screen.turns += 1; screen.prefix = prompt::prefix(clock()); screen.divider = false; } -/// Draws the tail area (the block still forming), a blank row, the status -/// row, the rule, and the prompt's rows, and puts the cursor where the -/// next character will go. -/// -/// The blank row between the tail and the status row is never populated, -/// so the thinking indicator always has one clear line above it. +/// Draws the tail area (the block still forming), the rule, and the +/// prompt's rows, and puts the cursor where the next character will go. fn render(screen: &Screen, frame: &mut Frame) { let area = frame.area(); - let rows = prompt::prompt_rows(&screen.input, area, FIXED_ROWS, &screen.prefix); - let [tail, _blank, status, rule, prompt_area] = Layout::vertical([ + let lead = lead(screen); + let rows = prompt::prompt_rows(&screen.input, area, FIXED_ROWS, &lead); + let [tail, rule, prompt_area] = Layout::vertical([ Constraint::Min(0), Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), Constraint::Length(rows), ]) .areas(area); frame.render_widget(tail_widget(screen, tail.width), tail); - frame.render_widget(status_widget(screen), status); frame.render_widget( Text::styled(RULE.repeat(rule.width as usize), aside()), rule, ); - let (text, cursor) = prompt::prompt_widget(&screen.input, prompt_area, &screen.prefix); + let (text, cursor) = prompt::prompt_widget(&screen.input, prompt_area, &lead); frame.render_widget(text, prompt_area); frame.set_cursor_position(prompt::on_screen(cursor, area)); } +/// What leads the prompt's first row: the prefix the session keeps while +/// no turn runs, and that prefix with a sparkle in place of the `>` while +/// one does. +/// +/// The sparkle is how the player sees that the DM is at work. It pulses +/// on the render pass count, and the prefix itself stays as it is, so the +/// line the player submits reaches the transcript under a plain `>`. +fn lead(screen: &Screen) -> String { + if screen.busy { + prompt::busy_prefix(&screen.prefix, screen.tick) + } else { + screen.prefix.clone() + } +} + /// What the tail area shows: the rows of the block the DM is still /// writing, rendered at `width`. fn tail_widget(screen: &Screen, width: u16) -> Text<'static> { Text::from(screen.transcript.forming_rows(width)) } -/// What the status row shows: the thinking indicator for the whole time a -/// turn runs, and nothing (a blank separator row) the rest of the time. -/// -/// The phrase beside the sparkle names what the DM is doing right now — -/// narrating, resolving a tool call, or still quiet at the top of the -/// turn — so it reads as "the DM is thinking or responding" rather than -/// as a static label. -fn status_widget(screen: &Screen) -> Text<'static> { - if !screen.busy { - return Text::raw(""); - } - let phrase = match screen.state { - DmState::Thinking => thinking::thinking_phrase(screen.turns), - DmState::Storytelling => thinking::STORYTELLING, - DmState::Working => thinking::BEHIND_THE_SCREEN, - }; - Text::styled( - thinking::line(screen.tick, phrase), - aside().add_modifier(Modifier::ITALIC), - ) -} - #[cfg(test)] #[path = "screen_tests.rs"] pub(in crate::play) mod tests; diff --git a/src/play/screen_clock_tests.rs b/src/play/screen_clock_tests.rs index 444bb1f..f0511ea 100644 --- a/src/play/screen_clock_tests.rs +++ b/src/play/screen_clock_tests.rs @@ -67,7 +67,7 @@ fn the_cursor_lands_past_the_timed_prefix() { played .terminal .backend_mut() - .assert_cursor_position((17, 4)); + .assert_cursor_position((17, 2)); } #[test] diff --git a/src/play/screen_editing_tests.rs b/src/play/screen_editing_tests.rs index 2e3c32d..e19ede4 100644 --- a/src/play/screen_editing_tests.rs +++ b/src/play/screen_editing_tests.rs @@ -163,7 +163,7 @@ fn the_cursor_follows_a_move_left() { let mut played = play_script(steps); - played.terminal.backend_mut().assert_cursor_position((3, 4)); + played.terminal.backend_mut().assert_cursor_position((3, 2)); } #[test] diff --git a/src/play/screen_history_tests.rs b/src/play/screen_history_tests.rs index 78e3afe..287ce2f 100644 --- a/src/play/screen_history_tests.rs +++ b/src/play/screen_history_tests.rs @@ -8,6 +8,7 @@ use super::*; fn up_recalls_the_previous_prompt() { let mut steps = typing("hi"); steps.push(press(Key::Enter)); + steps.push(done()); steps.push(press(Key::Up)); let played = play_script(steps); @@ -22,6 +23,7 @@ fn up_twice_reaches_the_older_entry() { steps.push(done()); steps.extend(typing("there")); steps.push(press(Key::Enter)); + steps.push(done()); steps.push(press(Key::Up)); steps.push(press(Key::Up)); diff --git a/src/play/screen_prompt_tests.rs b/src/play/screen_prompt_tests.rs index c42a947..5eb7518 100644 --- a/src/play/screen_prompt_tests.rs +++ b/src/play/screen_prompt_tests.rs @@ -1,7 +1,7 @@ //! Tests for the prompt area: the dim rule above the prompt, the prompt's -//! own rows and the cursor among them, and the viewport growing and -//! shrinking with the prompt's row count. The harness lives in -//! `screen_tests.rs`. +//! own rows and the cursor among them, the marker that pulses through the +//! sparkles while a turn runs, and the viewport growing and shrinking with +//! the prompt's row count. The harness lives in `screen_tests.rs`. use std::cell::RefCell; use std::rc::Rc; @@ -47,22 +47,77 @@ fn paste_rows(rows: usize) -> Step { } #[test] -fn a_blank_row_sits_between_the_tail_and_the_rule() { +fn a_dim_rule_sits_between_the_tail_and_the_prompt() { let played = play_script(typing("hi")); let (text, dim) = played.row(1); - assert_eq!(text, ""); - assert!(!dim); + assert_eq!(text, "─".repeat(40)); + assert!(dim); + assert_eq!(played.prompt(), "> hi"); +} + +/// Whether `c` is one of the sparkles the marker pulses through. +fn is_sparkle(c: char) -> bool { + prompt::SPARKS.contains(&c) +} + +/// The first character of the prompt after a submit and `passes` further +/// passes of the render loop, with the turn still running throughout. +/// Left is the harmless key: it moves the cursor of an empty input +/// nowhere and costs one pass. +fn marker_after(passes: usize) -> char { + let mut steps = typing("hi"); + steps.push(press(Key::Enter)); + steps.extend((0..passes).map(|_| press(Key::Left))); + + let played = play_script(steps); + + played.prompt().chars().next().unwrap() } #[test] -fn a_dim_rule_sits_beneath_the_blank_and_status_rows() { - let played = play_script(typing("hi")); +fn a_sparkle_takes_the_marker_while_a_turn_runs() { + assert!(is_sparkle(marker_after(0))); +} - let (text, dim) = played.row(3); - assert_eq!(text, "─".repeat(40)); - assert!(dim); - assert_eq!(played.prompt(), "> hi"); +#[test] +fn the_marker_reaches_the_next_sparkle_three_passes_on() { + assert_ne!(marker_after(0), marker_after(3)); + assert!(is_sparkle(marker_after(3))); +} + +#[test] +fn the_marker_is_the_angle_bracket_again_once_the_turn_ends() { + let mut steps = typing("hi"); + steps.push(press(Key::Enter)); + steps.push(done()); + + let played = play_script(steps); + + assert_eq!(played.prompt(), ">"); +} + +#[test] +fn the_input_lines_up_behind_the_sparkle_the_way_it_does_behind_the_marker() { + let mut steps = typing("hi"); + steps.push(press(Key::Enter)); + steps.extend(typing("more")); + + let mut played = play_script(steps); + + assert_eq!(played.prompt().chars().skip(1).collect::(), " more"); + played.terminal.backend_mut().assert_cursor_position((6, 2)); +} + +#[test] +fn no_sparkle_reaches_the_transcript() { + let mut steps = typing("hi"); + steps.push(press(Key::Enter)); + + let played = play_script(steps); + + assert!(played.transcript().contains("> hi")); + assert!(!played.transcript().chars().any(is_sparkle)); } #[test] @@ -78,7 +133,7 @@ fn an_input_that_wraps_to_two_rows_asks_for_one_taller_viewport() { // columns, and every keystroke after the wrap stays on two rows. let played = play_script_on(12, typing(&"ab".repeat(20))); - assert_eq!(played.requested(), vec![6]); + assert_eq!(played.requested(), vec![4]); } #[test] @@ -93,7 +148,7 @@ fn deleting_back_to_one_row_gives_the_row_back_between_turns() { // coming to fill the row the prompt no longer needs. The viewport // gives it back now rather than holding it blank until the next // reply. - assert_eq!(played.requested(), vec![6, 5]); + assert_eq!(played.requested(), vec![4, 3]); } #[test] @@ -108,17 +163,18 @@ fn deleting_back_to_one_row_keeps_the_taller_viewport_while_a_turn_runs() { // The reply is still arriving, so the rows it writes are what the // row the prompt gave up waits for. - assert_eq!(played.requested(), vec![6]); + assert_eq!(played.requested(), vec![4]); } #[test] fn submitting_asks_for_the_base_viewport_again() { let mut steps = typing(&"ab".repeat(20)); steps.push(press(Key::Enter)); + steps.push(done()); let played = play_script_on(12, steps); - assert_eq!(played.requested(), vec![6, 5]); + assert_eq!(played.requested(), vec![4, 3]); assert_eq!(played.prompt(), ">"); } @@ -129,16 +185,18 @@ fn a_forming_block_of_two_rows_asks_for_one_taller_viewport() { let played = play_script_on(12, steps); - assert_eq!(played.requested(), vec![6]); + assert_eq!(played.requested(), vec![4]); } #[test] fn a_block_that_spilled_asks_for_no_more_than_the_cap() { let steps = vec![Step::Turn(TurnEvent::Delta(TALL_BLOCK.to_string()))]; - let played = play_script_on(12, steps); + // Ten rows leave the viewport five, and the four-row block spills its + // top row to fit the three the tail area has. + let played = play_script_on(10, steps); - assert_eq!(played.requested(), vec![7]); + assert_eq!(played.requested(), vec![5]); } #[test] @@ -147,7 +205,7 @@ fn a_finished_reply_gives_the_tail_rows_back() { let played = play_script_on(12, steps); - assert_eq!(played.requested(), vec![7, 5]); + assert_eq!(played.requested(), vec![6, 3]); } #[test] @@ -163,8 +221,8 @@ fn an_absurdly_tall_input_stops_at_the_cap() { fn a_prompt_taller_than_its_room_shows_the_rows_that_end_at_the_cursor() { let played = play_script_on(10, vec![paste_rows(10)]); - // Six rows of room, ten rows of input, cursor on the last: the - // prompt shows the six rows that end at the cursor, and the marker + // Eight rows of room, ten rows of input, cursor on the last: the + // prompt shows the eight rows that end at the cursor, and the marker // and earlier rows are off the top with them. assert_eq!(played.row(4).0, " row 4"); assert_eq!(played.prompt(), " row 9"); diff --git a/src/play/screen_sync_tests.rs b/src/play/screen_sync_tests.rs index 41bb6a0..2c7f6cd 100644 --- a/src/play/screen_sync_tests.rs +++ b/src/play/screen_sync_tests.rs @@ -183,8 +183,6 @@ fn screen_with_a_two_row_input() -> Screen { transcript: Transcript::default(), busy: false, tick: 0, - turns: 0, - state: DmState::Thinking, rows: VIEWPORT_HEIGHT + 1, prefix: prompt::prefix(None), divider: false, diff --git a/src/play/screen_tail_tests.rs b/src/play/screen_tail_tests.rs index d6b2906..cf17834 100644 --- a/src/play/screen_tail_tests.rs +++ b/src/play/screen_tail_tests.rs @@ -1,14 +1,11 @@ //! Tests for the tail area of the viewport: the rows of the block the -//! reply is still forming, and the thinking indicator that takes that -//! area while a turn runs with nothing written yet. -//! `transcript_tests.rs` covers the rows that reach the scrollback, and -//! `screen_tests.rs` covers the prompt and holds the harness all three -//! play their scripts through. - -use ratatui::text::Text; +//! reply is still forming. `transcript_tests.rs` covers the rows that +//! reach the scrollback, `screen_prompt_tests.rs` covers the marker that +//! pulses while a turn runs, and `screen_tests.rs` covers the prompt and +//! holds the harness they all play their scripts through. use super::tests::{ - CRAMPED, Step, TALL_BLOCK, TALL_ROWS, done, play_script, play_script_on, press, typing, + CRAMPED, Step, TALL_BLOCK, TALL_ROWS, play_script, play_script_on, press, typing, }; use super::*; @@ -72,37 +69,8 @@ fn a_finished_reply_empties_the_tail() { assert_eq!(played.prompt(), ">"); } -/// Whether `c` is one of the sparkle glyphs the thinking indicator -/// pulses through. -fn is_sparkle(c: char) -> bool { - thinking::SPARKS.contains(&c) -} - -#[test] -fn a_submit_with_no_delta_yet_shows_the_thinking_indicator() { - let mut steps = typing("hi"); - steps.push(press(Key::Enter)); - - let played = play_script(steps); - - assert!(played.viewport().chars().any(is_sparkle)); - assert!(played.viewport().contains(thinking::thinking_phrase(0))); -} - -#[test] -fn the_tail_shows_the_narration_while_the_indicator_stays() { - let mut steps = typing("hi"); - steps.push(press(Key::Enter)); - steps.push(Step::Turn(TurnEvent::Delta("You wake.".to_string()))); - - let played = play_script(steps); - - assert_eq!(played.tail(), "You wake."); - assert!(played.viewport().chars().any(is_sparkle)); -} - #[test] -fn the_indicator_holds_for_the_whole_turn() { +fn the_tail_shows_the_narration_of_a_turn_that_is_still_running() { let mut steps = typing("hi"); steps.push(press(Key::Enter)); steps.push(Step::Turn(TurnEvent::Delta("You wake. ".to_string()))); @@ -111,84 +79,4 @@ fn the_indicator_holds_for_the_whole_turn() { let played = play_script(steps); assert_eq!(played.tail(), "You wake. You look around."); - assert!(played.viewport().chars().any(is_sparkle)); -} - -#[test] -fn the_indicator_stays_through_a_tool_line() { - let mut steps = typing("hi"); - steps.push(press(Key::Enter)); - steps.push(Step::Turn(TurnEvent::Delta("Rolling.".to_string()))); - steps.push(Step::Turn(TurnEvent::Tool(Text::raw("a tool line")))); - - let played = play_script(steps); - - assert!(played.viewport().chars().any(is_sparkle)); - assert!(played.transcript().contains("Rolling.")); -} - -#[test] -fn the_indicator_names_storytelling_while_narrating() { - let mut steps = typing("hi"); - steps.push(press(Key::Enter)); - steps.push(Step::Turn(TurnEvent::Delta("You wake.".to_string()))); - - let played = play_script(steps); - - assert!(played.viewport().contains(thinking::STORYTELLING)); -} - -#[test] -fn the_indicator_names_looking_behind_the_screen_while_calling_a_tool() { - let mut steps = typing("hi"); - steps.push(press(Key::Enter)); - steps.push(Step::Turn(TurnEvent::Tool(Text::raw("a tool line")))); - - let played = play_script(steps); - - assert!(played.viewport().contains(thinking::BEHIND_THE_SCREEN)); -} - -#[test] -fn the_thinking_indicator_is_italic() { - let mut steps = typing("hi"); - steps.push(press(Key::Enter)); - - let played = play_script(steps); - let buffer = played.terminal.backend().buffer(); - - // The status row is the third viewport row (tail, blank, status, - // rule, prompt) on the base harness. - let status: String = (0..buffer.area.width) - .map(|c| buffer[(c, 2)].symbol()) - .collect(); - let italic = (0..buffer.area.width) - .map(|c| buffer[(c, 2)].modifier) - .filter(|modifier| modifier.contains(Modifier::ITALIC)) - .count(); - assert!(status.contains(thinking::thinking_phrase(0))); - assert!(italic > 0); -} - -#[test] -fn a_second_turn_shows_the_next_phrase() { - let mut steps = typing("hi"); - steps.push(press(Key::Enter)); - steps.push(done()); - steps.extend(typing("again")); - steps.push(press(Key::Enter)); - - let played = play_script(steps); - - assert!(played.viewport().contains(thinking::thinking_phrase(1))); -} - -#[test] -fn the_thinking_indicator_never_reaches_the_transcript() { - let mut steps = typing("hi"); - steps.push(press(Key::Enter)); - - let played = play_script(steps); - - assert!(!played.transcript().contains(thinking::thinking_phrase(0))); } diff --git a/src/play/screen_tests.rs b/src/play/screen_tests.rs index b18fa7a..56abc8e 100644 --- a/src/play/screen_tests.rs +++ b/src/play/screen_tests.rs @@ -49,10 +49,10 @@ pub(in crate::play) const SLASH_CONTEXT: &str = "the DM's full system prompt"; /// A screen tall enough to hold a six-row block in the tail area: the /// transcript keeps five rows of it, which leaves the tail eleven. -pub(in crate::play) const ROOMY: u16 = 20; +pub(in crate::play) const ROOMY: u16 = 18; /// A screen whose tail area holds two rows. -pub(in crate::play) const CRAMPED: u16 = 11; +pub(in crate::play) const CRAMPED: u16 = 9; /// A paragraph that wraps to the four rows of [`TALL_ROWS`] at the /// harness's forty columns. @@ -128,8 +128,8 @@ impl Played { buffer_text(self.terminal.backend().scrollback()) } - /// The rows of the viewport itself: the tail row, the blank row, the - /// rule, and the prompt. + /// The rows of the viewport itself: the tail row, the rule, and the + /// prompt. pub(super) fn viewport(&self) -> String { buffer_text(self.terminal.backend().buffer()) } @@ -475,7 +475,7 @@ fn typing_shows_on_the_prompt() { fn the_cursor_sits_after_the_typed_text() { let mut played = play_script(typing("hi")); - played.terminal.backend_mut().assert_cursor_position((4, 4)); + played.terminal.backend_mut().assert_cursor_position((4, 2)); } #[test] @@ -512,6 +512,7 @@ fn submitting_puts_the_line_in_the_transcript() { fn submitting_clears_the_prompt() { let mut steps = typing("hi"); steps.push(press(Key::Enter)); + steps.push(done()); let played = play_script(steps); diff --git a/src/play/thinking.md b/src/play/thinking.md deleted file mode 100644 index 50f17e3..0000000 --- a/src/play/thinking.md +++ /dev/null @@ -1,8 +0,0 @@ -the DM peers behind the screen -dice clatter behind the screen -the DM consults the ancient tomes -the DM strokes their chin -somewhere, a goblin sharpens its knife -the DM shuffles their notes -the fates confer -the DM grins slowly diff --git a/src/play/thinking.rs b/src/play/thinking.rs deleted file mode 100644 index 277a426..0000000 --- a/src/play/thinking.rs +++ /dev/null @@ -1,100 +0,0 @@ -//! The thinking indicator: what the status row shows while a turn runs. -//! -//! The sparkle pulses, and the phrase beside it follows what the DM is -//! doing right now within the turn: a flavor line at the top of a turn -//! before anything has arrived, then `storytelling` while narration -//! streams, then `looking behind the screen` while tool calls resolve. - -/// The sparkle pulse, one glyph per animation frame, dim to bright and -/// back. Dice glyphs are reserved for real rolls when the dice tool -/// arrives. -pub const SPARKS: [char; 8] = ['·', '✧', '✦', '✶', '✷', '✶', '✦', '✧']; - -/// The phrase shown while the DM is narrating. -pub const STORYTELLING: &str = "storytelling"; - -/// The phrase shown while a tool call is being resolved. -pub const BEHIND_THE_SCREEN: &str = "looking behind the screen"; - -/// The flavor phrases, one per line of `thinking.md`. -const PHRASES: &str = include_str!("thinking.md"); - -/// Splits `text` into its non-blank lines. -fn parse(text: &str) -> Vec<&str> { - text.lines().filter(|line| !line.is_empty()).collect() -} - -/// The sparkle for `tick`, advancing one glyph every 3 ticks and wrapping -/// after the last glyph. -pub fn frame(tick: usize) -> char { - SPARKS[(tick / 3) % SPARKS.len()] -} - -/// The flavor phrase for the `turn`-th turn of the session, cycling -/// through `thinking.md` in file order. It shows while the DM is quiet at -/// the top of a turn, before any word or tool call has arrived. -pub fn thinking_phrase(turn: usize) -> &'static str { - let phrases = parse(PHRASES); - phrases[turn % phrases.len()] -} - -/// The composed indicator: a sparkle, a phrase naming what the DM is -/// doing, and three literal dots. -pub fn line(tick: usize, phrase: &str) -> String { - format!("{} {phrase}...", frame(tick)) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn the_frame_holds_for_three_ticks() { - assert_eq!(frame(0), frame(2)); - } - - #[test] - fn the_frame_advances_on_the_third_tick() { - assert_ne!(frame(2), frame(3)); - } - - #[test] - fn the_frame_wraps_after_eight_glyphs() { - assert_eq!(frame(0), frame(24)); - } - - #[test] - fn the_thinking_phrase_starts_with_the_first_line_of_the_file() { - assert_eq!(thinking_phrase(0), "the DM peers behind the screen"); - } - - #[test] - fn the_thinking_phrase_advances_with_the_turn() { - assert_eq!(thinking_phrase(1), "dice clatter behind the screen"); - } - - #[test] - fn the_thinking_phrase_wraps_after_eight_turns() { - assert_eq!(thinking_phrase(0), thinking_phrase(8)); - } - - #[test] - fn the_line_contains_the_sparkle() { - assert!(line(0, "x").contains('·')); - } - - #[test] - fn the_line_contains_the_phrase() { - assert!(line(0, STORYTELLING).contains(STORYTELLING)); - } - - #[test] - fn the_line_ends_with_three_dots() { - assert!(line(0, "x").ends_with("x...")); - } - - #[test] - fn parsing_ignores_blank_lines() { - assert_eq!(parse("one\n\ntwo\n"), vec!["one", "two"]); - } -}