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:
@@ -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<StyledRun> {
|
||||||
|
let mut runs = base_runs(input, theme);
|
||||||
|
inject_cursor(&mut runs, input, cursor_byte, theme);
|
||||||
|
runs
|
||||||
|
}
|
||||||
|
|
||||||
|
fn base_runs(input: &str, theme: &Theme) -> Vec<StyledRun> {
|
||||||
|
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<StyledRun>,
|
||||||
|
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<StyledRun> = 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ pub mod db;
|
|||||||
pub mod dsl;
|
pub mod dsl;
|
||||||
pub mod event;
|
pub mod event;
|
||||||
pub mod friendly;
|
pub mod friendly;
|
||||||
|
pub mod input_render;
|
||||||
pub mod logging;
|
pub mod logging;
|
||||||
pub mod mode;
|
pub mod mode;
|
||||||
pub mod output_render;
|
pub mod output_render;
|
||||||
|
|||||||
+28
@@ -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
|
||||||
@@ -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
|
// Cursor block: render the character at the cursor position
|
||||||
// inverted so the cursor is visible without enabling a real
|
// inverted so the cursor is visible without enabling a real
|
||||||
// terminal cursor. When the cursor is at end-of-input we
|
// terminal cursor.
|
||||||
// append an inverted space.
|
//
|
||||||
|
// 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 cursor = app.input_cursor.min(app.input.len());
|
||||||
let before = &app.input[..cursor];
|
let spans = match effective {
|
||||||
let (under, after) = if cursor < app.input.len() {
|
EffectiveMode::Simple => {
|
||||||
// Find the end of the character under the cursor.
|
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;
|
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;
|
end += 1;
|
||||||
}
|
}
|
||||||
(&app.input[cursor..end], &app.input[end..])
|
(&input[cursor..end], &input[end..])
|
||||||
} else {
|
} else {
|
||||||
(" ", "")
|
(" ", "")
|
||||||
};
|
};
|
||||||
let spans = vec![
|
vec![
|
||||||
Span::styled(before, Style::default().fg(theme.fg)),
|
Span::styled(before, Style::default().fg(theme.fg)),
|
||||||
Span::styled(
|
Span::styled(
|
||||||
under,
|
under,
|
||||||
Style::default().fg(theme.fg).add_modifier(Modifier::REVERSED),
|
Style::default().fg(theme.fg).add_modifier(Modifier::REVERSED),
|
||||||
),
|
),
|
||||||
Span::styled(after, Style::default().fg(theme.fg)),
|
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) {
|
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);
|
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]
|
#[test]
|
||||||
fn one_shot_advanced_prompt_snapshot() {
|
fn one_shot_advanced_prompt_snapshot() {
|
||||||
// Typing `:sel` in simple mode should flip the input panel
|
// Typing `:sel` in simple mode should flip the input panel
|
||||||
|
|||||||
Reference in New Issue
Block a user