//! Render-side helpers for the input panel //! (ADR-0022 stage 2 — token-class colouring of the live //! input field with cursor injection). //! //! The functions here are pure: given an input string, a //! cursor byte position, and a theme, they return a sequence //! of `StyledRun`s describing each contiguous span with its //! ratatui style. `ui::render_input_panel` converts these to //! `Span<'_>`s at render time. //! //! Cursor handling: //! - Cursor inside a token splits that token's run into //! before/under/after, with `under` carrying the token's //! colour plus `Modifier::REVERSED`. //! - Cursor on a whitespace gap between tokens splits the //! gap the same way. //! - Cursor at end-of-input is represented as a trailing //! run with empty byte range; the renderer treats that as //! "inverted space". //! //! Per ADR-0022 §2/§3, this is the silent always-on layer. //! The error overlay (stage 4) and hint panel (stage 5) //! compose with these runs without fighting them. use ratatui::style::{Modifier, Style}; use crate::dsl::lexer::lex; use crate::theme::Theme; /// A run of text with its byte range in the source and the /// ratatui style it should render with. The text itself is /// not stored — callers slice `source[byte_range.0..byte_range.1]`. /// /// An empty byte range (`(n, n)`) represents the end-of-input /// cursor and is rendered as an inverted space. #[derive(Debug, Clone, PartialEq, Eq)] pub struct StyledRun { pub byte_range: (usize, usize), pub style: Style, } impl StyledRun { /// The text this run covers in `source`. Empty for the /// end-of-input cursor sentinel. #[must_use] pub fn text<'a>(&self, source: &'a str) -> &'a str { &source[self.byte_range.0..self.byte_range.1] } } /// Build the run sequence for the input panel. /// /// Lexes `input`, assigns each token its `theme.token_color`, /// preserves whitespace gaps as `theme.fg` runs, then injects /// the cursor at `cursor_byte` (clamped to `input.len()`). #[must_use] pub fn render_input_runs(input: &str, cursor_byte: usize, theme: &Theme) -> Vec { let mut runs = lex_to_runs(input, theme); inject_cursor(&mut runs, input, cursor_byte, theme); runs } /// Cursor-less variant: tokenises `input` into styled runs /// covering the full byte range, with no inverted cursor. /// Used by the echo-line renderer (ADR-0022 §5) where there's /// no cursor to show. #[must_use] pub fn lex_to_runs(input: &str, theme: &Theme) -> Vec { base_runs(input, theme) } fn base_runs(input: &str, theme: &Theme) -> Vec { let tokens = lex(input); let mut runs = Vec::with_capacity(tokens.len() * 2); let mut pos = 0; for tok in tokens { let (start, end) = tok.span; if pos < start { // Whitespace gap before this token. runs.push(StyledRun { byte_range: (pos, start), style: Style::default().fg(theme.fg), }); } runs.push(StyledRun { byte_range: (start, end), style: Style::default().fg(theme.token_color(&tok.kind)), }); pos = end; } if pos < input.len() { runs.push(StyledRun { byte_range: (pos, input.len()), style: Style::default().fg(theme.fg), }); } runs } fn inject_cursor( runs: &mut Vec, input: &str, cursor_byte: usize, theme: &Theme, ) { let cursor_byte = cursor_byte.min(input.len()); // End-of-input cursor: append the empty-range sentinel. if cursor_byte == input.len() { runs.push(StyledRun { byte_range: (input.len(), input.len()), style: Style::default() .fg(theme.fg) .add_modifier(Modifier::REVERSED), }); return; } let idx = runs .iter() .position(|r| r.byte_range.0 <= cursor_byte && cursor_byte < r.byte_range.1) .expect("cursor_byte < input.len() ⇒ some run contains it"); let target = runs[idx].clone(); let (start, end) = target.byte_range; // Walk to the next char boundary so a multi-byte UTF-8 // codepoint is treated as a single visual unit at the // cursor. let mut char_end = cursor_byte + 1; while char_end < input.len() && !input.is_char_boundary(char_end) { char_end += 1; } let mut replacement: Vec = Vec::with_capacity(3); if start < cursor_byte { replacement.push(StyledRun { byte_range: (start, cursor_byte), style: target.style, }); } replacement.push(StyledRun { byte_range: (cursor_byte, char_end), style: target.style.add_modifier(Modifier::REVERSED), }); if char_end < end { replacement.push(StyledRun { byte_range: (char_end, end), style: target.style, }); } runs.splice(idx..=idx, replacement); } #[cfg(test)] mod tests { use super::*; use pretty_assertions::assert_eq; fn dark() -> Theme { Theme::dark() } fn reversed(r: &StyledRun) -> bool { r.style.add_modifier.contains(Modifier::REVERSED) } #[test] fn empty_input_renders_only_the_end_of_input_cursor() { let runs = render_input_runs("", 0, &dark()); assert_eq!(runs.len(), 1); assert_eq!(runs[0].byte_range, (0, 0)); assert!(reversed(&runs[0])); } #[test] fn keyword_token_takes_keyword_colour() { let theme = dark(); let runs = render_input_runs("create", 6, &theme); // Token + end-of-input cursor. assert_eq!(runs.len(), 2); assert_eq!(runs[0].byte_range, (0, 6)); assert_eq!(runs[0].style.fg, Some(theme.tok_keyword)); assert!(!reversed(&runs[0])); assert!(reversed(&runs[1])); } #[test] fn cursor_inside_token_splits_into_three_runs_keeping_colour() { let theme = dark(); let runs = render_input_runs("create", 3, &theme); assert_eq!(runs.len(), 3); assert_eq!(runs[0].byte_range, (0, 3)); assert_eq!(runs[1].byte_range, (3, 4)); assert_eq!(runs[2].byte_range, (4, 6)); // All three keep the keyword colour. for r in &runs { assert_eq!(r.style.fg, Some(theme.tok_keyword)); } assert!(!reversed(&runs[0])); assert!(reversed(&runs[1])); assert!(!reversed(&runs[2])); } #[test] fn cursor_on_whitespace_inverts_a_single_space() { let theme = dark(); // "create table" has whitespace at byte 6. let runs = render_input_runs("create table", 6, &theme); // base: keyword, ws(6,7), keyword. After cursor injection // at the start of ws: under=(6,7) REVERSED. The // before/after slices are empty so we get 3 runs total. assert_eq!(runs.len(), 3); let r_under: Vec<_> = runs.iter().filter(|r| reversed(r)).collect(); assert_eq!(r_under.len(), 1); assert_eq!(r_under[0].byte_range, (6, 7)); assert_eq!(r_under[0].style.fg, Some(theme.fg)); } #[test] fn lex_error_token_renders_in_error_colour() { let theme = dark(); let runs = render_input_runs("$", 1, &theme); // Error token (0,1), then end-of-input cursor (1,1). assert_eq!(runs.len(), 2); assert_eq!(runs[0].style.fg, Some(theme.tok_error)); } #[test] fn whitespace_between_tokens_takes_default_fg() { let theme = dark(); let runs = render_input_runs("create table", 12, &theme); // base: keyword(0,6), ws(6,7), keyword(7,12). Plus // end-of-input cursor (12,12) = 4 runs. assert_eq!(runs.len(), 4); assert_eq!(runs[1].byte_range, (6, 7)); assert_eq!(runs[1].style.fg, Some(theme.fg)); assert_eq!(runs[3].byte_range, (12, 12)); assert!(reversed(&runs[3])); } #[test] fn cursor_inside_multi_byte_string_literal_advances_to_char_boundary() { let theme = dark(); // 'café' = ['(0)', c(1), a(2), f(3), é(4-5), '(6)] — é is 2 bytes. // Cursor at byte 4: inside é. char_end advances to 6. let runs = render_input_runs("'café'", 4, &theme); let r_under: Vec<_> = runs.iter().filter(|r| reversed(r)).collect(); assert_eq!(r_under.len(), 1); assert_eq!(r_under[0].byte_range, (4, 6)); } #[test] fn end_of_input_cursor_is_an_empty_range() { let runs = render_input_runs("create", 6, &dark()); let last = runs.last().expect("non-empty"); assert_eq!(last.byte_range, (6, 6)); assert!(reversed(last)); } #[test] fn full_command_lexes_to_each_token_class() { let theme = dark(); let runs = render_input_runs( "insert into T values (1, 'hi', null) --all-rows", 47, &theme, ); // Spot-check: there's at least one run of each token class. let fgs: Vec<_> = runs.iter().filter_map(|r| r.style.fg).collect(); assert!(fgs.contains(&theme.tok_keyword)); // insert / into / values / null assert!(fgs.contains(&theme.tok_identifier)); // T / hi (string is separate) assert!(fgs.contains(&theme.tok_number)); // 1 assert!(fgs.contains(&theme.tok_string)); // 'hi' assert!(fgs.contains(&theme.tok_punct)); // ( , ) assert!(fgs.contains(&theme.tok_flag)); // --all-rows } }