ADR-0022 stage 2/8: input panel — token-class highlighting

New `input_render` module with `render_input_runs(input,
cursor_byte, theme) -> Vec<StyledRun>`. 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.
This commit is contained in:
claude@clouddev1
2026-05-10 17:29:51 +00:00
parent 00c9deaf6f
commit cafc455c8a
4 changed files with 366 additions and 11 deletions
+68 -11
View File
@@ -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<Span<'a>> {
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<Span<'a>> {
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