@@ -4,6 +4,7 @@ use std::fmt;
44
55use ratatui:: Terminal ;
66use ratatui:: backend:: TestBackend ;
7+ use ratatui:: style:: { Color , Modifier } ;
78use 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.
77207pub 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