diff --git a/src/app.rs b/src/app.rs index 44a5060..45632c3 100644 --- a/src/app.rs +++ b/src/app.rs @@ -30,11 +30,65 @@ pub enum OutputKind { Error, } +/// The semantic style class of an [`OutputSpan`] (ADR-0028 §5). +/// +/// A general output-styling vocabulary, resolved to a concrete +/// theme colour at render time — never a baked-in colour. The +/// query-plan renderer (ADR-0028) is its first consumer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OutputStyleClass { + /// Default foreground — connectors, names, structural text. + Neutral, + /// An efficient query-plan step — an index search, a + /// covering index, a primary-key lookup. + Efficient, + /// An expensive query-plan step — a full table scan or a + /// temp B-tree. + Expensive, + /// An automatic-index step — the engine built a temporary + /// index because none existed; the strongest "add an index + /// here" signal. + AutomaticIndex, +} + +/// A styled span of an output line: a byte range over the +/// line's text and the semantic class it carries (ADR-0028 §5). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OutputSpan { + /// Half-open byte range `[start, end)` into the line text. + pub byte_range: (usize, usize), + pub class: OutputStyleClass, +} + #[derive(Debug, Clone)] pub struct OutputLine { pub text: String, pub kind: OutputKind, pub mode_at_submission: Mode, + /// Optional per-span styling (ADR-0028 §5). When `Some`, + /// `render_output_line` colours the text span-by-span from + /// these runs; when `None` it falls back to whole-line + /// styling by `kind`. + pub styled_runs: Option>, +} + +impl OutputLine { + /// An output line carrying per-span styled runs (ADR-0028 + /// §5) — the text is coloured per `runs`, not by `kind`. + #[must_use] + pub const fn styled( + text: String, + kind: OutputKind, + mode_at_submission: Mode, + runs: Vec, + ) -> Self { + Self { + text, + kind, + mode_at_submission, + styled_runs: Some(runs), + } + } } /// What mode the next submission would be evaluated in. @@ -948,6 +1002,7 @@ impl App { text: effective_input, kind: OutputKind::Echo, mode_at_submission: effective_mode, + styled_runs: None, }); Vec::new() } @@ -1052,6 +1107,7 @@ impl App { text: crate::t!("dsl.running", input = input), kind: OutputKind::Echo, mode_at_submission: submission_mode, + styled_runs: None, }); vec![Action::Replay { path }] } @@ -1060,6 +1116,7 @@ impl App { text: crate::t!("dsl.running", input = input), kind: OutputKind::Echo, mode_at_submission: submission_mode, + styled_runs: None, }); vec![Action::ExecuteDsl { command: cmd, @@ -1074,6 +1131,7 @@ impl App { text: crate::t!("dsl.running", input = input), kind: OutputKind::Echo, mode_at_submission: submission_mode, + styled_runs: None, }); // Caret pointer at the failure position, when we // have one. Aligned to the "running: " prefix so @@ -1754,6 +1812,7 @@ impl App { text, kind, mode_at_submission: self.mode, + styled_runs: None, }); return; } @@ -1762,6 +1821,7 @@ impl App { text: line.to_string(), kind, mode_at_submission: self.mode, + styled_runs: None, }); } } diff --git a/src/theme.rs b/src/theme.rs index 6d119f7..1c907ff 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -40,8 +40,14 @@ pub struct Theme { pub error: Color, /// Validity-indicator WARNING colour (ADR-0027 §4) — an /// amber distinct from `error`'s red. Drives the `[WRN]` - /// label; `[ERR]` reuses `error`. + /// label; `[ERR]` reuses `error`. Also the query-plan + /// "expensive step" colour (ADR-0028 §6). pub warning: Color, + /// Query-plan "efficient step" colour (ADR-0028 §6) — a + /// green deliberately distinct from `system` so green never + /// reads as two things. Indexed lookups carry it; expensive + /// steps reuse `warning`. + pub plan_efficient: Color, // ---- Per-token-class colours (ADR-0022 §3) ------------------- pub tok_keyword: Color, pub tok_identifier: Color, @@ -67,6 +73,7 @@ impl Theme { system: Color::Rgb(0x9F, 0xD8, 0x91), error: Color::Rgb(0xFF, 0x6B, 0x6B), warning: Color::Rgb(0xF5, 0xA9, 0x4B), // amber + plan_efficient: Color::Rgb(0x4D, 0xD0, 0xA8), // teal-green // Token classes — distinct enough to tell apart at a // glance, quiet enough that 80-char lines don't read @@ -99,6 +106,7 @@ impl Theme { system: Color::Rgb(0x2E, 0x7C, 0x3C), error: Color::Rgb(0xC0, 0x39, 0x2B), warning: Color::Rgb(0xA6, 0x5A, 0x00), // burnt amber + plan_efficient: Color::Rgb(0x0B, 0x80, 0x6A), // deep teal-green // Light-theme token palette: same intent as dark — // identifier/punct close to fg/muted; warm tones for diff --git a/src/ui.rs b/src/ui.rs index 3f28497..2ba9ef0 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -12,7 +12,7 @@ use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, BorderType, Borders, Paragraph, Wrap}; -use crate::app::{App, EffectiveMode, OutputKind, OutputLine}; +use crate::app::{App, EffectiveMode, OutputKind, OutputLine, OutputStyleClass}; use crate::mode::Mode; use crate::theme::Theme; @@ -532,6 +532,19 @@ fn approximate_wrapped_rows_from_output( .sum() } +/// Resolve a semantic [`OutputStyleClass`] to a concrete style +/// against the active theme (ADR-0028 §5/§6). +const fn output_span_style(class: OutputStyleClass, theme: &Theme) -> Style { + match class { + OutputStyleClass::Neutral => Style::new().fg(theme.fg), + OutputStyleClass::Efficient => Style::new().fg(theme.plan_efficient), + OutputStyleClass::Expensive => Style::new().fg(theme.warning), + OutputStyleClass::AutomaticIndex => Style::new() + .fg(theme.warning) + .add_modifier(Modifier::BOLD), + } +} + fn render_output_line<'a>(line: &'a OutputLine, theme: &Theme) -> Line<'a> { let tag_style = match line.mode_at_submission { Mode::Simple => Style::default().fg(theme.mode_simple), @@ -569,6 +582,24 @@ fn render_output_line<'a>(line: &'a OutputLine, theme: &Theme) -> Line<'a> { // Echo body without the expected prefix, or any non-echo // line, falls through to the plain rendering below. + // ADR-0028 §5: a line carrying a styled-runs payload is + // rendered span-by-span, each run's semantic class resolved + // to a colour from the active theme. The tag keeps its + // kind styling. (Echo lines never carry runs, so this never + // collides with the branch above.) + if let Some(runs) = &line.styled_runs { + let mut spans: Vec> = Vec::with_capacity(runs.len() + 1); + spans.push(Span::styled(tag, tag_style)); + for run in runs { + let (start, end) = run.byte_range; + spans.push(Span::styled( + &line.text[start..end], + output_span_style(run.class, theme), + )); + } + return Line::from(spans); + } + let body_style = match line.kind { OutputKind::Echo => Style::default().fg(theme.fg), OutputKind::System => Style::default().fg(theme.system), @@ -920,10 +951,56 @@ fn render_status_bar(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect #[cfg(test)] mod tests { use super::*; - use crate::app::App; + use crate::app::{App, OutputSpan}; use ratatui::Terminal; use ratatui::backend::TestBackend; + #[test] + fn styled_runs_payload_renders_each_span_with_its_class_colour() { + // ADR-0028 §5: an OutputLine carrying a styled-runs + // payload renders span-by-span, each run's semantic + // class resolved to a theme colour. + let theme = Theme::dark(); + let line = OutputLine::styled( + "SCAN Customers".to_string(), + OutputKind::System, + Mode::Simple, + vec![ + OutputSpan { + byte_range: (0, 4), + class: OutputStyleClass::Expensive, + }, + OutputSpan { + byte_range: (4, 14), + class: OutputStyleClass::Neutral, + }, + ], + ); + let rendered = render_output_line(&line, &theme); + // tag span + 2 run spans. + assert_eq!(rendered.spans.len(), 3); + assert_eq!(rendered.spans[1].content.as_ref(), "SCAN"); + assert_eq!(rendered.spans[1].style.fg, Some(theme.warning)); + assert_eq!(rendered.spans[2].content.as_ref(), " Customers"); + assert_eq!(rendered.spans[2].style.fg, Some(theme.fg)); + } + + #[test] + fn a_line_without_styled_runs_keeps_whole_line_kind_styling() { + let theme = Theme::dark(); + let line = OutputLine { + text: "plain system line".to_string(), + kind: OutputKind::System, + mode_at_submission: Mode::Simple, + styled_runs: None, + }; + let rendered = render_output_line(&line, &theme); + // tag span + single whole-line body span. + assert_eq!(rendered.spans.len(), 2); + assert_eq!(rendered.spans[1].content.as_ref(), "plain system line"); + assert_eq!(rendered.spans[1].style.fg, Some(theme.system)); + } + fn render_to_string(app: &mut App, theme: &Theme, width: u16, height: u16) -> String { // Snapshot tests need realistic state, not the boot // fallback "(no project)" — every real session has a @@ -1057,21 +1134,25 @@ mod tests { text: "[ok] create table Customers".to_string(), kind: OutputKind::System, mode_at_submission: Mode::Simple, + styled_runs: None, }); app.output.push_back(OutputLine { text: " Customers".to_string(), kind: OutputKind::System, mode_at_submission: Mode::Simple, + styled_runs: None, }); app.output.push_back(OutputLine { text: " id serial [PK]".to_string(), kind: OutputKind::System, mode_at_submission: Mode::Simple, + styled_runs: None, }); app.output.push_back(OutputLine { text: " Name text".to_string(), kind: OutputKind::System, mode_at_submission: Mode::Simple, + styled_runs: None, }); let theme = Theme::dark();