Skip to main content

shape_runtime/renderers/
terminal.rs

1//! Terminal renderer — renders ContentNode to ANSI-escaped terminal output.
2//!
3//! Supports:
4//! - Styled text with fg/bg colors, bold, italic, underline, dim
5//! - Tables with unicode box-drawing characters (6 border styles)
6//! - Code blocks with indentation and language label
7//! - Charts as placeholder text
8//! - Key-value pairs with aligned output
9//! - Fragments via concatenation
10
11use crate::content_renderer::{ContentRenderer, RenderContext, RendererCapabilities};
12use shape_value::content::{
13    BorderStyle, ChartSpec, Color, ContentNode, ContentTable, NamedColor, Style, StyledText,
14};
15use std::fmt::Write;
16
17/// Renders ContentNode trees to ANSI terminal output.
18///
19/// Carries a [`RenderContext`] to control terminal width, max rows, etc.
20pub struct TerminalRenderer {
21    pub ctx: RenderContext,
22}
23
24impl TerminalRenderer {
25    /// Create a renderer with default terminal context.
26    pub fn new() -> Self {
27        Self {
28            ctx: RenderContext::terminal(),
29        }
30    }
31
32    /// Create a renderer with a specific context.
33    pub fn with_context(ctx: RenderContext) -> Self {
34        Self { ctx }
35    }
36}
37
38impl Default for TerminalRenderer {
39    fn default() -> Self {
40        Self::new()
41    }
42}
43
44impl ContentRenderer for TerminalRenderer {
45    fn capabilities(&self) -> RendererCapabilities {
46        RendererCapabilities::terminal()
47    }
48
49    fn render(&self, content: &ContentNode) -> String {
50        render_node(content, &self.ctx)
51    }
52}
53
54fn render_node(node: &ContentNode, ctx: &RenderContext) -> String {
55    match node {
56        ContentNode::Text(st) => render_styled_text(st),
57        ContentNode::Table(table) => render_table(table, ctx),
58        ContentNode::Code { language, source } => render_code(language.as_deref(), source),
59        ContentNode::Chart(spec) => render_chart(spec),
60        ContentNode::KeyValue(pairs) => render_key_value(pairs, ctx),
61        ContentNode::Fragment(parts) => parts.iter().map(|n| render_node(n, ctx)).collect(),
62    }
63}
64
65fn render_styled_text(st: &StyledText) -> String {
66    let mut out = String::new();
67    for span in &st.spans {
68        let codes = style_to_ansi_codes(&span.style);
69        if codes.is_empty() {
70            out.push_str(&span.text);
71        } else {
72            let _ = write!(out, "\x1b[{}m{}\x1b[0m", codes, span.text);
73        }
74    }
75    out
76}
77
78fn style_to_ansi_codes(style: &Style) -> String {
79    let mut codes = Vec::new();
80    if style.bold {
81        codes.push("1".to_string());
82    }
83    if style.dim {
84        codes.push("2".to_string());
85    }
86    if style.italic {
87        codes.push("3".to_string());
88    }
89    if style.underline {
90        codes.push("4".to_string());
91    }
92    if let Some(ref color) = style.fg {
93        codes.push(color_to_fg_code(color));
94    }
95    if let Some(ref color) = style.bg {
96        codes.push(color_to_bg_code(color));
97    }
98    codes.join(";")
99}
100
101fn color_to_fg_code(color: &Color) -> String {
102    match color {
103        Color::Named(named) => named_color_fg(*named).to_string(),
104        Color::Rgb(r, g, b) => format!("38;2;{};{};{}", r, g, b),
105    }
106}
107
108fn color_to_bg_code(color: &Color) -> String {
109    match color {
110        Color::Named(named) => named_color_bg(*named).to_string(),
111        Color::Rgb(r, g, b) => format!("48;2;{};{};{}", r, g, b),
112    }
113}
114
115fn named_color_fg(color: NamedColor) -> u8 {
116    match color {
117        NamedColor::Red => 31,
118        NamedColor::Green => 32,
119        NamedColor::Yellow => 33,
120        NamedColor::Blue => 34,
121        NamedColor::Magenta => 35,
122        NamedColor::Cyan => 36,
123        NamedColor::White => 37,
124        NamedColor::Default => 39,
125    }
126}
127
128fn named_color_bg(color: NamedColor) -> u8 {
129    match color {
130        NamedColor::Red => 41,
131        NamedColor::Green => 42,
132        NamedColor::Yellow => 43,
133        NamedColor::Blue => 44,
134        NamedColor::Magenta => 45,
135        NamedColor::Cyan => 46,
136        NamedColor::White => 47,
137        NamedColor::Default => 49,
138    }
139}
140
141// ========== Table rendering ==========
142
143/// Box-drawing character set for a given border style.
144struct BoxChars {
145    top_left: &'static str,
146    top_mid: &'static str,
147    top_right: &'static str,
148    mid_left: &'static str,
149    mid_mid: &'static str,
150    mid_right: &'static str,
151    bot_left: &'static str,
152    bot_mid: &'static str,
153    bot_right: &'static str,
154    horizontal: &'static str,
155    vertical: &'static str,
156}
157
158fn box_chars(style: BorderStyle) -> BoxChars {
159    match style {
160        BorderStyle::Rounded => BoxChars {
161            top_left: "\u{256d}",   // ╭
162            top_mid: "\u{252c}",    // ┬
163            top_right: "\u{256e}",  // ╮
164            mid_left: "\u{251c}",   // ├
165            mid_mid: "\u{253c}",    // ┼
166            mid_right: "\u{2524}",  // ┤
167            bot_left: "\u{2570}",   // ╰
168            bot_mid: "\u{2534}",    // ┴
169            bot_right: "\u{256f}",  // ╯
170            horizontal: "\u{2500}", // ─
171            vertical: "\u{2502}",   // │
172        },
173        BorderStyle::Sharp => BoxChars {
174            top_left: "\u{250c}",   // ┌
175            top_mid: "\u{252c}",    // ┬
176            top_right: "\u{2510}",  // ┐
177            mid_left: "\u{251c}",   // ├
178            mid_mid: "\u{253c}",    // ┼
179            mid_right: "\u{2524}",  // ┤
180            bot_left: "\u{2514}",   // └
181            bot_mid: "\u{2534}",    // ┴
182            bot_right: "\u{2518}",  // ┘
183            horizontal: "\u{2500}", // ─
184            vertical: "\u{2502}",   // │
185        },
186        BorderStyle::Heavy => BoxChars {
187            top_left: "\u{250f}",   // ┏
188            top_mid: "\u{2533}",    // ┳
189            top_right: "\u{2513}",  // ┓
190            mid_left: "\u{2523}",   // ┣
191            mid_mid: "\u{254b}",    // ╋
192            mid_right: "\u{252b}",  // ┫
193            bot_left: "\u{2517}",   // ┗
194            bot_mid: "\u{253b}",    // ┻
195            bot_right: "\u{251b}",  // ┛
196            horizontal: "\u{2501}", // ━
197            vertical: "\u{2503}",   // ┃
198        },
199        BorderStyle::Double => BoxChars {
200            top_left: "\u{2554}",   // ╔
201            top_mid: "\u{2566}",    // ╦
202            top_right: "\u{2557}",  // ╗
203            mid_left: "\u{2560}",   // ╠
204            mid_mid: "\u{256c}",    // ╬
205            mid_right: "\u{2563}",  // ╣
206            bot_left: "\u{255a}",   // ╚
207            bot_mid: "\u{2569}",    // ╩
208            bot_right: "\u{255d}",  // ╝
209            horizontal: "\u{2550}", // ═
210            vertical: "\u{2551}",   // ║
211        },
212        BorderStyle::Minimal => BoxChars {
213            top_left: " ",
214            top_mid: " ",
215            top_right: " ",
216            mid_left: " ",
217            mid_mid: " ",
218            mid_right: " ",
219            bot_left: " ",
220            bot_mid: " ",
221            bot_right: " ",
222            horizontal: "-",
223            vertical: " ",
224        },
225        BorderStyle::None => BoxChars {
226            top_left: "",
227            top_mid: "",
228            top_right: "",
229            mid_left: "",
230            mid_mid: "",
231            mid_right: "",
232            bot_left: "",
233            bot_mid: "",
234            bot_right: "",
235            horizontal: "",
236            vertical: " ",
237        },
238    }
239}
240
241fn render_table(table: &ContentTable, ctx: &RenderContext) -> String {
242    if table.border == BorderStyle::None {
243        return render_table_no_border(table);
244    }
245
246    let bc = box_chars(table.border);
247
248    // Compute column widths
249    let col_count = table.headers.len();
250    let mut widths: Vec<usize> = table.headers.iter().map(|h| h.len()).collect();
251
252    let limit = table.max_rows.or(ctx.max_rows).unwrap_or(table.rows.len());
253    let display_rows = &table.rows[..limit.min(table.rows.len())];
254    let truncated = table.rows.len().saturating_sub(limit);
255
256    for row in display_rows {
257        for (i, cell) in row.iter().enumerate() {
258            if i < col_count {
259                let cell_text = cell.to_string();
260                if cell_text.len() > widths[i] {
261                    widths[i] = cell_text.len();
262                }
263            }
264        }
265    }
266
267    // Constrain column widths to ctx.max_width (proportional shrink)
268    if let Some(max_w) = ctx.max_width {
269        let overhead = col_count + 1 + col_count * 2; // borders + padding
270        if overhead < max_w {
271            let available = max_w - overhead;
272            let total_natural: usize = widths.iter().sum();
273            if total_natural > available && total_natural > 0 {
274                for w in &mut widths {
275                    *w = (*w * available / total_natural).max(3);
276                }
277            }
278        }
279    }
280
281    let mut out = String::new();
282
283    // Top border
284    let _ = write!(out, "{}", bc.top_left);
285    for (i, w) in widths.iter().enumerate() {
286        for _ in 0..(w + 2) {
287            out.push_str(bc.horizontal);
288        }
289        if i < col_count - 1 {
290            out.push_str(bc.top_mid);
291        }
292    }
293    let _ = writeln!(out, "{}", bc.top_right);
294
295    // Header row
296    let _ = write!(out, "{}", bc.vertical);
297    for (i, header) in table.headers.iter().enumerate() {
298        let _ = write!(out, " {:width$} ", header, width = widths[i]);
299        out.push_str(bc.vertical);
300    }
301    let _ = writeln!(out);
302
303    // Separator
304    let _ = write!(out, "{}", bc.mid_left);
305    for (i, w) in widths.iter().enumerate() {
306        for _ in 0..(w + 2) {
307            out.push_str(bc.horizontal);
308        }
309        if i < col_count - 1 {
310            out.push_str(bc.mid_mid);
311        }
312    }
313    let _ = writeln!(out, "{}", bc.mid_right);
314
315    // Data rows
316    for row in display_rows {
317        let _ = write!(out, "{}", bc.vertical);
318        for i in 0..col_count {
319            let cell_text = row.get(i).map(|c| c.to_string()).unwrap_or_default();
320            let _ = write!(out, " {:width$} ", cell_text, width = widths[i]);
321            out.push_str(bc.vertical);
322        }
323        let _ = writeln!(out);
324    }
325
326    // Truncation indicator
327    if truncated > 0 {
328        let _ = write!(out, "{}", bc.vertical);
329        let msg = format!("... {} more rows", truncated);
330        let total_width: usize = widths.iter().sum::<usize>() + (col_count - 1) * 3 + 2;
331        let _ = write!(out, " {:width$} ", msg, width = total_width);
332        out.push_str(bc.vertical);
333        let _ = writeln!(out);
334    }
335
336    // Bottom border
337    let _ = write!(out, "{}", bc.bot_left);
338    for (i, w) in widths.iter().enumerate() {
339        for _ in 0..(w + 2) {
340            out.push_str(bc.horizontal);
341        }
342        if i < col_count - 1 {
343            out.push_str(bc.bot_mid);
344        }
345    }
346    let _ = writeln!(out, "{}", bc.bot_right);
347
348    out
349}
350
351fn render_table_no_border(table: &ContentTable) -> String {
352    let col_count = table.headers.len();
353    let mut widths: Vec<usize> = table.headers.iter().map(|h| h.len()).collect();
354
355    let limit = table.max_rows.unwrap_or(table.rows.len());
356    let display_rows = &table.rows[..limit.min(table.rows.len())];
357    let truncated = table.rows.len().saturating_sub(limit);
358
359    for row in display_rows {
360        for (i, cell) in row.iter().enumerate() {
361            if i < col_count {
362                let cell_text = cell.to_string();
363                if cell_text.len() > widths[i] {
364                    widths[i] = cell_text.len();
365                }
366            }
367        }
368    }
369
370    let mut out = String::new();
371
372    // Header row
373    for (i, header) in table.headers.iter().enumerate() {
374        if i > 0 {
375            out.push_str("  ");
376        }
377        let _ = write!(out, "{:width$}", header, width = widths[i]);
378    }
379    let _ = writeln!(out);
380
381    // Data rows
382    for row in display_rows {
383        for i in 0..col_count {
384            if i > 0 {
385                out.push_str("  ");
386            }
387            let cell_text = row.get(i).map(|c| c.to_string()).unwrap_or_default();
388            let _ = write!(out, "{:width$}", cell_text, width = widths[i]);
389        }
390        let _ = writeln!(out);
391    }
392
393    if truncated > 0 {
394        let _ = writeln!(out, "... {} more rows", truncated);
395    }
396
397    out
398}
399
400fn render_code(language: Option<&str>, source: &str) -> String {
401    let mut out = String::new();
402    if let Some(lang) = language {
403        let _ = writeln!(out, "\x1b[2m[{}]\x1b[0m", lang);
404    }
405    for line in source.lines() {
406        let _ = writeln!(out, "    {}", line);
407    }
408    out
409}
410
411fn render_chart(spec: &ChartSpec) -> String {
412    // If the chart has actual data, render with braille/block characters
413    let has_data = !spec.channels.is_empty()
414        && spec.channels.iter().any(|c| !c.values.is_empty());
415    if has_data {
416        return super::terminal_chart::render_chart_text(spec);
417    }
418
419    // Fallback: placeholder text
420    let title = spec.title.as_deref().unwrap_or("untitled");
421    let type_name = chart_type_display_name(spec.chart_type);
422    let y_count = spec.channels_by_name("y").len();
423    format!(
424        "[{} Chart: {} ({} series)]\n",
425        type_name, title, y_count
426    )
427}
428
429fn chart_type_display_name(ct: shape_value::content::ChartType) -> &'static str {
430    use shape_value::content::ChartType;
431    match ct {
432        ChartType::Line => "Line",
433        ChartType::Bar => "Bar",
434        ChartType::Scatter => "Scatter",
435        ChartType::Area => "Area",
436        ChartType::Candlestick => "Candlestick",
437        ChartType::Histogram => "Histogram",
438        ChartType::BoxPlot => "BoxPlot",
439        ChartType::Heatmap => "Heatmap",
440        ChartType::Bubble => "Bubble",
441    }
442}
443
444fn render_key_value(pairs: &[(String, ContentNode)], ctx: &RenderContext) -> String {
445    if pairs.is_empty() {
446        return String::new();
447    }
448    let max_key_len = pairs.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
449    let mut out = String::new();
450    for (key, value) in pairs {
451        let value_str = render_node(value, ctx);
452        let _ = writeln!(out, "{:width$}  {}", key, value_str, width = max_key_len);
453    }
454    out
455}
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460    use shape_value::content::ContentTable;
461
462    fn renderer() -> TerminalRenderer {
463        TerminalRenderer::new()
464    }
465
466    #[test]
467    fn test_plain_text_no_ansi() {
468        let node = ContentNode::plain("hello world");
469        let output = renderer().render(&node);
470        assert_eq!(output, "hello world");
471    }
472
473    #[test]
474    fn test_bold_text_ansi() {
475        let node = ContentNode::plain("bold").with_bold();
476        let output = renderer().render(&node);
477        assert!(output.contains("\x1b[1m"));
478        assert!(output.contains("bold"));
479        assert!(output.contains("\x1b[0m"));
480    }
481
482    #[test]
483    fn test_fg_color_ansi() {
484        let node = ContentNode::plain("red").with_fg(Color::Named(NamedColor::Red));
485        let output = renderer().render(&node);
486        assert!(output.contains("\x1b[31m"));
487        assert!(output.contains("red"));
488        assert!(output.contains("\x1b[0m"));
489    }
490
491    #[test]
492    fn test_bg_color_ansi() {
493        let node = ContentNode::plain("bg").with_bg(Color::Named(NamedColor::Blue));
494        let output = renderer().render(&node);
495        assert!(output.contains("\x1b[44m"));
496    }
497
498    #[test]
499    fn test_rgb_fg_color() {
500        let node = ContentNode::plain("rgb").with_fg(Color::Rgb(255, 128, 0));
501        let output = renderer().render(&node);
502        assert!(output.contains("\x1b[38;2;255;128;0m"));
503    }
504
505    #[test]
506    fn test_rgb_bg_color() {
507        let node = ContentNode::plain("rgb").with_bg(Color::Rgb(0, 255, 128));
508        let output = renderer().render(&node);
509        assert!(output.contains("\x1b[48;2;0;255;128m"));
510    }
511
512    #[test]
513    fn test_italic_ansi() {
514        let node = ContentNode::plain("italic").with_italic();
515        let output = renderer().render(&node);
516        assert!(output.contains("\x1b[3m"));
517    }
518
519    #[test]
520    fn test_underline_ansi() {
521        let node = ContentNode::plain("underline").with_underline();
522        let output = renderer().render(&node);
523        assert!(output.contains("\x1b[4m"));
524    }
525
526    #[test]
527    fn test_dim_ansi() {
528        let node = ContentNode::plain("dim").with_dim();
529        let output = renderer().render(&node);
530        assert!(output.contains("\x1b[2m"));
531    }
532
533    #[test]
534    fn test_combined_styles() {
535        let node = ContentNode::plain("styled")
536            .with_bold()
537            .with_fg(Color::Named(NamedColor::Green));
538        let output = renderer().render(&node);
539        // Should contain both bold (1) and green fg (32)
540        assert!(output.contains("1;32") || output.contains("32;1"));
541        assert!(output.contains("styled"));
542    }
543
544    #[test]
545    fn test_rounded_table() {
546        let table = ContentNode::Table(ContentTable {
547            headers: vec!["Name".into(), "Age".into()],
548            rows: vec![
549                vec![ContentNode::plain("Alice"), ContentNode::plain("30")],
550                vec![ContentNode::plain("Bob"), ContentNode::plain("25")],
551            ],
552            border: BorderStyle::Rounded,
553            max_rows: None,
554            column_types: None,
555            total_rows: None,
556            sortable: false,
557        });
558        let output = renderer().render(&table);
559        assert!(output.contains("\u{256d}")); // ╭
560        assert!(output.contains("\u{256f}")); // ╯
561        assert!(output.contains("Alice"));
562        assert!(output.contains("Bob"));
563    }
564
565    #[test]
566    fn test_heavy_table() {
567        let table = ContentNode::Table(ContentTable {
568            headers: vec!["X".into()],
569            rows: vec![vec![ContentNode::plain("1")]],
570            border: BorderStyle::Heavy,
571            max_rows: None,
572            column_types: None,
573            total_rows: None,
574            sortable: false,
575        });
576        let output = renderer().render(&table);
577        assert!(output.contains("\u{250f}")); // ┏
578        assert!(output.contains("\u{251b}")); // ┛
579    }
580
581    #[test]
582    fn test_double_table() {
583        let table = ContentNode::Table(ContentTable {
584            headers: vec!["X".into()],
585            rows: vec![vec![ContentNode::plain("1")]],
586            border: BorderStyle::Double,
587            max_rows: None,
588            column_types: None,
589            total_rows: None,
590            sortable: false,
591        });
592        let output = renderer().render(&table);
593        assert!(output.contains("\u{2554}")); // ╔
594        assert!(output.contains("\u{255d}")); // ╝
595    }
596
597    #[test]
598    fn test_table_max_rows_truncation() {
599        let table = ContentNode::Table(ContentTable {
600            headers: vec!["X".into()],
601            rows: vec![
602                vec![ContentNode::plain("1")],
603                vec![ContentNode::plain("2")],
604                vec![ContentNode::plain("3")],
605                vec![ContentNode::plain("4")],
606            ],
607            border: BorderStyle::Rounded,
608            max_rows: Some(2),
609            column_types: None,
610            total_rows: None,
611            sortable: false,
612        });
613        let output = renderer().render(&table);
614        assert!(output.contains("1"));
615        assert!(output.contains("2"));
616        assert!(!output.contains(" 3 "));
617        assert!(output.contains("... 2 more rows"));
618    }
619
620    #[test]
621    fn test_no_border_table() {
622        let table = ContentNode::Table(ContentTable {
623            headers: vec!["A".into(), "B".into()],
624            rows: vec![vec![ContentNode::plain("x"), ContentNode::plain("y")]],
625            border: BorderStyle::None,
626            max_rows: None,
627            column_types: None,
628            total_rows: None,
629            sortable: false,
630        });
631        let output = renderer().render(&table);
632        assert!(output.contains("A"));
633        assert!(output.contains("B"));
634        assert!(output.contains("x"));
635        assert!(output.contains("y"));
636        // Should not contain box-drawing characters
637        assert!(!output.contains("\u{256d}"));
638        assert!(!output.contains("\u{2500}"));
639    }
640
641    #[test]
642    fn test_code_block_with_language() {
643        let code = ContentNode::Code {
644            language: Some("rust".into()),
645            source: "fn main() {\n    println!(\"hi\");\n}".into(),
646        };
647        let output = renderer().render(&code);
648        assert!(output.contains("[rust]"));
649        assert!(output.contains("    fn main() {"));
650    }
651
652    #[test]
653    fn test_code_block_no_language() {
654        let code = ContentNode::Code {
655            language: None,
656            source: "hello".into(),
657        };
658        let output = renderer().render(&code);
659        assert!(!output.contains("["));
660        assert!(output.contains("    hello"));
661    }
662
663    #[test]
664    fn test_chart_placeholder() {
665        let chart = ContentNode::Chart(shape_value::content::ChartSpec {
666            chart_type: shape_value::content::ChartType::Line,
667            channels: vec![],
668            x_categories: None,
669            title: Some("Revenue".into()),
670            x_label: None,
671            y_label: None,
672            width: None,
673            height: None,
674            echarts_options: None,
675            interactive: true,
676        });
677        let output = renderer().render(&chart);
678        assert!(output.contains("Line Chart: Revenue (0 series)"));
679    }
680
681    #[test]
682    fn test_key_value_aligned() {
683        let kv = ContentNode::KeyValue(vec![
684            ("name".into(), ContentNode::plain("Alice")),
685            ("age".into(), ContentNode::plain("30")),
686            ("location".into(), ContentNode::plain("NYC")),
687        ]);
688        let output = renderer().render(&kv);
689        assert!(output.contains("name"));
690        assert!(output.contains("Alice"));
691        assert!(output.contains("location"));
692        assert!(output.contains("NYC"));
693    }
694
695    #[test]
696    fn test_fragment_concatenation() {
697        let frag = ContentNode::Fragment(vec![
698            ContentNode::plain("hello "),
699            ContentNode::plain("world"),
700        ]);
701        let output = renderer().render(&frag);
702        assert_eq!(output, "hello world");
703    }
704
705    #[test]
706    fn test_sharp_table_borders() {
707        let table = ContentNode::Table(ContentTable {
708            headers: vec!["X".into()],
709            rows: vec![vec![ContentNode::plain("1")]],
710            border: BorderStyle::Sharp,
711            max_rows: None,
712            column_types: None,
713            total_rows: None,
714            sortable: false,
715        });
716        let output = renderer().render(&table);
717        assert!(output.contains("\u{250c}")); // ┌
718        assert!(output.contains("\u{2518}")); // ┘
719    }
720}