//! The prompt widget: how many rows the input takes, its text and cursor //! 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}; use unicode_width::UnicodeWidthStr; use crate::campaign::GameTime; use super::editor::Editor; /// The marker the prefix ends with, and the whole prefix while the /// 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. /// /// 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(), } } /// 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 /// continuation would be hard to tell from the text the player typed. 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 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 /// wraps to, and never more than the rows left after `fixed_rows`, the /// 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)) } /// 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 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, 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)| { let lead = if index == 0 { prefix } else { &continuation }; Line::raw(format!("{lead}{text}")) }) .collect(); ( Text::from(lines), cursor(row.saturating_sub(first), column, area, prefix), ) } /// Where the cursor goes: at screen `column` of the prompt's `row`th /// 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, 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) } /// Clamps `cursor` to the last row and column of `area`. /// /// `cursor` is a position in the whole screen's coordinates, not `area`'s /// 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 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)), cursor.y.min(area.bottom().saturating_sub(1)), ) } #[cfg(test)] 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 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(); input.set_text("日本"); let area = Rect::new(0, 0, 20, 2); 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, PLAYER_MARKER); assert_eq!( text, Text::from(vec![Line::raw("> 日本"), Line::raw(" 語")]) ); } #[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); assert_eq!(on_screen(Position::new(5, 23), area), Position::new(5, 23)); } #[test] fn a_cursor_past_the_bottom_of_a_viewport_pushed_down_the_screen_clamps_to_it() { let area = Rect::new(0, 20, 80, 4); assert_eq!(on_screen(Position::new(5, 99), area), Position::new(5, 23)); } #[test] fn a_cursor_past_the_right_edge_of_an_area_with_a_nonzero_x_clamps_to_it() { let area = Rect::new(10, 0, 5, 4); assert_eq!(on_screen(Position::new(99, 0), area), Position::new(14, 0)); } }