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:
@@ -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