Skip to main content

xript_ratatui/
render.rs

1use std::collections::HashMap;
2
3use ratatui::layout::{Layout, Rect};
4use ratatui::style::Stylize;
5use ratatui::text::{Line, Span};
6use ratatui::widgets::{
7    Block, Borders as RBorders, BorderType as RBorderType, Gauge, List, Paragraph, Sparkline,
8};
9use ratatui::Frame;
10use serde_json::Value;
11
12use crate::parser::{
13    value_to_display_string, BlockProps, BorderType, Borders, GaugeProps, LineNode, SpanNode,
14    WidgetNode,
15};
16
17pub fn render_fragment(
18    frame: &mut Frame,
19    area: Rect,
20    node: &WidgetNode,
21    bindings: &HashMap<String, Value>,
22) {
23    match node {
24        WidgetNode::Block { props, children } => {
25            render_block(frame, area, props, children, bindings);
26        }
27        WidgetNode::Paragraph { props, lines } => {
28            if !evaluate_data_if(&props.data_if, bindings) {
29                return;
30            }
31            render_paragraph(frame, area, props, lines, bindings);
32        }
33        WidgetNode::Gauge { props } => {
34            if !evaluate_data_if(&props.data_if, bindings) {
35                return;
36            }
37            render_gauge(frame, area, props, bindings);
38        }
39        WidgetNode::Layout { props, children } => {
40            if !evaluate_data_if(&props.data_if, bindings) {
41                return;
42            }
43            render_layout(frame, area, props, children, bindings);
44        }
45        WidgetNode::List { props, items } => {
46            if !evaluate_data_if(&props.data_if, bindings) {
47                return;
48            }
49            render_list(frame, area, props, items);
50        }
51        WidgetNode::Table { props, rows } => {
52            if !evaluate_data_if(&props.data_if, bindings) {
53                return;
54            }
55            render_table(frame, area, props, rows);
56        }
57        WidgetNode::Sparkline { props } => {
58            if !evaluate_data_if(&props.data_if, bindings) {
59                return;
60            }
61            render_sparkline(frame, area, props, bindings);
62        }
63        WidgetNode::Text(s) => {
64            let p = Paragraph::new(s.as_str());
65            frame.render_widget(p, area);
66        }
67    }
68}
69
70fn render_block(
71    frame: &mut Frame,
72    area: Rect,
73    props: &BlockProps,
74    children: &[WidgetNode],
75    bindings: &HashMap<String, Value>,
76) {
77    let mut block = Block::new();
78
79    if let Some(ref title) = props.title {
80        block = block.title(title.as_str());
81    }
82
83    block = block.borders(to_ratatui_borders(props.borders));
84    block = block.border_type(to_ratatui_border_type(props.border_type));
85    block = block.border_style(props.border_style);
86
87    let inner = block.inner(area);
88    frame.render_widget(block, area);
89
90    for child in children {
91        render_fragment(frame, inner, child, bindings);
92    }
93}
94
95fn render_paragraph(
96    frame: &mut Frame,
97    area: Rect,
98    props: &crate::parser::ParagraphProps,
99    lines: &[LineNode],
100    bindings: &HashMap<String, Value>,
101) {
102    let ratatui_lines: Vec<Line> = lines
103        .iter()
104        .map(|line_node| {
105            let spans: Vec<Span> = line_node
106                .spans
107                .iter()
108                .map(|span_node| match span_node {
109                    SpanNode::Text(s) => Span::raw(s.clone()),
110                    SpanNode::Styled { props, text } => {
111                        let display_text = if let Some(ref bind_name) = props.data_bind {
112                            bindings
113                                .get(bind_name)
114                                .map(value_to_display_string)
115                                .unwrap_or_else(|| text.clone())
116                        } else {
117                            text.clone()
118                        };
119                        Span::styled(display_text, props.style)
120                    }
121                })
122                .collect();
123            Line::from(spans)
124        })
125        .collect();
126
127    let paragraph = Paragraph::new(ratatui_lines)
128        .alignment(props.alignment)
129        .style(props.style);
130    frame.render_widget(paragraph, area);
131}
132
133fn render_gauge(
134    frame: &mut Frame,
135    area: Rect,
136    props: &GaugeProps,
137    bindings: &HashMap<String, Value>,
138) {
139    let ratio = if let Some(ref rb) = props.ratio_bind {
140        let num = bindings
141            .get(&rb.numerator)
142            .and_then(|v| v.as_f64())
143            .unwrap_or(0.0);
144        let den = bindings
145            .get(&rb.denominator)
146            .and_then(|v| v.as_f64())
147            .unwrap_or(1.0);
148        if den == 0.0 {
149            0.0
150        } else {
151            (num / den).clamp(0.0, 1.0)
152        }
153    } else if let Some(ref bind_name) = props.data_bind {
154        bindings
155            .get(bind_name)
156            .and_then(|v| v.as_f64())
157            .map(|v| (v / 100.0).clamp(0.0, 1.0))
158            .unwrap_or(0.0)
159    } else {
160        0.0
161    };
162
163    let mut gauge = Gauge::default()
164        .ratio(ratio)
165        .gauge_style(props.gauge_style);
166
167    if let Some(ref label_text) = props.label {
168        gauge = gauge.label(label_text.as_str());
169    }
170
171    frame.render_widget(gauge, area);
172}
173
174fn render_layout(
175    frame: &mut Frame,
176    area: Rect,
177    props: &crate::parser::LayoutProps,
178    children: &[WidgetNode],
179    bindings: &HashMap<String, Value>,
180) {
181    let chunks = Layout::default()
182        .direction(props.direction)
183        .constraints(&props.constraints)
184        .split(area);
185
186    for (i, child) in children.iter().enumerate() {
187        if i < chunks.len() {
188            render_fragment(frame, chunks[i], child, bindings);
189        }
190    }
191}
192
193fn render_list(
194    frame: &mut Frame,
195    area: Rect,
196    props: &crate::parser::ListProps,
197    items: &[String],
198) {
199    let list_items: Vec<&str> = items.iter().map(String::as_str).collect();
200    let list = List::new(list_items).style(props.style);
201    frame.render_widget(list, area);
202}
203
204fn render_table(
205    frame: &mut Frame,
206    area: Rect,
207    props: &crate::parser::TableProps,
208    rows: &[Vec<String>],
209) {
210    use ratatui::widgets::{Row, Table};
211
212    let table_rows: Vec<Row> = rows
213        .iter()
214        .map(|cells| Row::new(cells.iter().map(String::as_str).collect::<Vec<_>>()))
215        .collect();
216
217    let mut table = Table::new(table_rows, &props.widths).style(props.style);
218
219    if let Some(ref header) = props.header {
220        let header_row = Row::new(header.iter().map(String::as_str).collect::<Vec<_>>()).bold();
221        table = table.header(header_row);
222    }
223
224    frame.render_widget(table, area);
225}
226
227fn render_sparkline(
228    frame: &mut Frame,
229    area: Rect,
230    props: &crate::parser::SparklineProps,
231    bindings: &HashMap<String, Value>,
232) {
233    let data: Vec<u64> = if let Some(ref bind_name) = props.data_bind {
234        bindings
235            .get(bind_name)
236            .and_then(|v| v.as_array())
237            .map(|arr| arr.iter().filter_map(|v| v.as_u64()).collect())
238            .unwrap_or_else(|| props.data.clone())
239    } else {
240        props.data.clone()
241    };
242
243    let sparkline = Sparkline::default().data(&data).style(props.style);
244    frame.render_widget(sparkline, area);
245}
246
247fn to_ratatui_borders(b: Borders) -> RBorders {
248    match b {
249        Borders::None => RBorders::NONE,
250        Borders::All => RBorders::ALL,
251        Borders::Top => RBorders::TOP,
252        Borders::Bottom => RBorders::BOTTOM,
253        Borders::Left => RBorders::LEFT,
254        Borders::Right => RBorders::RIGHT,
255    }
256}
257
258fn to_ratatui_border_type(bt: BorderType) -> RBorderType {
259    match bt {
260        BorderType::Plain => RBorderType::Plain,
261        BorderType::Rounded => RBorderType::Rounded,
262        BorderType::Double => RBorderType::Double,
263        BorderType::Thick => RBorderType::Thick,
264    }
265}
266
267fn evaluate_data_if(expr: &Option<String>, bindings: &HashMap<String, Value>) -> bool {
268    let expression = match expr {
269        Some(e) => e,
270        None => return true,
271    };
272
273    let trimmed = expression.trim();
274
275    if let Some(val) = bindings.get(trimmed) {
276        return is_truthy(val);
277    }
278
279    if let Some(result) = try_comparison(trimmed, "<", bindings, |a, b| a < b) {
280        return result;
281    }
282    if let Some(result) = try_comparison(trimmed, "<=", bindings, |a, b| a <= b) {
283        return result;
284    }
285    if let Some(result) = try_comparison(trimmed, ">=", bindings, |a, b| a >= b) {
286        return result;
287    }
288    if let Some(result) = try_comparison(trimmed, ">", bindings, |a, b| a > b) {
289        return result;
290    }
291    if let Some(result) = try_comparison(trimmed, "!=", bindings, |a, b| (a - b).abs() >= f64::EPSILON) {
292        return result;
293    }
294    if let Some(result) = try_comparison(trimmed, "==", bindings, |a, b| (a - b).abs() < f64::EPSILON) {
295        return result;
296    }
297
298    false
299}
300
301fn try_comparison(
302    expr: &str,
303    op: &str,
304    bindings: &HashMap<String, Value>,
305    cmp: fn(f64, f64) -> bool,
306) -> Option<bool> {
307    let parts: Vec<&str> = expr.splitn(2, op).collect();
308    if parts.len() != 2 {
309        return None;
310    }
311
312    let var_name = parts[0].trim();
313    let rhs_str = parts[1].trim();
314
315    if var_name.is_empty() || rhs_str.is_empty() {
316        return None;
317    }
318
319    if var_name.chars().next()?.is_ascii_digit() {
320        return None;
321    }
322
323    let rhs: f64 = rhs_str.parse().ok()?;
324    let lhs = bindings.get(var_name)?.as_f64()?;
325
326    Some(cmp(lhs, rhs))
327}
328
329fn is_truthy(val: &Value) -> bool {
330    match val {
331        Value::Null => false,
332        Value::Bool(b) => *b,
333        Value::Number(n) => n.as_f64().map_or(false, |v| v != 0.0),
334        Value::String(s) => !s.is_empty(),
335        Value::Array(_) | Value::Object(_) => true,
336    }
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342    use crate::parser::parse_fragment;
343    use ratatui::backend::TestBackend;
344    use ratatui::Terminal;
345    use serde_json::json;
346
347    fn render_to_test_terminal(
348        node: &WidgetNode,
349        bindings: &HashMap<String, Value>,
350        width: u16,
351        height: u16,
352    ) {
353        let backend = TestBackend::new(width, height);
354        let mut terminal = Terminal::new(backend).unwrap();
355        terminal
356            .draw(|frame| {
357                let area = frame.area();
358                render_fragment(frame, area, node, bindings);
359            })
360            .unwrap();
361    }
362
363    #[test]
364    fn renders_text_node_without_panic() {
365        let node = WidgetNode::Text("Hello".to_string());
366        render_to_test_terminal(&node, &HashMap::new(), 40, 5);
367    }
368
369    #[test]
370    fn renders_block_without_panic() {
371        let json = json!(["Block", {"title": "Test", "borders": "ALL", "border_type": "Rounded"}]);
372        let node = parse_fragment(&json, &HashMap::new()).unwrap();
373        render_to_test_terminal(&node, &HashMap::new(), 40, 10);
374    }
375
376    #[test]
377    fn renders_paragraph_without_panic() {
378        let json = json!(["Paragraph", {"alignment": "Center"},
379            ["Line", {},
380                ["Span", {"style": {"fg": "Green"}}, "Hello "],
381                ["Span", {"style": {"fg": "White", "mod": ["BOLD"]}}, "World"]
382            ]
383        ]);
384        let node = parse_fragment(&json, &HashMap::new()).unwrap();
385        render_to_test_terminal(&node, &HashMap::new(), 40, 5);
386    }
387
388    #[test]
389    fn renders_gauge_with_bindings() {
390        let json = json!(["Gauge", {
391            "ratio_bind": {"numerator": "health", "denominator": "maxHealth"},
392            "gauge_style": {"fg": "Green"}
393        }]);
394        let mut bindings = HashMap::new();
395        bindings.insert("health".to_string(), json!(75));
396        bindings.insert("maxHealth".to_string(), json!(100));
397
398        let node = parse_fragment(&json, &bindings).unwrap();
399        render_to_test_terminal(&node, &bindings, 40, 3);
400    }
401
402    #[test]
403    fn renders_layout_with_children() {
404        let json = json!(["Layout", {
405            "direction": "Vertical",
406            "constraints": ["Length:1", "Length:1"]
407        },
408            ["Paragraph", {}, ["Line", {}, ["Span", {}, "Row 1"]]],
409            ["Paragraph", {}, ["Line", {}, ["Span", {}, "Row 2"]]]
410        ]);
411        let node = parse_fragment(&json, &HashMap::new()).unwrap();
412        render_to_test_terminal(&node, &HashMap::new(), 40, 5);
413    }
414
415    #[test]
416    fn renders_list_without_panic() {
417        let json = json!(["List", {}, "Item A", "Item B", "Item C"]);
418        let node = parse_fragment(&json, &HashMap::new()).unwrap();
419        render_to_test_terminal(&node, &HashMap::new(), 40, 5);
420    }
421
422    #[test]
423    fn renders_table_without_panic() {
424        let json = json!(["Table", {
425            "header": ["Name", "Score"],
426            "widths": ["Percentage:50", "Percentage:50"]
427        },
428            ["Alice", "100"],
429            ["Bob", "95"]
430        ]);
431        let node = parse_fragment(&json, &HashMap::new()).unwrap();
432        render_to_test_terminal(&node, &HashMap::new(), 40, 8);
433    }
434
435    #[test]
436    fn renders_sparkline_without_panic() {
437        let json = json!(["Sparkline", {"data": [1, 3, 5, 2, 8], "style": {"fg": "Yellow"}}]);
438        let node = parse_fragment(&json, &HashMap::new()).unwrap();
439        render_to_test_terminal(&node, &HashMap::new(), 40, 3);
440    }
441
442    #[test]
443    fn data_if_hides_widget_when_false() {
444        let json = json!(["Paragraph", {"data-if": "health < 50"},
445            ["Line", {}, ["Span", {}, "Warning!"]]
446        ]);
447        let mut bindings = HashMap::new();
448        bindings.insert("health".to_string(), json!(80));
449
450        let node = parse_fragment(&json, &bindings).unwrap();
451
452        let backend = TestBackend::new(40, 3);
453        let mut terminal = Terminal::new(backend).unwrap();
454        terminal
455            .draw(|frame| {
456                let area = frame.area();
457                render_fragment(frame, area, &node, &bindings);
458            })
459            .unwrap();
460
461        let buf = terminal.backend().buffer().clone();
462        let content: String = (0..buf.area.width)
463            .map(|x| buf.cell((x, 0)).unwrap().symbol().to_string())
464            .collect();
465        assert!(!content.contains("Warning!"));
466    }
467
468    #[test]
469    fn data_if_shows_widget_when_true() {
470        let json = json!(["Paragraph", {"data-if": "health < 50"},
471            ["Line", {}, ["Span", {}, "Warning!"]]
472        ]);
473        let mut bindings = HashMap::new();
474        bindings.insert("health".to_string(), json!(30));
475
476        let node = parse_fragment(&json, &bindings).unwrap();
477
478        let backend = TestBackend::new(40, 3);
479        let mut terminal = Terminal::new(backend).unwrap();
480        terminal
481            .draw(|frame| {
482                let area = frame.area();
483                render_fragment(frame, area, &node, &bindings);
484            })
485            .unwrap();
486
487        let buf = terminal.backend().buffer().clone();
488        let content: String = (0..buf.area.width)
489            .map(|x| buf.cell((x, 0)).unwrap().symbol().to_string())
490            .collect();
491        assert!(content.contains("Warning!"));
492    }
493
494    #[test]
495    fn evaluate_data_if_handles_all_comparison_operators() {
496        let mut bindings = HashMap::new();
497        bindings.insert("val".to_string(), json!(50));
498
499        assert!(evaluate_data_if(&Some("val < 100".into()), &bindings));
500        assert!(!evaluate_data_if(&Some("val < 10".into()), &bindings));
501        assert!(evaluate_data_if(&Some("val > 10".into()), &bindings));
502        assert!(!evaluate_data_if(&Some("val > 100".into()), &bindings));
503        assert!(evaluate_data_if(&Some("val <= 50".into()), &bindings));
504        assert!(!evaluate_data_if(&Some("val <= 49".into()), &bindings));
505        assert!(evaluate_data_if(&Some("val >= 50".into()), &bindings));
506        assert!(!evaluate_data_if(&Some("val >= 51".into()), &bindings));
507        assert!(evaluate_data_if(&Some("val == 50".into()), &bindings));
508        assert!(!evaluate_data_if(&Some("val == 51".into()), &bindings));
509        assert!(evaluate_data_if(&Some("val != 51".into()), &bindings));
510        assert!(!evaluate_data_if(&Some("val != 50".into()), &bindings));
511    }
512
513    #[test]
514    fn evaluate_data_if_returns_true_when_none() {
515        assert!(evaluate_data_if(&None, &HashMap::new()));
516    }
517
518    #[test]
519    fn evaluate_data_if_truthy_binding() {
520        let mut bindings = HashMap::new();
521        bindings.insert("visible".to_string(), json!(true));
522        assert!(evaluate_data_if(&Some("visible".into()), &bindings));
523
524        bindings.insert("visible".to_string(), json!(false));
525        assert!(!evaluate_data_if(&Some("visible".into()), &bindings));
526    }
527
528    #[test]
529    fn renders_full_health_panel() {
530        let json = json!(["Block", {
531            "title": "Health",
532            "borders": "ALL",
533            "border_type": "Rounded",
534            "border_style": {"fg": "Cyan"}
535        },
536            ["Layout", {"direction": "Vertical", "constraints": ["Length:1", "Length:1", "Length:1"]},
537                ["Paragraph", {"alignment": "Left"},
538                    ["Line", {},
539                        ["Span", {"style": {"fg": "Gray"}}, "Player: "],
540                        ["Span", {"data-bind": "name", "style": {"fg": "White", "mod": ["BOLD"]}}, "Unknown"]
541                    ]
542                ],
543                ["Gauge", {
544                    "data-bind": "health",
545                    "ratio_bind": {"numerator": "health", "denominator": "maxHealth"},
546                    "gauge_style": {"fg": "Green"}
547                }],
548                ["Paragraph", {
549                    "data-if": "health < 50",
550                    "alignment": "Center",
551                    "style": {"fg": "Red", "mod": ["BOLD", "SLOW_BLINK"]}
552                },
553                    ["Line", {}, ["Span", {}, "LOW HEALTH WARNING"]]
554                ]
555            ]
556        ]);
557
558        let mut bindings = HashMap::new();
559        bindings.insert("name".to_string(), json!("Hero"));
560        bindings.insert("health".to_string(), json!(35));
561        bindings.insert("maxHealth".to_string(), json!(100));
562
563        let node = parse_fragment(&json, &bindings).unwrap();
564        render_to_test_terminal(&node, &bindings, 50, 10);
565    }
566
567    #[test]
568    fn gauge_clamps_ratio_to_valid_range() {
569        let json = json!(["Gauge", {
570            "ratio_bind": {"numerator": "health", "denominator": "maxHealth"},
571            "gauge_style": {"fg": "Green"}
572        }]);
573        let mut bindings = HashMap::new();
574        bindings.insert("health".to_string(), json!(150));
575        bindings.insert("maxHealth".to_string(), json!(100));
576
577        let node = parse_fragment(&json, &bindings).unwrap();
578        render_to_test_terminal(&node, &bindings, 40, 3);
579    }
580
581    #[test]
582    fn gauge_handles_zero_denominator() {
583        let json = json!(["Gauge", {
584            "ratio_bind": {"numerator": "health", "denominator": "maxHealth"},
585            "gauge_style": {"fg": "Green"}
586        }]);
587        let mut bindings = HashMap::new();
588        bindings.insert("health".to_string(), json!(50));
589        bindings.insert("maxHealth".to_string(), json!(0));
590
591        let node = parse_fragment(&json, &bindings).unwrap();
592        render_to_test_terminal(&node, &bindings, 40, 3);
593    }
594
595    #[test]
596    fn sparkline_resolves_data_bind() {
597        let json = json!(["Sparkline", {"data-bind": "history", "style": {"fg": "Cyan"}}]);
598        let mut bindings = HashMap::new();
599        bindings.insert("history".to_string(), json!([10, 20, 30, 40]));
600
601        let node = parse_fragment(&json, &bindings).unwrap();
602        render_to_test_terminal(&node, &bindings, 40, 3);
603    }
604}