Skip to content

Commit 00a35bb

Browse files
author
cortex-qa
committed
feat(designer): ANSI color dump for scenario previews
Emit truecolor SGR from cell styles via dump --color auto|always|never so cortexnight themes are visible; plain text remains the golden path.
1 parent a09bac8 commit 00a35bb

4 files changed

Lines changed: 188 additions & 6 deletions

File tree

‎README.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,9 @@ cargo run --release -- run --scenario home-motd
135135

136136
# Headless UTF-8 buffer to stdout (default size 92x30, theme cortexnight)
137137
cargo run --release -- dump --scenario home-motd
138+
# ANSI colors (theme tokens: truecolor SGR) — use in a real terminal
139+
cargo run --release -- dump --scenario home-motd --color always
140+
cargo run --release -- dump --scenario view-mc-home --color always
138141
cargo run --release -- dump --scenario chat-idle --width 92 --height 30 --theme cortexnight
139142
```
140143

‎src/cli.rs‎

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
//! CLI: `list`, `dump`, `run`.
22
3-
use std::io::{self, Write};
3+
use std::io::{self, IsTerminal, Write};
44
use std::path::Path;
55
use std::process::ExitCode;
66

@@ -17,7 +17,8 @@ use tui::draw;
1717
use crate::catalog::{Catalog, CatalogError, default_catalog};
1818
use crate::gallery::{GalleryAction, GalleryState};
1919
use crate::paint::{
20-
DEFAULT_HEIGHT, DEFAULT_THEME, DEFAULT_WIDTH, PaintError, apply_theme, render_to_string,
20+
DEFAULT_HEIGHT, DEFAULT_THEME, DEFAULT_WIDTH, PaintError, apply_theme, render_to_ansi,
21+
render_to_string,
2122
};
2223

2324
/// Exit code for unknown scenario id (plan todo 9).
@@ -38,7 +39,10 @@ pub struct Cli {
3839
pub enum Commands {
3940
/// Print scenario ids grouped by category.
4041
List,
41-
/// Headless paint one scenario to stdout (C5 plain text).
42+
/// Headless paint one scenario to stdout.
43+
///
44+
/// Default is plain C5 text (stable goldens). Pass `--color` / `--color=always` for
45+
/// ANSI SGR so themes (e.g. cortexnight magenta/orange) are visible in a terminal.
4246
Dump {
4347
/// Scenario id (C6).
4448
#[arg(long)]
@@ -49,6 +53,9 @@ pub enum Commands {
4953
height: u16,
5054
#[arg(long, default_value = DEFAULT_THEME)]
5155
theme: String,
56+
/// Color mode for dump: `auto` (TTY→ANSI), `always`, `never` (plain C5).
57+
#[arg(long, default_value = "auto", value_parser = ["auto", "always", "never"])]
58+
color: String,
5259
},
5360
/// Interactive gallery (TTY). Optional starting scenario id.
5461
Run {
@@ -91,7 +98,22 @@ pub fn run_cli(args: impl IntoIterator<Item = std::ffi::OsString>) -> ExitCode {
9198
width,
9299
height,
93100
theme,
94-
} => match cmd_dump(&catalog, &scenario, width, height, &theme, &mut io::stdout()) {
101+
color,
102+
} => {
103+
let use_ansi = match color.as_str() {
104+
"always" => true,
105+
"never" => false,
106+
_ => io::stdout().is_terminal(),
107+
};
108+
match cmd_dump(
109+
&catalog,
110+
&scenario,
111+
width,
112+
height,
113+
&theme,
114+
use_ansi,
115+
&mut io::stdout(),
116+
) {
95117
Ok(()) => ExitCode::SUCCESS,
96118
Err(DumpError::Unknown(e)) => {
97119
eprintln!("{e}");
@@ -105,6 +127,7 @@ pub fn run_cli(args: impl IntoIterator<Item = std::ffi::OsString>) -> ExitCode {
105127
eprintln!("io failed: {e}");
106128
ExitCode::FAILURE
107129
}
130+
}
108131
},
109132
Commands::Run {
110133
id,
@@ -166,12 +189,17 @@ pub fn cmd_dump(
166189
width: u16,
167190
height: u16,
168191
theme: &str,
192+
ansi: bool,
169193
out: &mut impl Write,
170194
) -> Result<(), DumpError> {
171195
let scenario = catalog.get(id)?;
172196
let mut app = scenario.build();
173197
apply_theme(&mut app, theme);
174-
let screen = render_to_string(&app, width, height)?;
198+
let screen = if ansi {
199+
render_to_ansi(&app, width, height)?
200+
} else {
201+
render_to_string(&app, width, height)?
202+
};
175203
out.write_all(screen.as_bytes())?;
176204
Ok(())
177205
}
@@ -341,6 +369,7 @@ mod tests {
341369
DEFAULT_WIDTH,
342370
DEFAULT_HEIGHT,
343371
DEFAULT_THEME,
372+
false,
344373
&mut buf,
345374
)
346375
.expect("dump");
@@ -364,6 +393,7 @@ mod tests {
364393
DEFAULT_WIDTH,
365394
DEFAULT_HEIGHT,
366395
DEFAULT_THEME,
396+
false,
367397
&mut buf,
368398
)
369399
.expect_err("must fail");

‎src/lib.rs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ pub use catalog::{Catalog, CatalogError, REQUIRED_IDS, category_for_id, default_
1515
pub use gallery::{GalleryAction, GalleryState};
1616
pub use goldens::{CORE_GOLDEN_IDS, dump_normalized, golden_path, normalize, snapshots_dir, write_core_goldens};
1717
pub use paint::{
18-
DEFAULT_HEIGHT, DEFAULT_THEME, DEFAULT_WIDTH, PaintError, apply_theme, render_to_string,
18+
DEFAULT_HEIGHT, DEFAULT_THEME, DEFAULT_WIDTH, PaintError, apply_theme, render_to_ansi,
19+
render_to_string,
1920
};
2021
pub use scenario::{FnScenario, Scenario, fixture_status, prepare_base};
2122

‎src/paint.rs‎

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use std::fmt;
44

55
use ratatui::Terminal;
66
use ratatui::backend::TestBackend;
7+
use ratatui::style::{Color, Modifier};
78
use tui::{App, draw};
89

910
/// Default canvas width (plan C5).
@@ -73,6 +74,135 @@ pub fn render_to_string(app: &App, width: u16, height: u16) -> Result<String, Pa
7374
Ok(out)
7475
}
7576

77+
/// Render `app` to an ANSI-colored grid for terminal preview.
78+
///
79+
/// Same geometry rules as [`render_to_string`], but each cell emits SGR codes for
80+
/// fg/bg/modifiers when styles change. A reset (`\x1b[0m`) is emitted at end of
81+
/// each line. Use this for human `dump --color`; keep goldens on plain text.
82+
pub fn render_to_ansi(app: &App, width: u16, height: u16) -> Result<String, PaintError> {
83+
if width == 0 || height == 0 {
84+
return Err(PaintError::ZeroSize { width, height });
85+
}
86+
87+
let backend = TestBackend::new(width, height);
88+
let mut terminal = Terminal::new(backend).map_err(|e| PaintError::Terminal(e.to_string()))?;
89+
terminal
90+
.draw(|frame| draw(frame, app))
91+
.map_err(|e| PaintError::Draw(e.to_string()))?;
92+
93+
let buffer = terminal.backend().buffer();
94+
let area = buffer.area();
95+
// ANSI overhead estimate ~24 bytes/cell worst case.
96+
let mut out =
97+
String::with_capacity(usize::from(width) * usize::from(height) * 8 + usize::from(height));
98+
99+
for y in 0..area.height {
100+
let mut line = String::with_capacity(usize::from(width) * 8);
101+
let mut prev_style: Option<(Color, Color, Modifier)> = None;
102+
let mut cells: Vec<(String, Color, Color, Modifier)> =
103+
Vec::with_capacity(usize::from(width));
104+
105+
for x in 0..area.width {
106+
let cell = &buffer[(x, y)];
107+
let style = cell.style();
108+
let fg = style.fg.unwrap_or(Color::Reset);
109+
let bg = style.bg.unwrap_or(Color::Reset);
110+
let mods = style.add_modifier;
111+
cells.push((cell.symbol().to_owned(), fg, bg, mods));
112+
}
113+
114+
// Trim trailing whitespace cells (symbol is space/empty) so ANSI dump matches
115+
// plain dump line length after trim — compute last non-space index.
116+
let mut last_content = cells.len();
117+
while last_content > 0 {
118+
let sym = cells[last_content - 1].0.as_str();
119+
if sym == " " || sym.is_empty() {
120+
last_content -= 1;
121+
} else {
122+
break;
123+
}
124+
}
125+
cells.truncate(last_content);
126+
127+
for (symbol, fg, bg, mods) in cells {
128+
let cur = (fg, bg, mods);
129+
if prev_style != Some(cur) {
130+
line.push_str("\u{1b}[0m");
131+
push_sgr(&mut line, fg, bg, mods);
132+
prev_style = Some(cur);
133+
}
134+
line.push_str(&symbol);
135+
}
136+
if prev_style.is_some() {
137+
line.push_str("\u{1b}[0m");
138+
}
139+
out.push_str(&line);
140+
out.push('\n');
141+
}
142+
143+
debug_assert_eq!(
144+
out.bytes().filter(|&b| b == b'\n').count(),
145+
usize::from(height)
146+
);
147+
Ok(out)
148+
}
149+
150+
fn push_sgr(out: &mut String, fg: Color, bg: Color, mods: Modifier) {
151+
if mods.contains(Modifier::BOLD) {
152+
out.push_str("\u{1b}[1m");
153+
}
154+
if mods.contains(Modifier::DIM) {
155+
out.push_str("\u{1b}[2m");
156+
}
157+
if mods.contains(Modifier::ITALIC) {
158+
out.push_str("\u{1b}[3m");
159+
}
160+
if mods.contains(Modifier::UNDERLINED) {
161+
out.push_str("\u{1b}[4m");
162+
}
163+
if mods.contains(Modifier::REVERSED) {
164+
out.push_str("\u{1b}[7m");
165+
}
166+
if let Some(code) = color_sgr(fg, true) {
167+
out.push_str(&code);
168+
}
169+
if let Some(code) = color_sgr(bg, false) {
170+
out.push_str(&code);
171+
}
172+
}
173+
174+
fn color_sgr(color: Color, foreground: bool) -> Option<String> {
175+
let base = if foreground { 30 } else { 40 };
176+
let bright = if foreground { 90 } else { 100 };
177+
match color {
178+
Color::Reset => None,
179+
Color::Black => Some(format!("\u{1b}[{base}m")),
180+
Color::Red => Some(format!("\u{1b}[{}m", base + 1)),
181+
Color::Green => Some(format!("\u{1b}[{}m", base + 2)),
182+
Color::Yellow => Some(format!("\u{1b}[{}m", base + 3)),
183+
Color::Blue => Some(format!("\u{1b}[{}m", base + 4)),
184+
Color::Magenta => Some(format!("\u{1b}[{}m", base + 5)),
185+
Color::Cyan => Some(format!("\u{1b}[{}m", base + 6)),
186+
Color::Gray => Some(format!("\u{1b}[{}m", base + 7)),
187+
Color::DarkGray => Some(format!("\u{1b}[{bright}m")),
188+
Color::LightRed => Some(format!("\u{1b}[{}m", bright + 1)),
189+
Color::LightGreen => Some(format!("\u{1b}[{}m", bright + 2)),
190+
Color::LightYellow => Some(format!("\u{1b}[{}m", bright + 3)),
191+
Color::LightBlue => Some(format!("\u{1b}[{}m", bright + 4)),
192+
Color::LightMagenta => Some(format!("\u{1b}[{}m", bright + 5)),
193+
Color::LightCyan => Some(format!("\u{1b}[{}m", bright + 6)),
194+
Color::White => Some(format!("\u{1b}[{}m", bright + 7)),
195+
Color::Rgb(r, g, b) => {
196+
let kind = if foreground { 38 } else { 48 };
197+
Some(format!("\u{1b}[{kind};2;{r};{g};{b}m"))
198+
}
199+
Color::Indexed(i) => {
200+
let kind = if foreground { 38 } else { 48 };
201+
Some(format!("\u{1b}[{kind};5;{i}m"))
202+
}
203+
}
204+
}
205+
76206
/// Apply optional theme override after scenario build.
77207
pub fn apply_theme(app: &mut App, theme: &str) {
78208
if !theme.is_empty() && app.theme() != theme {
@@ -136,4 +266,22 @@ mod tests {
136266
}
137267
assert_eq!(screen.lines().count(), 10);
138268
}
269+
270+
#[test]
271+
fn ansi_render_emits_sgr_and_line_count() {
272+
let app = prepare_base();
273+
let colored = render_to_ansi(&app, DEFAULT_WIDTH, DEFAULT_HEIGHT).expect("ansi paint");
274+
assert_eq!(colored.lines().count(), usize::from(DEFAULT_HEIGHT));
275+
assert!(
276+
colored.contains('\u{1b}'),
277+
"ansi dump must contain ESC sequences; got bare text"
278+
);
279+
assert!(
280+
colored.contains("Cortex") || colored.contains('✦') || colored.contains('❯'),
281+
"chrome missing in ansi dump"
282+
);
283+
// Still human-readable after stripping-ish sanity: plain dump shorter but both same line count.
284+
let plain = render_to_string(&app, DEFAULT_WIDTH, DEFAULT_HEIGHT).expect("plain");
285+
assert_eq!(plain.lines().count(), colored.lines().count());
286+
}
139287
}

0 commit comments

Comments
 (0)