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,
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct OutputLine {
|
pub struct OutputLine {
|
||||||
pub text: String,
|
pub text: String,
|
||||||
pub kind: OutputKind,
|
pub kind: OutputKind,
|
||||||
pub mode_at_submission: Mode,
|
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.
|
/// What mode the next submission would be evaluated in.
|
||||||
@@ -948,6 +1002,7 @@ impl App {
|
|||||||
text: effective_input,
|
text: effective_input,
|
||||||
kind: OutputKind::Echo,
|
kind: OutputKind::Echo,
|
||||||
mode_at_submission: effective_mode,
|
mode_at_submission: effective_mode,
|
||||||
|
styled_runs: None,
|
||||||
});
|
});
|
||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
@@ -1052,6 +1107,7 @@ impl App {
|
|||||||
text: crate::t!("dsl.running", input = input),
|
text: crate::t!("dsl.running", input = input),
|
||||||
kind: OutputKind::Echo,
|
kind: OutputKind::Echo,
|
||||||
mode_at_submission: submission_mode,
|
mode_at_submission: submission_mode,
|
||||||
|
styled_runs: None,
|
||||||
});
|
});
|
||||||
vec![Action::Replay { path }]
|
vec![Action::Replay { path }]
|
||||||
}
|
}
|
||||||
@@ -1060,6 +1116,7 @@ impl App {
|
|||||||
text: crate::t!("dsl.running", input = input),
|
text: crate::t!("dsl.running", input = input),
|
||||||
kind: OutputKind::Echo,
|
kind: OutputKind::Echo,
|
||||||
mode_at_submission: submission_mode,
|
mode_at_submission: submission_mode,
|
||||||
|
styled_runs: None,
|
||||||
});
|
});
|
||||||
vec![Action::ExecuteDsl {
|
vec![Action::ExecuteDsl {
|
||||||
command: cmd,
|
command: cmd,
|
||||||
@@ -1074,6 +1131,7 @@ impl App {
|
|||||||
text: crate::t!("dsl.running", input = input),
|
text: crate::t!("dsl.running", input = input),
|
||||||
kind: OutputKind::Echo,
|
kind: OutputKind::Echo,
|
||||||
mode_at_submission: submission_mode,
|
mode_at_submission: submission_mode,
|
||||||
|
styled_runs: None,
|
||||||
});
|
});
|
||||||
// Caret pointer at the failure position, when we
|
// Caret pointer at the failure position, when we
|
||||||
// have one. Aligned to the "running: " prefix so
|
// have one. Aligned to the "running: " prefix so
|
||||||
@@ -1754,6 +1812,7 @@ impl App {
|
|||||||
text,
|
text,
|
||||||
kind,
|
kind,
|
||||||
mode_at_submission: self.mode,
|
mode_at_submission: self.mode,
|
||||||
|
styled_runs: None,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1762,6 +1821,7 @@ impl App {
|
|||||||
text: line.to_string(),
|
text: line.to_string(),
|
||||||
kind,
|
kind,
|
||||||
mode_at_submission: self.mode,
|
mode_at_submission: self.mode,
|
||||||
|
styled_runs: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-1
@@ -40,8 +40,14 @@ pub struct Theme {
|
|||||||
pub error: Color,
|
pub error: Color,
|
||||||
/// Validity-indicator WARNING colour (ADR-0027 §4) — an
|
/// Validity-indicator WARNING colour (ADR-0027 §4) — an
|
||||||
/// amber distinct from `error`'s red. Drives the `[WRN]`
|
/// 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,
|
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) -------------------
|
// ---- Per-token-class colours (ADR-0022 §3) -------------------
|
||||||
pub tok_keyword: Color,
|
pub tok_keyword: Color,
|
||||||
pub tok_identifier: Color,
|
pub tok_identifier: Color,
|
||||||
@@ -67,6 +73,7 @@ impl Theme {
|
|||||||
system: Color::Rgb(0x9F, 0xD8, 0x91),
|
system: Color::Rgb(0x9F, 0xD8, 0x91),
|
||||||
error: Color::Rgb(0xFF, 0x6B, 0x6B),
|
error: Color::Rgb(0xFF, 0x6B, 0x6B),
|
||||||
warning: Color::Rgb(0xF5, 0xA9, 0x4B), // amber
|
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
|
// Token classes — distinct enough to tell apart at a
|
||||||
// glance, quiet enough that 80-char lines don't read
|
// glance, quiet enough that 80-char lines don't read
|
||||||
@@ -99,6 +106,7 @@ impl Theme {
|
|||||||
system: Color::Rgb(0x2E, 0x7C, 0x3C),
|
system: Color::Rgb(0x2E, 0x7C, 0x3C),
|
||||||
error: Color::Rgb(0xC0, 0x39, 0x2B),
|
error: Color::Rgb(0xC0, 0x39, 0x2B),
|
||||||
warning: Color::Rgb(0xA6, 0x5A, 0x00), // burnt amber
|
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 —
|
// Light-theme token palette: same intent as dark —
|
||||||
// identifier/punct close to fg/muted; warm tones for
|
// 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::text::{Line, Span};
|
||||||
use ratatui::widgets::{Block, BorderType, Borders, Paragraph, Wrap};
|
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::mode::Mode;
|
||||||
use crate::theme::Theme;
|
use crate::theme::Theme;
|
||||||
|
|
||||||
@@ -532,6 +532,19 @@ fn approximate_wrapped_rows_from_output(
|
|||||||
.sum()
|
.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> {
|
fn render_output_line<'a>(line: &'a OutputLine, theme: &Theme) -> Line<'a> {
|
||||||
let tag_style = match line.mode_at_submission {
|
let tag_style = match line.mode_at_submission {
|
||||||
Mode::Simple => Style::default().fg(theme.mode_simple),
|
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
|
// Echo body without the expected prefix, or any non-echo
|
||||||
// line, falls through to the plain rendering below.
|
// 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 {
|
let body_style = match line.kind {
|
||||||
OutputKind::Echo => Style::default().fg(theme.fg),
|
OutputKind::Echo => Style::default().fg(theme.fg),
|
||||||
OutputKind::System => Style::default().fg(theme.system),
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::app::App;
|
use crate::app::{App, OutputSpan};
|
||||||
use ratatui::Terminal;
|
use ratatui::Terminal;
|
||||||
use ratatui::backend::TestBackend;
|
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 {
|
fn render_to_string(app: &mut App, theme: &Theme, width: u16, height: u16) -> String {
|
||||||
// Snapshot tests need realistic state, not the boot
|
// Snapshot tests need realistic state, not the boot
|
||||||
// fallback "(no project)" — every real session has a
|
// fallback "(no project)" — every real session has a
|
||||||
@@ -1057,21 +1134,25 @@ mod tests {
|
|||||||
text: "[ok] create table Customers".to_string(),
|
text: "[ok] create table Customers".to_string(),
|
||||||
kind: OutputKind::System,
|
kind: OutputKind::System,
|
||||||
mode_at_submission: Mode::Simple,
|
mode_at_submission: Mode::Simple,
|
||||||
|
styled_runs: None,
|
||||||
});
|
});
|
||||||
app.output.push_back(OutputLine {
|
app.output.push_back(OutputLine {
|
||||||
text: " Customers".to_string(),
|
text: " Customers".to_string(),
|
||||||
kind: OutputKind::System,
|
kind: OutputKind::System,
|
||||||
mode_at_submission: Mode::Simple,
|
mode_at_submission: Mode::Simple,
|
||||||
|
styled_runs: None,
|
||||||
});
|
});
|
||||||
app.output.push_back(OutputLine {
|
app.output.push_back(OutputLine {
|
||||||
text: " id serial [PK]".to_string(),
|
text: " id serial [PK]".to_string(),
|
||||||
kind: OutputKind::System,
|
kind: OutputKind::System,
|
||||||
mode_at_submission: Mode::Simple,
|
mode_at_submission: Mode::Simple,
|
||||||
|
styled_runs: None,
|
||||||
});
|
});
|
||||||
app.output.push_back(OutputLine {
|
app.output.push_back(OutputLine {
|
||||||
text: " Name text".to_string(),
|
text: " Name text".to_string(),
|
||||||
kind: OutputKind::System,
|
kind: OutputKind::System,
|
||||||
mode_at_submission: Mode::Simple,
|
mode_at_submission: Mode::Simple,
|
||||||
|
styled_runs: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
let theme = Theme::dark();
|
let theme = Theme::dark();
|
||||||
|
|||||||
Reference in New Issue
Block a user