ui: styled-output-line mechanism (ADR-0028 step 1)
OutputLine gains an optional styled-runs payload — a
Vec<OutputSpan> of { byte_range, OutputStyleClass } over the
line text. render_output_line gains a branch: when the payload
is present it renders the text span-by-span, each run's
semantic class (Neutral / Efficient / Expensive /
AutomaticIndex) resolved to a theme colour at render time;
otherwise the existing whole-line kind styling. The echo path
is untouched.
Theme gains `plan_efficient` — a green deliberately distinct
from `system` so green never reads as two things (ADR-0028 §6);
`warning` is reused for expensive steps.
A general per-span output-styling capability (ADR-0016's OOS-3
realized); the query-plan renderer will be its first consumer.
No user-visible change on its own. 1133 passing, clippy clean.
This commit is contained in:
+60
@@ -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<Vec<OutputSpan>>,
|
||||
}
|
||||
|
||||
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<OutputSpan>,
|
||||
) -> 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+9
-1
@@ -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
|
||||
|
||||
@@ -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<Span<'a>> = 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();
|
||||
|
||||
Reference in New Issue
Block a user