use ratatui::style::{Color as RColor, Modifier, Style as RStyle};
use ratatui::text::{Line, Span};
use crate::config::Config;
use crate::data_context::error::UsageError;
use crate::data_context::DataContext;
use crate::layout::render_to_runs;
use crate::logging::CapturedSink;
use crate::segments::builder::build_lines;
use crate::segments::LineItem;
use crate::theme::{AnsiColor, Capability, Color, StyledRun, Theme};
const PREVIEW_STDIN_JSON: &[u8] = br#"{
"model": { "display_name": "claude-sonnet-4-5" },
"workspace": { "project_dir": "/home/dev/linesmith" },
"context_window": {
"used_percentage": 35.0,
"context_window_size": 200000,
"total_input_tokens": 50000,
"total_output_tokens": 25000
},
"cost": {
"total_cost_usd": 0.42,
"total_duration_ms": 12345,
"total_api_duration_ms": 6789,
"total_lines_added": 12,
"total_lines_removed": 4
}
}"#;
pub(super) fn render_lines(
config: &Config,
theme: &Theme,
capability: Capability,
terminal_width: u16,
sink: Option<&CapturedSink>,
) -> (Vec<Line<'static>>, Vec<String>) {
let mut warnings: Vec<String> = Vec::new();
let lines = build_lines(Some(config), None, |msg| warnings.push(msg.to_string()));
let ctx = preview_context();
let mut rendered = Vec::with_capacity(lines.len());
for line in &lines {
rendered.push(render_line(
line,
&ctx,
terminal_width,
theme,
capability,
|msg| {
warnings.push(msg.to_string());
},
));
}
if let Some(sink) = sink {
warnings.extend(sink.drain());
}
(rendered, warnings)
}
fn render_line(
items: &[LineItem],
ctx: &DataContext,
width: u16,
theme: &Theme,
capability: Capability,
mut warn: impl FnMut(&str),
) -> Line<'static> {
let mut observers = crate::layout::LayoutObservers::new(&mut warn);
let runs = render_to_runs(items, ctx, width, &mut observers);
let spans: Vec<Span<'static>> = runs
.iter()
.map(|r| run_to_span(r, theme, capability))
.collect();
Line::from(spans)
}
fn run_to_span(run: &StyledRun, theme: &Theme, capability: Capability) -> Span<'static> {
let mut style = RStyle::default();
let color = run
.style()
.fg
.or_else(|| run.style().role.map(|role| theme.color(role)));
if let Some(c) = color {
if let Some(rcolor) = color_to_ratatui(c.downgrade(capability)) {
style = style.fg(rcolor);
}
}
if run.style().bold {
style = style.add_modifier(Modifier::BOLD);
}
if run.style().italic {
style = style.add_modifier(Modifier::ITALIC);
}
if run.style().underline {
style = style.add_modifier(Modifier::UNDERLINED);
}
if run.style().dim {
style = style.add_modifier(Modifier::DIM);
}
Span::styled(run.text().to_string(), style)
}
fn color_to_ratatui(c: Color) -> Option<RColor> {
match c {
Color::TrueColor { r, g, b } => Some(RColor::Rgb(r, g, b)),
Color::Palette256(n) => Some(RColor::Indexed(n)),
Color::Palette16(ansi) => Some(ansi_to_ratatui(ansi)),
Color::NoColor => None,
_ => None,
}
}
fn ansi_to_ratatui(c: AnsiColor) -> RColor {
match c {
AnsiColor::Black => RColor::Black,
AnsiColor::Red => RColor::Red,
AnsiColor::Green => RColor::Green,
AnsiColor::Yellow => RColor::Yellow,
AnsiColor::Blue => RColor::Blue,
AnsiColor::Magenta => RColor::Magenta,
AnsiColor::Cyan => RColor::Cyan,
AnsiColor::White => RColor::Gray,
AnsiColor::BrightBlack => RColor::DarkGray,
AnsiColor::BrightRed => RColor::LightRed,
AnsiColor::BrightGreen => RColor::LightGreen,
AnsiColor::BrightYellow => RColor::LightYellow,
AnsiColor::BrightBlue => RColor::LightBlue,
AnsiColor::BrightMagenta => RColor::LightMagenta,
AnsiColor::BrightCyan => RColor::LightCyan,
AnsiColor::BrightWhite => RColor::White,
}
}
fn preview_context() -> DataContext {
preview_context_from(PREVIEW_STDIN_JSON)
}
fn preview_context_from(bytes: &[u8]) -> DataContext {
let status = crate::input::parse(bytes).unwrap_or_else(|_| empty_status());
let cwd = std::env::current_dir().ok();
let ctx = DataContext::with_cwd(status, cwd);
let _ = ctx.preseed_usage(Err(UsageError::NoCredentials));
ctx
}
fn empty_status() -> crate::input::StatusContext {
crate::input::parse(b"{}").expect("empty JSON object always parses")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::theme::{self, Role, Style};
fn style_with_fg(fg: Color) -> Style {
let mut s = Style::default();
s.fg = Some(fg);
s
}
fn style_with_decorations() -> Style {
let mut s = Style::default();
s.bold = true;
s.italic = true;
s.underline = true;
s.dim = true;
s
}
fn render(config: &Config, width: u16) -> (Vec<Line<'static>>, Vec<String>) {
render_lines(
config,
theme::default_theme(),
Capability::TrueColor,
width,
None,
)
}
#[test]
fn preview_context_parses_without_panicking() {
let _ = preview_context();
}
#[test]
fn preview_context_falls_back_when_parse_rejects() {
let rejecting = b"{ \"context_window\": { \"used_percentage\": -1.0 } }";
assert!(
crate::input::parse(rejecting).is_err(),
"test premise — payload must reject",
);
let _ = preview_context_from(rejecting);
}
#[test]
fn render_lines_with_default_config_returns_one_line_no_warnings() {
let cfg = Config::default();
let (lines, warnings) = render(&cfg, 200);
assert_eq!(lines.len(), 1);
assert!(warnings.is_empty(), "default config warned: {warnings:?}");
}
#[test]
fn render_lines_for_two_line_config_returns_two_lines() {
let cfg: Config = "layout = \"multi-line\"\n\
[line.1]\nsegments = [\"model\"]\n\
[line.2]\nsegments = [\"workspace\"]\n"
.parse()
.expect("parse");
let (lines, warnings) = render(&cfg, 200);
assert_eq!(lines.len(), 2);
assert!(
warnings.is_empty(),
"clean multi-line config warned: {warnings:?}",
);
}
#[test]
fn render_lines_surfaces_unknown_segment_warning() {
let cfg: Config = "[line]\nsegments = [\"modle\"]\n".parse().expect("parse");
let (_lines, warnings) = render(&cfg, 80);
assert!(
warnings.iter().any(|w| w.contains("modle")),
"expected warning about 'modle' typo, got {warnings:?}",
);
}
#[test]
fn run_to_span_maps_fg_color_to_ratatui_rgb() {
let run = StyledRun::new(
"x",
style_with_fg(Color::TrueColor {
r: 0xab,
g: 0xcd,
b: 0xef,
}),
);
let span = run_to_span(&run, theme::default_theme(), Capability::TrueColor);
assert_eq!(span.style.fg, Some(RColor::Rgb(0xab, 0xcd, 0xef)));
}
#[test]
fn run_to_span_fg_wins_over_role_when_both_set() {
let mut style = Style::default();
style.fg = Some(Color::TrueColor {
r: 0x11,
g: 0x22,
b: 0x33,
});
style.role = Some(Role::Error);
let run = StyledRun::new("x", style);
let span = run_to_span(&run, theme::default_theme(), Capability::TrueColor);
assert_eq!(span.style.fg, Some(RColor::Rgb(0x11, 0x22, 0x33)));
}
#[test]
fn run_to_span_resolves_role_through_theme_when_fg_unset() {
let run = StyledRun::new("x", Style::role(Role::Primary));
let span = run_to_span(&run, theme::default_theme(), Capability::TrueColor);
assert!(
span.style.fg.is_some(),
"role-only run must resolve to a color via theme",
);
}
#[test]
fn run_to_span_applies_decorations() {
let run = StyledRun::new("x", style_with_decorations());
let span = run_to_span(&run, theme::default_theme(), Capability::TrueColor);
let mods = span.style.add_modifier;
assert!(mods.contains(Modifier::BOLD));
assert!(mods.contains(Modifier::ITALIC));
assert!(mods.contains(Modifier::UNDERLINED));
assert!(mods.contains(Modifier::DIM));
}
#[test]
fn no_color_capability_strips_fg_but_keeps_decorations() {
let mut style = style_with_fg(Color::TrueColor { r: 1, g: 2, b: 3 });
style.bold = true;
let run = StyledRun::new("x", style);
let span = run_to_span(&run, theme::default_theme(), Capability::None);
assert_eq!(span.style.fg, None, "Capability::None must strip color");
assert!(span.style.add_modifier.contains(Modifier::BOLD));
}
#[test]
fn color_to_ratatui_covers_every_known_variant() {
assert_eq!(
color_to_ratatui(Color::TrueColor { r: 1, g: 2, b: 3 }),
Some(RColor::Rgb(1, 2, 3)),
);
assert_eq!(
color_to_ratatui(Color::Palette256(42)),
Some(RColor::Indexed(42)),
);
assert_eq!(
color_to_ratatui(Color::Palette16(AnsiColor::Red)),
Some(RColor::Red),
);
assert_eq!(color_to_ratatui(Color::NoColor), None);
}
#[test]
fn ansi_to_ratatui_maps_every_variant_with_correct_brightness_swap() {
assert_eq!(ansi_to_ratatui(AnsiColor::Black), RColor::Black);
assert_eq!(ansi_to_ratatui(AnsiColor::Red), RColor::Red);
assert_eq!(ansi_to_ratatui(AnsiColor::Green), RColor::Green);
assert_eq!(ansi_to_ratatui(AnsiColor::Yellow), RColor::Yellow);
assert_eq!(ansi_to_ratatui(AnsiColor::Blue), RColor::Blue);
assert_eq!(ansi_to_ratatui(AnsiColor::Magenta), RColor::Magenta);
assert_eq!(ansi_to_ratatui(AnsiColor::Cyan), RColor::Cyan);
assert_eq!(ansi_to_ratatui(AnsiColor::White), RColor::Gray);
assert_eq!(ansi_to_ratatui(AnsiColor::BrightBlack), RColor::DarkGray);
assert_eq!(ansi_to_ratatui(AnsiColor::BrightRed), RColor::LightRed);
assert_eq!(ansi_to_ratatui(AnsiColor::BrightGreen), RColor::LightGreen);
assert_eq!(
ansi_to_ratatui(AnsiColor::BrightYellow),
RColor::LightYellow
);
assert_eq!(ansi_to_ratatui(AnsiColor::BrightBlue), RColor::LightBlue);
assert_eq!(
ansi_to_ratatui(AnsiColor::BrightMagenta),
RColor::LightMagenta,
);
assert_eq!(ansi_to_ratatui(AnsiColor::BrightCyan), RColor::LightCyan);
assert_eq!(ansi_to_ratatui(AnsiColor::BrightWhite), RColor::White);
}
#[test]
fn render_lines_drains_captured_sink_into_warnings() {
use crate::logging::{self, Level};
use std::sync::Arc;
let _serial = logging::_test_serial_lock();
let cfg = Config::default();
let captured = Arc::new(CapturedSink::default());
let _restore = logging::SinkGuard::install(captured.clone());
logging::set_level(Level::Warn);
linesmith_core::lsm_warn!("preview-drain-pin: synthetic warn");
let (_lines, warnings) = render_lines(
&cfg,
theme::default_theme(),
Capability::TrueColor,
200,
Some(captured.as_ref()),
);
let matches: Vec<&String> = warnings
.iter()
.filter(|w| w.contains("preview-drain-pin"))
.collect();
assert_eq!(
matches.len(),
1,
"expected exactly one synthetic-warn entry, got {warnings:?}",
);
assert!(
matches[0].starts_with("[warn] "),
"drained entry must carry the `[warn]` prefix, got {:?}",
matches[0],
);
assert!(
captured.drain().is_empty(),
"render_lines must consume the captured sink",
);
}
#[test]
fn render_lines_with_sink_none_does_not_drain_global_sink() {
use crate::logging::{self, Level};
use std::sync::Arc;
let _serial = logging::_test_serial_lock();
let cfg = Config::default();
let captured = Arc::new(CapturedSink::default());
let _restore = logging::SinkGuard::install(captured.clone());
logging::set_level(Level::Warn);
linesmith_core::lsm_warn!("none-bypass-pin: stays in sink");
let (_lines, _warnings) = render_lines(
&cfg,
theme::default_theme(),
Capability::TrueColor,
200,
None,
);
let leftovers = captured.drain();
assert!(
leftovers.iter().any(|w| w.contains("none-bypass-pin")),
"sink=None must leave global sink contents alone, got {leftovers:?}",
);
}
#[test]
fn render_lines_with_plugin_segment_does_not_crash() {
let cfg: Config = "[line]\nsegments = [\"model\", \"my-plugin\", \"workspace\"]\n"
.parse()
.expect("parse");
let (lines, warnings) = render(&cfg, 200);
assert_eq!(lines.len(), 1, "plugin segment must not break the line");
assert!(
!lines[0].spans.is_empty(),
"non-plugin segments must still render alongside the unknown id",
);
assert!(
warnings.iter().any(|w| w.contains("my-plugin")),
"expected plugin warning, got {warnings:?}",
);
}
}