1use 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
17pub struct TerminalRenderer {
21 pub ctx: RenderContext,
22}
23
24impl TerminalRenderer {
25 pub fn new() -> Self {
27 Self {
28 ctx: RenderContext::terminal(),
29 }
30 }
31
32 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
141struct 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}", top_mid: "\u{252c}", top_right: "\u{256e}", mid_left: "\u{251c}", mid_mid: "\u{253c}", mid_right: "\u{2524}", bot_left: "\u{2570}", bot_mid: "\u{2534}", bot_right: "\u{256f}", horizontal: "\u{2500}", vertical: "\u{2502}", },
173 BorderStyle::Sharp => BoxChars {
174 top_left: "\u{250c}", top_mid: "\u{252c}", top_right: "\u{2510}", mid_left: "\u{251c}", mid_mid: "\u{253c}", mid_right: "\u{2524}", bot_left: "\u{2514}", bot_mid: "\u{2534}", bot_right: "\u{2518}", horizontal: "\u{2500}", vertical: "\u{2502}", },
186 BorderStyle::Heavy => BoxChars {
187 top_left: "\u{250f}", top_mid: "\u{2533}", top_right: "\u{2513}", mid_left: "\u{2523}", mid_mid: "\u{254b}", mid_right: "\u{252b}", bot_left: "\u{2517}", bot_mid: "\u{253b}", bot_right: "\u{251b}", horizontal: "\u{2501}", vertical: "\u{2503}", },
199 BorderStyle::Double => BoxChars {
200 top_left: "\u{2554}", top_mid: "\u{2566}", top_right: "\u{2557}", mid_left: "\u{2560}", mid_mid: "\u{256c}", mid_right: "\u{2563}", bot_left: "\u{255a}", bot_mid: "\u{2569}", bot_right: "\u{255d}", horizontal: "\u{2550}", vertical: "\u{2551}", },
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 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 if let Some(max_w) = ctx.max_width {
269 let overhead = col_count + 1 + col_count * 2; 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 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 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 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 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 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 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 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 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 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 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 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}")); assert!(output.contains("\u{256f}")); 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}")); assert!(output.contains("\u{251b}")); }
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}")); assert!(output.contains("\u{255d}")); }
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 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}")); assert!(output.contains("\u{2518}")); }
720}