Skip to main content

shape_value/
content.rs

1//! Structured content nodes — the output of Content.render().
2//!
3//! ContentNode is a rich, structured representation of rendered output that
4//! supports styled text, tables, code blocks, charts, key-value pairs, and
5//! fragments (compositions of multiple nodes).
6
7use std::fmt;
8
9/// A structured content node — the output of Content.render()
10#[derive(Debug, Clone, PartialEq)]
11pub enum ContentNode {
12    /// Styled text with spans
13    Text(StyledText),
14    /// Table with headers, rows, and optional styling
15    Table(ContentTable),
16    /// Code block with optional language
17    Code {
18        language: Option<String>,
19        source: String,
20    },
21    /// Chart specification
22    Chart(ChartSpec),
23    /// Key-value pairs
24    KeyValue(Vec<(String, ContentNode)>),
25    /// Composition of multiple content nodes
26    Fragment(Vec<ContentNode>),
27}
28
29#[derive(Debug, Clone, PartialEq)]
30pub struct StyledText {
31    pub spans: Vec<StyledSpan>,
32}
33
34#[derive(Debug, Clone, PartialEq)]
35pub struct StyledSpan {
36    pub text: String,
37    pub style: Style,
38}
39
40#[derive(Debug, Clone, PartialEq, Default)]
41pub struct Style {
42    pub fg: Option<Color>,
43    pub bg: Option<Color>,
44    pub bold: bool,
45    pub italic: bool,
46    pub underline: bool,
47    pub dim: bool,
48}
49
50#[derive(Debug, Clone, PartialEq)]
51pub enum Color {
52    Named(NamedColor),
53    Rgb(u8, u8, u8),
54}
55
56#[derive(Debug, Clone, Copy, PartialEq)]
57pub enum NamedColor {
58    Red,
59    Green,
60    Blue,
61    Yellow,
62    Magenta,
63    Cyan,
64    White,
65    Default,
66}
67
68#[derive(Debug, Clone, PartialEq)]
69pub struct ContentTable {
70    pub headers: Vec<String>,
71    pub rows: Vec<Vec<ContentNode>>,
72    pub border: BorderStyle,
73    pub max_rows: Option<usize>,
74    /// Column type hints: "string", "number", "date", etc.
75    pub column_types: Option<Vec<String>>,
76    /// Total row count before truncation (for display: "showing 50 of 1000").
77    pub total_rows: Option<usize>,
78    /// Whether interactive renderers should enable column sorting.
79    pub sortable: bool,
80}
81
82#[derive(Debug, Clone, Copy, PartialEq)]
83pub enum BorderStyle {
84    Rounded,
85    Sharp,
86    Heavy,
87    Double,
88    Minimal,
89    None,
90}
91
92impl Default for BorderStyle {
93    fn default() -> Self {
94        BorderStyle::Rounded
95    }
96}
97
98#[derive(Debug, Clone, PartialEq)]
99pub struct ChartSpec {
100    pub chart_type: ChartType,
101    pub series: Vec<ChartSeries>,
102    pub title: Option<String>,
103    pub x_label: Option<String>,
104    pub y_label: Option<String>,
105    pub width: Option<usize>,
106    pub height: Option<usize>,
107    /// Full ECharts option JSON override (injected by chart detection).
108    pub echarts_options: Option<serde_json::Value>,
109    /// Whether this chart should be rendered interactively (default true).
110    pub interactive: bool,
111}
112
113#[derive(Debug, Clone, Copy, PartialEq)]
114pub enum ChartType {
115    Line,
116    Bar,
117    Scatter,
118    Area,
119    Candlestick,
120    Histogram,
121}
122
123#[derive(Debug, Clone, PartialEq)]
124pub struct ChartSeries {
125    pub label: String,
126    pub data: Vec<(f64, f64)>,
127    pub color: Option<Color>,
128}
129
130impl ContentNode {
131    /// Create a plain text node.
132    pub fn plain(text: impl Into<String>) -> Self {
133        ContentNode::Text(StyledText {
134            spans: vec![StyledSpan {
135                text: text.into(),
136                style: Style::default(),
137            }],
138        })
139    }
140
141    /// Create a styled text node.
142    pub fn styled(text: impl Into<String>, style: Style) -> Self {
143        ContentNode::Text(StyledText {
144            spans: vec![StyledSpan {
145                text: text.into(),
146                style,
147            }],
148        })
149    }
150
151    /// Apply foreground color to this node.
152    pub fn with_fg(self, color: Color) -> Self {
153        match self {
154            ContentNode::Text(mut st) => {
155                for span in &mut st.spans {
156                    span.style.fg = Some(color.clone());
157                }
158                ContentNode::Text(st)
159            }
160            other => other,
161        }
162    }
163
164    /// Apply background color.
165    pub fn with_bg(self, color: Color) -> Self {
166        match self {
167            ContentNode::Text(mut st) => {
168                for span in &mut st.spans {
169                    span.style.bg = Some(color.clone());
170                }
171                ContentNode::Text(st)
172            }
173            other => other,
174        }
175    }
176
177    /// Apply bold.
178    pub fn with_bold(self) -> Self {
179        match self {
180            ContentNode::Text(mut st) => {
181                for span in &mut st.spans {
182                    span.style.bold = true;
183                }
184                ContentNode::Text(st)
185            }
186            other => other,
187        }
188    }
189
190    /// Apply italic.
191    pub fn with_italic(self) -> Self {
192        match self {
193            ContentNode::Text(mut st) => {
194                for span in &mut st.spans {
195                    span.style.italic = true;
196                }
197                ContentNode::Text(st)
198            }
199            other => other,
200        }
201    }
202
203    /// Apply underline.
204    pub fn with_underline(self) -> Self {
205        match self {
206            ContentNode::Text(mut st) => {
207                for span in &mut st.spans {
208                    span.style.underline = true;
209                }
210                ContentNode::Text(st)
211            }
212            other => other,
213        }
214    }
215
216    /// Apply dim.
217    pub fn with_dim(self) -> Self {
218        match self {
219            ContentNode::Text(mut st) => {
220                for span in &mut st.spans {
221                    span.style.dim = true;
222                }
223                ContentNode::Text(st)
224            }
225            other => other,
226        }
227    }
228}
229
230impl fmt::Display for ContentNode {
231    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
232        match self {
233            ContentNode::Text(st) => {
234                for span in &st.spans {
235                    write!(f, "{}", span.text)?;
236                }
237                Ok(())
238            }
239            ContentNode::Table(table) => {
240                if !table.headers.is_empty() {
241                    for (i, header) in table.headers.iter().enumerate() {
242                        if i > 0 {
243                            write!(f, " | ")?;
244                        }
245                        write!(f, "{}", header)?;
246                    }
247                    writeln!(f)?;
248                    for (i, _) in table.headers.iter().enumerate() {
249                        if i > 0 {
250                            write!(f, "-+-")?;
251                        }
252                        write!(f, "---")?;
253                    }
254                    writeln!(f)?;
255                }
256                let limit = table.max_rows.unwrap_or(table.rows.len());
257                for row in table.rows.iter().take(limit) {
258                    for (i, cell) in row.iter().enumerate() {
259                        if i > 0 {
260                            write!(f, " | ")?;
261                        }
262                        write!(f, "{}", cell)?;
263                    }
264                    writeln!(f)?;
265                }
266                Ok(())
267            }
268            ContentNode::Code { source, .. } => write!(f, "{}", source),
269            ContentNode::Chart(spec) => {
270                write!(
271                    f,
272                    "[Chart: {}]",
273                    spec.title.as_deref().unwrap_or("untitled")
274                )
275            }
276            ContentNode::KeyValue(pairs) => {
277                for (i, (key, value)) in pairs.iter().enumerate() {
278                    if i > 0 {
279                        writeln!(f)?;
280                    }
281                    write!(f, "{}: {}", key, value)?;
282                }
283                Ok(())
284            }
285            ContentNode::Fragment(parts) => {
286                for part in parts {
287                    write!(f, "{}", part)?;
288                }
289                Ok(())
290            }
291        }
292    }
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    #[test]
300    fn test_plain_text_node() {
301        let node = ContentNode::plain("hello world");
302        match &node {
303            ContentNode::Text(st) => {
304                assert_eq!(st.spans.len(), 1);
305                assert_eq!(st.spans[0].text, "hello world");
306                assert_eq!(st.spans[0].style, Style::default());
307            }
308            _ => panic!("expected Text variant"),
309        }
310    }
311
312    #[test]
313    fn test_styled_text_node() {
314        let style = Style {
315            bold: true,
316            fg: Some(Color::Named(NamedColor::Red)),
317            ..Default::default()
318        };
319        let node = ContentNode::styled("warning", style.clone());
320        match &node {
321            ContentNode::Text(st) => {
322                assert_eq!(st.spans.len(), 1);
323                assert_eq!(st.spans[0].text, "warning");
324                assert_eq!(st.spans[0].style, style);
325            }
326            _ => panic!("expected Text variant"),
327        }
328    }
329
330    #[test]
331    fn test_content_node_display() {
332        assert_eq!(ContentNode::plain("hello").to_string(), "hello");
333
334        let code = ContentNode::Code {
335            language: Some("rust".into()),
336            source: "fn main() {}".into(),
337        };
338        assert_eq!(code.to_string(), "fn main() {}");
339
340        let chart = ContentNode::Chart(ChartSpec {
341            chart_type: ChartType::Line,
342            series: vec![],
343            title: Some("My Chart".into()),
344            x_label: None,
345            y_label: None,
346            width: None,
347            height: None,
348            echarts_options: None,
349            interactive: true,
350        });
351        assert_eq!(chart.to_string(), "[Chart: My Chart]");
352
353        let chart_no_title = ContentNode::Chart(ChartSpec {
354            chart_type: ChartType::Bar,
355            series: vec![],
356            title: None,
357            x_label: None,
358            y_label: None,
359            width: None,
360            height: None,
361            echarts_options: None,
362            interactive: true,
363        });
364        assert_eq!(chart_no_title.to_string(), "[Chart: untitled]");
365    }
366
367    #[test]
368    fn test_with_fg_color() {
369        let node = ContentNode::plain("text").with_fg(Color::Named(NamedColor::Green));
370        match &node {
371            ContentNode::Text(st) => {
372                assert_eq!(st.spans[0].style.fg, Some(Color::Named(NamedColor::Green)));
373            }
374            _ => panic!("expected Text variant"),
375        }
376    }
377
378    #[test]
379    fn test_with_bold() {
380        let node = ContentNode::plain("text").with_bold();
381        match &node {
382            ContentNode::Text(st) => {
383                assert!(st.spans[0].style.bold);
384            }
385            _ => panic!("expected Text variant"),
386        }
387    }
388
389    #[test]
390    fn test_with_italic() {
391        let node = ContentNode::plain("text").with_italic();
392        match &node {
393            ContentNode::Text(st) => {
394                assert!(st.spans[0].style.italic);
395            }
396            _ => panic!("expected Text variant"),
397        }
398    }
399
400    #[test]
401    fn test_with_underline() {
402        let node = ContentNode::plain("text").with_underline();
403        match &node {
404            ContentNode::Text(st) => {
405                assert!(st.spans[0].style.underline);
406            }
407            _ => panic!("expected Text variant"),
408        }
409    }
410
411    #[test]
412    fn test_with_dim() {
413        let node = ContentNode::plain("text").with_dim();
414        match &node {
415            ContentNode::Text(st) => {
416                assert!(st.spans[0].style.dim);
417            }
418            _ => panic!("expected Text variant"),
419        }
420    }
421
422    #[test]
423    fn test_with_bg_color() {
424        let node = ContentNode::plain("text").with_bg(Color::Rgb(255, 0, 0));
425        match &node {
426            ContentNode::Text(st) => {
427                assert_eq!(st.spans[0].style.bg, Some(Color::Rgb(255, 0, 0)));
428            }
429            _ => panic!("expected Text variant"),
430        }
431    }
432
433    #[test]
434    fn test_style_chaining() {
435        let node = ContentNode::plain("text")
436            .with_bold()
437            .with_fg(Color::Named(NamedColor::Cyan))
438            .with_underline();
439        match &node {
440            ContentNode::Text(st) => {
441                assert!(st.spans[0].style.bold);
442                assert!(st.spans[0].style.underline);
443                assert_eq!(st.spans[0].style.fg, Some(Color::Named(NamedColor::Cyan)));
444            }
445            _ => panic!("expected Text variant"),
446        }
447    }
448
449    #[test]
450    fn test_non_text_node_style_passthrough() {
451        let code = ContentNode::Code {
452            language: None,
453            source: "x = 1".into(),
454        };
455        let result = code.with_bold();
456        match &result {
457            ContentNode::Code { source, .. } => assert_eq!(source, "x = 1"),
458            _ => panic!("expected Code variant"),
459        }
460    }
461
462    #[test]
463    fn test_fragment_composition() {
464        let frag = ContentNode::Fragment(vec![
465            ContentNode::plain("hello "),
466            ContentNode::plain("world"),
467        ]);
468        assert_eq!(frag.to_string(), "hello world");
469    }
470
471    #[test]
472    fn test_key_value_display() {
473        let kv = ContentNode::KeyValue(vec![
474            ("name".into(), ContentNode::plain("Alice")),
475            ("age".into(), ContentNode::plain("30")),
476        ]);
477        assert_eq!(kv.to_string(), "name: Alice\nage: 30");
478    }
479
480    #[test]
481    fn test_table_display() {
482        let table = ContentNode::Table(ContentTable {
483            headers: vec!["Name".into(), "Value".into()],
484            rows: vec![
485                vec![ContentNode::plain("a"), ContentNode::plain("1")],
486                vec![ContentNode::plain("b"), ContentNode::plain("2")],
487            ],
488            border: BorderStyle::default(),
489            max_rows: None,
490            column_types: None,
491            total_rows: None,
492            sortable: false,
493        });
494        let output = table.to_string();
495        assert!(output.contains("Name"));
496        assert!(output.contains("Value"));
497        assert!(output.contains("a"));
498        assert!(output.contains("1"));
499        assert!(output.contains("b"));
500        assert!(output.contains("2"));
501    }
502
503    #[test]
504    fn test_table_max_rows() {
505        let table = ContentNode::Table(ContentTable {
506            headers: vec!["X".into()],
507            rows: vec![
508                vec![ContentNode::plain("1")],
509                vec![ContentNode::plain("2")],
510                vec![ContentNode::plain("3")],
511            ],
512            border: BorderStyle::None,
513            max_rows: Some(2),
514            column_types: None,
515            total_rows: None,
516            sortable: false,
517        });
518        let output = table.to_string();
519        assert!(output.contains("1"));
520        assert!(output.contains("2"));
521        assert!(!output.contains("3"));
522    }
523
524    #[test]
525    fn test_content_node_equality() {
526        let a = ContentNode::plain("hello");
527        let b = ContentNode::plain("hello");
528        let c = ContentNode::plain("world");
529        assert_eq!(a, b);
530        assert_ne!(a, c);
531    }
532
533    #[test]
534    fn test_border_style_default() {
535        assert_eq!(BorderStyle::default(), BorderStyle::Rounded);
536    }
537}