From 6ed934865846aae18700d27ad41796950ddee2dd Mon Sep 17 00:00:00 2001 From: Chris Guidry Date: Tue, 4 Aug 2026 13:33:17 -0400 Subject: [PATCH] Pulse the prompt marker while a turn runs The status row and its flavor phrases are gone. While a turn runs, the prompt's > marker renders as the sparkle pulse, one glyph per three render passes, and returns to > when the turn ends. The substitution happens at render time only: the stored prefix keeps its plain >, so a submitted line echoes into the transcript unchanged, and every sparkle glyph is one column wide, so wrap and cursor math hold. The viewport drops from five rows to three, tail, rule, prompt, and thinking.md, DmState, and the turn-counted phrase cycling go with the row that showed them. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HvctyUUkzw7PcNrjCGG6dF --- src/play/mod.rs | 1 - src/play/prompt.rs | 79 ++++++++++++++++--- src/play/screen.rs | 128 ++++++++++--------------------- src/play/screen_clock_tests.rs | 2 +- src/play/screen_editing_tests.rs | 2 +- src/play/screen_history_tests.rs | 2 + src/play/screen_prompt_tests.rs | 102 ++++++++++++++++++------ src/play/screen_sync_tests.rs | 2 - src/play/screen_tail_tests.rs | 124 ++---------------------------- src/play/screen_tests.rs | 11 +-- src/play/thinking.md | 8 -- src/play/thinking.rs | 100 ------------------------ 12 files changed, 207 insertions(+), 354 deletions(-) delete mode 100644 src/play/thinking.md delete mode 100644 src/play/thinking.rs 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"]); - } -} -- 2.51.2