diff --git a/src/app.rs b/src/app.rs index 64e1e0a..7044f90 100644 --- a/src/app.rs +++ b/src/app.rs @@ -71,6 +71,13 @@ pub struct App { pub input_cursor: usize, pub output: VecDeque, pub hint: Option, + /// The validity indicator's currently-visible verdict + /// (ADR-0027). `None` means the indicator shows nothing — + /// the input is clean, or it is hidden mid-typing while the + /// debounce settles. The runtime owns the timing: it clears + /// this on a keystroke and sets it from + /// [`App::input_validity_verdict`] once typing pauses. + pub input_indicator: Option, pub tables: Vec, /// Last successfully described table, shown in the output /// pane until the next DDL operation. @@ -235,6 +242,7 @@ impl App { input_cursor: 0, output: VecDeque::with_capacity(OUTPUT_CAPACITY), hint: None, + input_indicator: None, tables: Vec::new(), current_table: None, history: Vec::new(), @@ -304,6 +312,21 @@ impl App { } } + /// The validity-indicator verdict for the current input + /// (ADR-0027 §3). `None` when the input would run clean. + /// + /// Computed only in simple mode — advanced mode is raw SQL, + /// which the DSL walker does not parse (ADR-0027 §7). A + /// pure query the runtime calls once the typing debounce + /// settles; the result is stored in `input_indicator`. + #[must_use] + pub fn input_validity_verdict(&self) -> Option { + if !matches!(self.effective_mode(), EffectiveMode::Simple) { + return None; + } + crate::dsl::walker::input_verdict(&self.input, Some(&self.schema_cache)) + } + /// Process one event from the runtime, mutating state and /// returning any actions for the runtime to enact. pub fn update(&mut self, event: AppEvent) -> Vec { 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 index 1a55edb..2cafed7 100644 --- 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 @@ -19,7 +19,7 @@ expression: snapshot │ ││ │ │ │╰──────────────────────────────────────────────────╯ │ │╭ SIMPLE ──────────────────────────────────────────╮ -│ ││insert into T values (1, 'hi', null) --all-rows $ │ +│ ││insert into T values (1, 'hi', null) --all-r │ │ │╰──────────────────────────────────────────────────╯ │ │╭ Hint ────────────────────────────────────────────╮ │ ││after `insert into T values (1, 'hi', null)`, │ diff --git a/src/theme.rs b/src/theme.rs index 32fed72..6d119f7 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -38,6 +38,10 @@ pub struct Theme { pub mode_advanced: Color, pub system: Color, pub error: Color, + /// Validity-indicator WARNING colour (ADR-0027 §4) — an + /// amber distinct from `error`'s red. Drives the `[WRN]` + /// label; `[ERR]` reuses `error`. + pub warning: Color, // ---- Per-token-class colours (ADR-0022 §3) ------------------- pub tok_keyword: Color, pub tok_identifier: Color, @@ -62,6 +66,8 @@ impl Theme { mode_advanced: Color::Rgb(0xFF, 0x9E, 0x6B), system: Color::Rgb(0x9F, 0xD8, 0x91), error: Color::Rgb(0xFF, 0x6B, 0x6B), + warning: Color::Rgb(0xF5, 0xA9, 0x4B), // amber + // Token classes — distinct enough to tell apart at a // glance, quiet enough that 80-char lines don't read // like a Christmas tree. Identifier and punct sit @@ -92,6 +98,8 @@ impl Theme { mode_advanced: Color::Rgb(0xB0, 0x4A, 0x12), system: Color::Rgb(0x2E, 0x7C, 0x3C), error: Color::Rgb(0xC0, 0x39, 0x2B), + warning: Color::Rgb(0xA6, 0x5A, 0x00), // burnt amber + // Light-theme token palette: same intent as dark — // identifier/punct close to fg/muted; warm tones for // literals + flags; cool accent for keyword. @@ -144,6 +152,7 @@ mod tests { ("tok_string", t.tok_string), ("tok_flag", t.tok_flag), ("tok_error", t.tok_error), + ("warning", t.warning), ] { assert_ne!( c, t.bg, @@ -161,6 +170,7 @@ mod tests { ("tok_string", t.tok_string), ("tok_flag", t.tok_flag), ("tok_error", t.tok_error), + ("warning", t.warning), ] { assert_ne!( c, t.bg, diff --git a/src/ui.rs b/src/ui.rs index a9f553b..3f28497 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -643,8 +643,37 @@ fn render_input_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rec plain_input_spans(&app.input, cursor, theme) } }; - let paragraph = Paragraph::new(Line::from(spans)).block(block); - frame.render_widget(paragraph, area); + // ADR-0027 §4: the rightmost six columns of the input row + // (a five-column label plus a one-column gap) are reserved + // unconditionally, so the text area is always + // `inner.width - 6` and the typed command never shifts + // sideways when the validity indicator appears or hides. + let inner = block.inner(area); + frame.render_widget(block, area); + let text_area = Rect { + width: inner.width.saturating_sub(6), + ..inner + }; + frame.render_widget(Paragraph::new(Line::from(spans)), text_area); + + if let Some(severity) = app.input_indicator { + let (indicator_label, color) = match severity { + crate::dsl::walker::Severity::Error => ("[ERR]", theme.error), + crate::dsl::walker::Severity::Warning => ("[WRN]", theme.warning), + }; + let label_area = Rect { + x: inner.x + inner.width.saturating_sub(5), + width: 5.min(inner.width), + ..inner + }; + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + indicator_label, + Style::default().fg(color).add_modifier(Modifier::BOLD), + ))), + label_area, + ); + } } /// Convert `StyledRun`s into ratatui `Span`s borrowed from diff --git a/tests/walking_skeleton.rs b/tests/walking_skeleton.rs index 829bd37..01b7cf6 100644 --- a/tests/walking_skeleton.rs +++ b/tests/walking_skeleton.rs @@ -595,3 +595,23 @@ fn dsl_failure_shows_friendly_error_in_output() { "error should include the friendly message:\n{rendered}" ); } + +#[test] +fn validity_indicator_renders_err_and_wrn_labels() { + // ADR-0027 §4: the input row shows a `[ERR]` / `[WRN]` + // label at its right edge, or nothing when clean. + use rdbms_playground::dsl::walker::Severity; + let mut app = App::new(); + + let clean = rendered_text(&mut app, &Theme::dark(), 80, 24); + assert!(!clean.contains("[ERR]"), "clean input shows no label:\n{clean}"); + assert!(!clean.contains("[WRN]"), "clean input shows no label:\n{clean}"); + + app.input_indicator = Some(Severity::Error); + let err = rendered_text(&mut app, &Theme::dark(), 80, 24); + assert!(err.contains("[ERR]"), "ERROR verdict shows [ERR]:\n{err}"); + + app.input_indicator = Some(Severity::Warning); + let wrn = rendered_text(&mut app, &Theme::dark(), 80, 24); + assert!(wrn.contains("[WRN]"), "WARNING verdict shows [WRN]:\n{wrn}"); +}