diff --git a/src/play/screen.rs b/src/play/screen.rs index ea728e2..f698c88 100644 --- a/src/play/screen.rs +++ b/src/play/screen.rs @@ -67,6 +67,11 @@ struct Screen { /// Counts submits, so the thinking indicator's phrase changes turn to /// turn. turns: usize, + /// True when the most recent transcript insert was a tool line. + /// Consecutive tool lines pack together with no blank row between + /// them; the next non-tool insert reads this to put one blank row + /// back before whatever comes next. + last_was_tool: bool, } /// Runs the game until the player quits. @@ -91,8 +96,9 @@ pub fn play( busy: false, tick: 0, turns: 0, + last_was_tool: false, }; - insert(terminal, banner, aside())?; + insert(terminal, &mut screen, banner, aside())?; loop { screen.tick = screen.tick.wrapping_add(1); drain(&mut screen, terminal, turns)?; @@ -156,7 +162,8 @@ fn submit( return Ok(()); } let input = screen.input.take(); - insert(terminal, &format!("{PLAYER_MARKER}{input}"), player())?; + let line = format!("{PLAYER_MARKER}{input}"); + insert(terminal, screen, &line, player())?; screen.busy = true; let _ = inputs.send(input.clone()); history.record(&input); @@ -182,7 +189,7 @@ fn drain( Err(TryRecvError::Empty) => return Ok(()), Err(TryRecvError::Disconnected) => { if screen.busy { - insert(terminal, WORKER_GONE, failure())?; + insert(terminal, screen, WORKER_GONE, failure())?; settle(screen); } return Ok(()); @@ -192,18 +199,19 @@ fn drain( TurnEvent::Delta(text) => screen.live.push_str(&text), TurnEvent::Tool(text) => { flush(screen, terminal)?; - insert_text(terminal, text)?; + insert_text(terminal, screen, text)?; } TurnEvent::Done(_) => finish(screen, terminal)?, TurnEvent::Cancelled(_) => { - if !screen.live.is_empty() { - insert(terminal, &screen.live, reply())?; + let narration = screen.live.trim().to_string(); + if !narration.is_empty() { + insert(terminal, screen, &narration, reply())?; } - insert(terminal, INTERRUPTED, aside())?; + insert(terminal, screen, INTERRUPTED, aside())?; settle(screen); } TurnEvent::Failed(text) => { - insert(terminal, &text, failure())?; + insert(terminal, screen, &text, failure())?; settle(screen); } } @@ -220,8 +228,9 @@ fn drain( /// narrating first would otherwise leave a blank row before the tool /// line. fn flush(screen: &mut Screen, terminal: &mut Terminal) -> Result<(), B::Error> { - if !screen.live.trim().is_empty() { - insert(terminal, &screen.live, reply())?; + let narration = screen.live.trim().to_string(); + if !narration.is_empty() { + insert(terminal, screen, &narration, reply())?; } screen.live.clear(); Ok(()) @@ -234,10 +243,11 @@ fn flush(screen: &mut Screen, terminal: &mut Terminal) -> Result< /// nothing flushes, and the live region is the only place that has seen /// every delta of the turn. fn finish(screen: &mut Screen, terminal: &mut Terminal) -> Result<(), B::Error> { - if screen.live.trim().is_empty() { - insert(terminal, NOTHING_SAID, aside())?; + let narration = screen.live.trim().to_string(); + if narration.is_empty() { + insert(terminal, screen, NOTHING_SAID, aside())?; } else { - insert(terminal, &screen.live, reply())?; + insert(terminal, screen, &narration, reply())?; } settle(screen); Ok(()) @@ -253,32 +263,41 @@ fn settle(screen: &mut Screen) { } /// Wraps `text` to the terminal width and inserts it above the viewport, -/// with one blank row after it. +/// with one blank row after it. If the previous insert was a tool line, +/// this puts one blank row in first, so a tool block stays visually apart +/// from the prose, prompt, or aside that follows it. fn insert( terminal: &mut Terminal, + screen: &mut Screen, text: &str, style: Style, ) -> Result<(), B::Error> { + let spacer = u16::from(screen.last_was_tool); + screen.last_was_tool = false; let width = terminal.size()?.width; let rows = wrap(text, width as usize); - let height = rows.len() as u16 + 1; + let height = spacer + rows.len() as u16 + 1; terminal.insert_before(height, |buffer| { for (row, line) in rows.iter().enumerate() { - buffer.set_string(0, row as u16, line, style); + buffer.set_string(0, row as u16 + spacer, line, style); } }) } /// Wraps `text` to the terminal width, preserving each span's style, and -/// inserts it above the viewport with one blank row after it. A tool -/// composes its own styling; this adds none of its own. +/// inserts it above the viewport. A tool composes its own styling; this +/// adds none of its own. Consecutive tool lines pack together with no +/// blank row between them; `insert` puts the blank row back once +/// something else follows. fn insert_text( terminal: &mut Terminal, + screen: &mut Screen, text: Text<'static>, ) -> Result<(), B::Error> { + screen.last_was_tool = true; let width = terminal.size()?.width; let rows = wrap_spans(text, width as usize); - let height = rows.len() as u16 + 1; + let height = rows.len() as u16; terminal.insert_before(height, |buffer| { for (row, line) in rows.iter().enumerate() { buffer.set_line(0, row as u16, line, width); diff --git a/src/play/screen_turn_tests.rs b/src/play/screen_turn_tests.rs index 87f8d31..fd33f2d 100644 --- a/src/play/screen_turn_tests.rs +++ b/src/play/screen_turn_tests.rs @@ -163,6 +163,116 @@ fn a_tool_event_with_no_narration_inserts_only_the_tool_line() { assert_eq!(tool - banner, 2); } +#[test] +fn a_round_of_tool_calls_packs_into_one_block() { + let steps = vec![ + Step::Turn(TurnEvent::Delta( + "Let's try the trickier notations!".to_string(), + )), + Step::Turn(TurnEvent::Tool(Text::raw("roll a"))), + Step::Turn(TurnEvent::Tool(Text::raw("roll b"))), + Step::Turn(TurnEvent::Tool(Text::raw("roll c"))), + Step::Turn(TurnEvent::Tool(Text::raw("roll d"))), + Step::Turn(TurnEvent::Delta("All exotic rolls work great!".to_string())), + Step::Turn(TurnEvent::Done("All exotic rolls work great!".to_string())), + ]; + + let played = play_script(steps); + + let transcript = played.transcript(); + let lines: Vec<&str> = transcript.lines().collect(); + let opening = lines + .iter() + .position(|line| *line == "Let's try the trickier notations!") + .unwrap(); + let a = lines.iter().position(|line| *line == "roll a").unwrap(); + let b = lines.iter().position(|line| *line == "roll b").unwrap(); + let c = lines.iter().position(|line| *line == "roll c").unwrap(); + let d = lines.iter().position(|line| *line == "roll d").unwrap(); + let closing = lines + .iter() + .position(|line| *line == "All exotic rolls work great!") + .unwrap(); + + // One blank row opens the block, none separate the tool lines inside + // it, and one blank row closes it before the next narration. + assert_eq!(a - opening, 2); + assert_eq!(b - a, 1); + assert_eq!(c - b, 1); + assert_eq!(d - c, 1); + assert_eq!(closing - d, 2); +} + +#[test] +fn narration_ending_in_blank_lines_flushes_without_extra_blank_rows() { + let steps = vec![ + Step::Turn(TurnEvent::Delta("You wake.\n\n".to_string())), + Step::Turn(TurnEvent::Tool(Text::raw("a tool line"))), + ]; + + let played = play_script(steps); + + let transcript = played.transcript(); + let lines: Vec<&str> = transcript.lines().collect(); + let narration = lines.iter().position(|line| *line == "You wake.").unwrap(); + let tool = lines + .iter() + .position(|line| *line == "a tool line") + .unwrap(); + assert_eq!(tool - narration, 2); +} + +#[test] +fn a_finished_reply_ending_in_blank_lines_has_no_extra_blank_rows() { + let steps = vec![ + Step::Turn(TurnEvent::Delta("You wake.\n\n".to_string())), + Step::Turn(TurnEvent::Done("You wake.\n\n".to_string())), + ]; + + let played = play_script(steps); + + let transcript = played.transcript(); + let lines: Vec<&str> = transcript.lines().collect(); + let banner = lines.iter().position(|line| *line == "a banner").unwrap(); + let narration = lines.iter().position(|line| *line == "You wake.").unwrap(); + assert_eq!(narration - banner, 2); +} + +#[test] +fn a_whitespace_only_reply_says_the_dm_says_nothing() { + let steps = vec![Step::Turn(TurnEvent::Done(" \n".to_string()))]; + + let played = play_script(steps); + + assert!(played.transcript().contains("(the DM says nothing)")); +} + +#[test] +fn the_prompt_after_a_tool_ended_turn_gets_one_blank_row() { + let mut steps = vec![Step::Turn(TurnEvent::Tool(Text::raw("a tool line")))]; + steps.push(Step::Turn(TurnEvent::Delta("ok".to_string()))); + steps.push(done()); + steps.extend(typing("again")); + steps.push(press(Key::Enter)); + + let played = play_script(steps); + + let transcript = played.transcript(); + let lines: Vec<&str> = transcript.lines().collect(); + let tool = lines + .iter() + .position(|line| *line == "a tool line") + .unwrap(); + let narration = lines.iter().position(|line| *line == "ok").unwrap(); + let prompt = lines.iter().position(|line| *line == "> again").unwrap(); + + // One blank row separates the tool line from the narration that ends + // the turn, and the usual single blank row separates it from the + // player's next prompt. + assert_eq!(narration - tool, 2); + assert_eq!(prompt - narration, 2); +} + #[test] fn a_secret_roll_between_narrated_rounds_keeps_every_word_in_the_transcript() { let steps = vec![