From cafc455c8a3c48fbe7fbb24bbfa110413029b39c Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Sun, 10 May 2026 17:29:51 +0000 Subject: [PATCH] =?UTF-8?q?ADR-0022=20stage=202/8:=20input=20panel=20?= =?UTF-8?q?=E2=80=94=20token-class=20highlighting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `input_render` module with `render_input_runs(input, cursor_byte, theme) -> Vec`. Lexes the input, assigns each token its `theme.token_color`, preserves whitespace gaps as `theme.fg` runs, and injects the cursor by splitting the run that contains it into before/under/after sub-spans (under marked Modifier::REVERSED). End-of-input cursor is an empty-range sentinel rendered as an inverted space. ui::render_input_panel switches over EffectiveMode: simple mode goes through render_input_runs + a small runs_to_spans helper that borrows from the input string; advanced modes (persistent + one-shot `:`) keep the previous plain before/under/after rendering since the DSL lexer doesn't speak SQL (ADR-0022 §12). Multi-byte UTF-8 in string literals is handled by walking to the next char boundary when splitting the cursor run, mirroring the previous renderer. Tests: 682 passing, 0 failing, 1 ignored (672 baseline → +10: 9 input_render unit tests covering each token class, cursor placements, multi-byte, full-command shape; +1 new "all token classes" UI snapshot). Clippy clean. Caveat (noted inline in the new snapshot test): the TestBackend/render_to_string path records text symbols only, not ratatui style. The new snapshot is therefore a text-layout regression net; the unit tests in input_render::tests are the authoritative regression net for colour mappings. Stage 3 wires the same colouring into simple-mode echo lines in the output panel. --- src/input_render.rs | 269 ++++++++++++++++++ src/lib.rs | 1 + ...hlighted_input_all_token_classes_dark.snap | 28 ++ src/ui.rs | 79 ++++- 4 files changed, 366 insertions(+), 11 deletions(-) create mode 100644 src/input_render.rs create mode 100644 src/snapshots/rdbms_playground__ui__tests__highlighted_input_all_token_classes_dark.snap diff --git a/src/input_render.rs b/src/input_render.rs new file mode 100644 index 0000000..5825b71 --- /dev/null +++ b/src/input_render.rs @@ -0,0 +1,269 @@ +//! 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 = base_runs(input, theme); + inject_cursor(&mut runs, input, cursor_byte, theme); + runs +} + +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 + } +} diff --git a/src/lib.rs b/src/lib.rs index 7dcd3e9..58ff5c3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,6 +13,7 @@ pub mod db; pub mod dsl; pub mod event; pub mod friendly; +pub mod input_render; pub mod logging; pub mod mode; pub mod output_render; diff --git a/src/snapshots/rdbms_playground__ui__tests__highlighted_input_all_token_classes_dark.snap b/src/snapshots/rdbms_playground__ui__tests__highlighted_input_all_token_classes_dark.snap new file mode 100644 index 0000000..b0f7e56 --- /dev/null +++ b/src/snapshots/rdbms_playground__ui__tests__highlighted_input_all_token_classes_dark.snap @@ -0,0 +1,28 @@ +--- +source: src/ui.rs +expression: snapshot +--- +╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮ +│(none yet) ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ │╰──────────────────────────────────────────────────╯ +│ │╭ SIMPLE ──────────────────────────────────────────╮ +│ ││insert into T values (1, 'hi', null) --all-rows $ │ +│ │╰──────────────────────────────────────────────────╯ +│ │╭ Hint ────────────────────────────────────────────╮ +│ ││(no active hint) │ +╰──────────────────────────╯╰──────────────────────────────────────────────────╯ +Project: Term Planner +Enter submit · : advanced once · mode advanced switch · Ctrl-C quit diff --git a/src/ui.rs b/src/ui.rs index 8ff08b1..d705643 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -580,30 +580,67 @@ fn render_input_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rec // Cursor block: render the character at the cursor position // inverted so the cursor is visible without enabling a real - // terminal cursor. When the cursor is at end-of-input we - // append an inverted space. + // terminal cursor. + // + // Simple-mode input gets per-token colouring (ADR-0022 §3) + // via input_render::render_input_runs. Advanced-mode input + // — DSL lexer doesn't speak SQL — renders plain (§12), with + // the same before/under/after cursor shape we always had. let cursor = app.input_cursor.min(app.input.len()); - let before = &app.input[..cursor]; - let (under, after) = if cursor < app.input.len() { - // Find the end of the character under the cursor. + let spans = match effective { + EffectiveMode::Simple => { + let runs = crate::input_render::render_input_runs(&app.input, cursor, theme); + runs_to_spans(&app.input, &runs) + } + EffectiveMode::AdvancedPersistent | EffectiveMode::AdvancedOneShot => { + plain_input_spans(&app.input, cursor, theme) + } + }; + let paragraph = Paragraph::new(Line::from(spans)).block(block); + frame.render_widget(paragraph, area); +} + +/// Convert `StyledRun`s into ratatui `Span`s borrowed from +/// `input`. The end-of-input cursor sentinel (empty range) is +/// rendered as an inverted space. +fn runs_to_spans<'a>( + input: &'a str, + runs: &[crate::input_render::StyledRun], +) -> Vec> { + runs.iter() + .map(|r| { + if r.byte_range.0 == r.byte_range.1 { + Span::styled(" ", r.style) + } else { + Span::styled(&input[r.byte_range.0..r.byte_range.1], r.style) + } + }) + .collect() +} + +/// Plain (no token highlighting) input rendering for advanced +/// mode. Same before/under/after cursor shape as the +/// pre-ADR-0022 input panel; here as a deliberate fallback. +fn plain_input_spans<'a>(input: &'a str, cursor: usize, theme: &Theme) -> Vec> { + let cursor = cursor.min(input.len()); + let before = &input[..cursor]; + let (under, after) = if cursor < input.len() { let mut end = cursor + 1; - while end < app.input.len() && !app.input.is_char_boundary(end) { + while end < input.len() && !input.is_char_boundary(end) { end += 1; } - (&app.input[cursor..end], &app.input[end..]) + (&input[cursor..end], &input[end..]) } else { (" ", "") }; - let spans = vec![ + vec![ Span::styled(before, Style::default().fg(theme.fg)), Span::styled( under, Style::default().fg(theme.fg).add_modifier(Modifier::REVERSED), ), Span::styled(after, Style::default().fg(theme.fg)), - ]; - let paragraph = Paragraph::new(Line::from(spans)).block(block); - frame.render_widget(paragraph, area); + ] } fn render_hint_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { @@ -731,6 +768,26 @@ mod tests { insta::assert_snapshot!("default_advanced_dark", snapshot); } + #[test] + fn highlighted_input_all_token_classes_snapshot() { + // ADR-0022 stage 2: representative input that exercises + // every token class — keyword (insert / into / values + // / null), identifier (T), number (1), string ('hi'), + // punct (parens, comma), flag (--all-rows), lex error + // ($ at the end). The snapshot captures the rendered + // text symbols only — `render_to_string` does not record + // ratatui style — so this test is a regression net for + // text layout, not for colour mappings. Colour mappings + // are unit-tested in `input_render::tests`. + let mut app = App::new(); + app.input + .push_str("insert into T values (1, 'hi', null) --all-rows $"); + app.input_cursor = app.input.len(); + let theme = Theme::dark(); + let snapshot = render_to_string(&mut app, &theme, 80, 24); + insta::assert_snapshot!("highlighted_input_all_token_classes_dark", snapshot); + } + #[test] fn one_shot_advanced_prompt_snapshot() { // Typing `:sel` in simple mode should flip the input panel