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}