1use std::collections::HashMap;
2
3use ratatui::layout::{Alignment, Constraint, Direction};
4use ratatui::style::Style;
5use serde_json::Value;
6
7use crate::layout::{parse_constraint, parse_direction};
8use crate::style::parse_style;
9
10#[derive(Debug, Clone)]
11pub struct BlockProps {
12 pub title: Option<String>,
13 pub borders: Borders,
14 pub border_type: BorderType,
15 pub border_style: Style,
16}
17
18#[derive(Debug, Clone, Copy, PartialEq)]
19pub enum Borders {
20 None,
21 All,
22 Top,
23 Bottom,
24 Left,
25 Right,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq)]
29pub enum BorderType {
30 Plain,
31 Rounded,
32 Double,
33 Thick,
34}
35
36#[derive(Debug, Clone)]
37pub struct ParagraphProps {
38 pub alignment: Alignment,
39 pub style: Style,
40 pub data_if: Option<String>,
41}
42
43#[derive(Debug, Clone)]
44pub struct SpanProps {
45 pub style: Style,
46 pub data_bind: Option<String>,
47}
48
49#[derive(Debug, Clone)]
50pub struct LineNode {
51 pub spans: Vec<SpanNode>,
52}
53
54#[derive(Debug, Clone)]
55pub enum SpanNode {
56 Styled { props: SpanProps, text: String },
57 Text(String),
58}
59
60#[derive(Debug, Clone)]
61pub struct GaugeProps {
62 pub data_bind: Option<String>,
63 pub ratio_bind: Option<RatioBind>,
64 pub gauge_style: Style,
65 pub label: Option<String>,
66 pub data_if: Option<String>,
67}
68
69#[derive(Debug, Clone)]
70pub struct RatioBind {
71 pub numerator: String,
72 pub denominator: String,
73}
74
75#[derive(Debug, Clone)]
76pub struct LayoutProps {
77 pub direction: Direction,
78 pub constraints: Vec<Constraint>,
79 pub data_if: Option<String>,
80}
81
82#[derive(Debug, Clone)]
83pub struct ListProps {
84 pub style: Style,
85 pub data_if: Option<String>,
86}
87
88#[derive(Debug, Clone)]
89pub struct TableProps {
90 pub header: Option<Vec<String>>,
91 pub widths: Vec<Constraint>,
92 pub style: Style,
93 pub data_if: Option<String>,
94}
95
96#[derive(Debug, Clone)]
97pub struct SparklineProps {
98 pub style: Style,
99 pub data: Vec<u64>,
100 pub data_bind: Option<String>,
101 pub data_if: Option<String>,
102}
103
104#[derive(Debug, Clone)]
105pub enum WidgetNode {
106 Block {
107 props: BlockProps,
108 children: Vec<WidgetNode>,
109 },
110 Paragraph {
111 props: ParagraphProps,
112 lines: Vec<LineNode>,
113 },
114 Gauge {
115 props: GaugeProps,
116 },
117 Layout {
118 props: LayoutProps,
119 children: Vec<WidgetNode>,
120 },
121 List {
122 props: ListProps,
123 items: Vec<String>,
124 },
125 Table {
126 props: TableProps,
127 rows: Vec<Vec<String>>,
128 },
129 Sparkline {
130 props: SparklineProps,
131 },
132 Text(String),
133}
134
135pub fn parse_fragment(value: &Value, bindings: &HashMap<String, Value>) -> Option<WidgetNode> {
136 match value {
137 Value::String(s) => Some(WidgetNode::Text(s.clone())),
138 Value::Array(arr) if !arr.is_empty() => {
139 let tag = arr[0].as_str()?;
140 let (attrs, child_start) = extract_attrs(arr);
141 parse_widget(tag, &attrs, arr, child_start, bindings)
142 }
143 _ => None,
144 }
145}
146
147fn extract_attrs(arr: &[Value]) -> (Value, usize) {
148 if arr.len() > 1 {
149 if let Some(obj) = arr[1].as_object() {
150 return (Value::Object(obj.clone()), 2);
151 }
152 }
153 (Value::Object(serde_json::Map::new()), 1)
154}
155
156fn parse_widget(
157 tag: &str,
158 attrs: &Value,
159 arr: &[Value],
160 child_start: usize,
161 bindings: &HashMap<String, Value>,
162) -> Option<WidgetNode> {
163 match tag {
164 "Block" => parse_block(attrs, arr, child_start, bindings),
165 "Paragraph" => parse_paragraph(attrs, arr, child_start, bindings),
166 "Gauge" => parse_gauge(attrs),
167 "Layout" => parse_layout(attrs, arr, child_start, bindings),
168 "List" => parse_list(attrs, arr, child_start),
169 "Table" => parse_table(attrs, arr, child_start),
170 "Sparkline" => parse_sparkline(attrs),
171 _ => None,
172 }
173}
174
175fn parse_block(
176 attrs: &Value,
177 arr: &[Value],
178 child_start: usize,
179 bindings: &HashMap<String, Value>,
180) -> Option<WidgetNode> {
181 let obj = attrs.as_object();
182
183 let title = obj.and_then(|o| o.get("title")).and_then(|v| v.as_str()).map(String::from);
184 let borders = obj
185 .and_then(|o| o.get("borders"))
186 .and_then(|v| v.as_str())
187 .map(parse_borders)
188 .unwrap_or(Borders::None);
189 let border_type = obj
190 .and_then(|o| o.get("border_type"))
191 .and_then(|v| v.as_str())
192 .map(parse_border_type)
193 .unwrap_or(BorderType::Plain);
194 let border_style = obj
195 .and_then(|o| o.get("border_style"))
196 .map(|v| parse_style(v))
197 .unwrap_or_default();
198
199 let children = parse_children(arr, child_start, bindings);
200
201 Some(WidgetNode::Block {
202 props: BlockProps {
203 title,
204 borders,
205 border_type,
206 border_style,
207 },
208 children,
209 })
210}
211
212fn parse_paragraph(
213 attrs: &Value,
214 arr: &[Value],
215 child_start: usize,
216 bindings: &HashMap<String, Value>,
217) -> Option<WidgetNode> {
218 let obj = attrs.as_object();
219
220 let alignment = obj
221 .and_then(|o| o.get("alignment"))
222 .and_then(|v| v.as_str())
223 .map(parse_alignment)
224 .unwrap_or(Alignment::Left);
225 let style = obj
226 .and_then(|o| o.get("style"))
227 .map(|v| parse_style(v))
228 .unwrap_or_default();
229 let data_if = obj
230 .and_then(|o| o.get("data-if"))
231 .and_then(|v| v.as_str())
232 .map(String::from);
233
234 let mut lines = Vec::new();
235 for i in child_start..arr.len() {
236 if let Some(line) = parse_line_node(&arr[i], bindings) {
237 lines.push(line);
238 }
239 }
240
241 Some(WidgetNode::Paragraph {
242 props: ParagraphProps {
243 alignment,
244 style,
245 data_if,
246 },
247 lines,
248 })
249}
250
251fn parse_line_node(value: &Value, bindings: &HashMap<String, Value>) -> Option<LineNode> {
252 match value {
253 Value::String(s) => Some(LineNode {
254 spans: vec![SpanNode::Text(s.clone())],
255 }),
256 Value::Array(arr) if !arr.is_empty() => {
257 let tag = arr[0].as_str()?;
258 if tag != "Line" {
259 return None;
260 }
261 let (_, child_start) = extract_attrs(arr);
262 let mut spans = Vec::new();
263 for i in child_start..arr.len() {
264 if let Some(span) = parse_span_node(&arr[i], bindings) {
265 spans.push(span);
266 }
267 }
268 Some(LineNode { spans })
269 }
270 _ => None,
271 }
272}
273
274fn parse_span_node(value: &Value, bindings: &HashMap<String, Value>) -> Option<SpanNode> {
275 match value {
276 Value::String(s) => Some(SpanNode::Text(s.clone())),
277 Value::Array(arr) if !arr.is_empty() => {
278 let tag = arr[0].as_str()?;
279 if tag != "Span" {
280 return None;
281 }
282 let (attrs, child_start) = extract_attrs(arr);
283 let obj = attrs.as_object();
284
285 let style = obj
286 .and_then(|o| o.get("style"))
287 .map(|v| parse_style(v))
288 .unwrap_or_default();
289 let data_bind = obj
290 .and_then(|o| o.get("data-bind"))
291 .and_then(|v| v.as_str())
292 .map(String::from);
293
294 let mut text = String::new();
295 for i in child_start..arr.len() {
296 if let Some(s) = arr[i].as_str() {
297 text.push_str(s);
298 }
299 }
300
301 if let Some(ref bind_name) = data_bind {
302 if let Some(val) = bindings.get(bind_name) {
303 text = value_to_display_string(val);
304 }
305 }
306
307 Some(SpanNode::Styled {
308 props: SpanProps { style, data_bind },
309 text,
310 })
311 }
312 _ => None,
313 }
314}
315
316fn parse_gauge(attrs: &Value) -> Option<WidgetNode> {
317 let obj = attrs.as_object();
318
319 let data_bind = obj
320 .and_then(|o| o.get("data-bind"))
321 .and_then(|v| v.as_str())
322 .map(String::from);
323 let ratio_bind = obj.and_then(|o| o.get("ratio_bind")).and_then(|v| {
324 let rb = v.as_object()?;
325 Some(RatioBind {
326 numerator: rb.get("numerator")?.as_str()?.to_string(),
327 denominator: rb.get("denominator")?.as_str()?.to_string(),
328 })
329 });
330 let gauge_style = obj
331 .and_then(|o| o.get("gauge_style"))
332 .map(|v| parse_style(v))
333 .unwrap_or_default();
334 let label = obj
335 .and_then(|o| o.get("label"))
336 .and_then(|v| v.as_str())
337 .map(String::from);
338 let data_if = obj
339 .and_then(|o| o.get("data-if"))
340 .and_then(|v| v.as_str())
341 .map(String::from);
342
343 Some(WidgetNode::Gauge {
344 props: GaugeProps {
345 data_bind,
346 ratio_bind,
347 gauge_style,
348 label,
349 data_if,
350 },
351 })
352}
353
354fn parse_layout(
355 attrs: &Value,
356 arr: &[Value],
357 child_start: usize,
358 bindings: &HashMap<String, Value>,
359) -> Option<WidgetNode> {
360 let obj = attrs.as_object();
361
362 let direction = obj
363 .and_then(|o| o.get("direction"))
364 .and_then(|v| v.as_str())
365 .map(parse_direction)
366 .unwrap_or(Direction::Vertical);
367
368 let constraints = obj
369 .and_then(|o| o.get("constraints"))
370 .and_then(|v| v.as_array())
371 .map(|arr| {
372 arr.iter()
373 .filter_map(|v| v.as_str().and_then(parse_constraint))
374 .collect()
375 })
376 .unwrap_or_default();
377
378 let data_if = obj
379 .and_then(|o| o.get("data-if"))
380 .and_then(|v| v.as_str())
381 .map(String::from);
382
383 let children = parse_children(arr, child_start, bindings);
384
385 Some(WidgetNode::Layout {
386 props: LayoutProps {
387 direction,
388 constraints,
389 data_if,
390 },
391 children,
392 })
393}
394
395fn parse_list(attrs: &Value, arr: &[Value], child_start: usize) -> Option<WidgetNode> {
396 let obj = attrs.as_object();
397
398 let style = obj
399 .and_then(|o| o.get("style"))
400 .map(|v| parse_style(v))
401 .unwrap_or_default();
402 let data_if = obj
403 .and_then(|o| o.get("data-if"))
404 .and_then(|v| v.as_str())
405 .map(String::from);
406
407 let items: Vec<String> = (child_start..arr.len())
408 .filter_map(|i| arr[i].as_str().map(String::from))
409 .collect();
410
411 Some(WidgetNode::List {
412 props: ListProps { style, data_if },
413 items,
414 })
415}
416
417fn parse_table(attrs: &Value, arr: &[Value], child_start: usize) -> Option<WidgetNode> {
418 let obj = attrs.as_object();
419
420 let header = obj.and_then(|o| o.get("header")).and_then(|v| {
421 v.as_array().map(|a| {
422 a.iter()
423 .filter_map(|v| v.as_str().map(String::from))
424 .collect()
425 })
426 });
427 let widths = obj
428 .and_then(|o| o.get("widths"))
429 .and_then(|v| v.as_array())
430 .map(|a| {
431 a.iter()
432 .filter_map(|v| v.as_str().and_then(parse_constraint))
433 .collect()
434 })
435 .unwrap_or_default();
436 let style = obj
437 .and_then(|o| o.get("style"))
438 .map(|v| parse_style(v))
439 .unwrap_or_default();
440 let data_if = obj
441 .and_then(|o| o.get("data-if"))
442 .and_then(|v| v.as_str())
443 .map(String::from);
444
445 let rows: Vec<Vec<String>> = (child_start..arr.len())
446 .filter_map(|i| {
447 arr[i].as_array().map(|row| {
448 row.iter()
449 .filter_map(|v| v.as_str().map(String::from))
450 .collect()
451 })
452 })
453 .collect();
454
455 Some(WidgetNode::Table {
456 props: TableProps {
457 header,
458 widths,
459 style,
460 data_if,
461 },
462 rows,
463 })
464}
465
466fn parse_sparkline(attrs: &Value) -> Option<WidgetNode> {
467 let obj = attrs.as_object();
468
469 let style = obj
470 .and_then(|o| o.get("style"))
471 .map(|v| parse_style(v))
472 .unwrap_or_default();
473 let data = obj
474 .and_then(|o| o.get("data"))
475 .and_then(|v| v.as_array())
476 .map(|a| a.iter().filter_map(|v| v.as_u64()).collect())
477 .unwrap_or_default();
478 let data_bind = obj
479 .and_then(|o| o.get("data-bind"))
480 .and_then(|v| v.as_str())
481 .map(String::from);
482 let data_if = obj
483 .and_then(|o| o.get("data-if"))
484 .and_then(|v| v.as_str())
485 .map(String::from);
486
487 Some(WidgetNode::Sparkline {
488 props: SparklineProps {
489 style,
490 data,
491 data_bind,
492 data_if,
493 },
494 })
495}
496
497fn parse_children(
498 arr: &[Value],
499 child_start: usize,
500 bindings: &HashMap<String, Value>,
501) -> Vec<WidgetNode> {
502 (child_start..arr.len())
503 .filter_map(|i| parse_fragment(&arr[i], bindings))
504 .collect()
505}
506
507fn parse_borders(s: &str) -> Borders {
508 match s {
509 "ALL" => Borders::All,
510 "TOP" => Borders::Top,
511 "BOTTOM" => Borders::Bottom,
512 "LEFT" => Borders::Left,
513 "RIGHT" => Borders::Right,
514 "NONE" => Borders::None,
515 _ => Borders::None,
516 }
517}
518
519fn parse_border_type(s: &str) -> BorderType {
520 match s {
521 "Plain" => BorderType::Plain,
522 "Rounded" => BorderType::Rounded,
523 "Double" => BorderType::Double,
524 "Thick" => BorderType::Thick,
525 _ => BorderType::Plain,
526 }
527}
528
529fn parse_alignment(s: &str) -> Alignment {
530 match s {
531 "Left" => Alignment::Left,
532 "Center" => Alignment::Center,
533 "Right" => Alignment::Right,
534 _ => Alignment::Left,
535 }
536}
537
538pub fn value_to_display_string(val: &Value) -> String {
539 match val {
540 Value::String(s) => s.clone(),
541 Value::Null => String::new(),
542 other => other.to_string(),
543 }
544}
545
546#[cfg(test)]
547mod tests {
548 use super::*;
549 use serde_json::json;
550
551 #[test]
552 fn parses_text_node() {
553 let node = parse_fragment(&json!("hello"), &HashMap::new());
554 assert!(matches!(node, Some(WidgetNode::Text(ref s)) if s == "hello"));
555 }
556
557 #[test]
558 fn parses_block_with_title() {
559 let json = json!(["Block", {"title": "Health", "borders": "ALL"}]);
560 let node = parse_fragment(&json, &HashMap::new()).unwrap();
561 match node {
562 WidgetNode::Block { props, children } => {
563 assert_eq!(props.title.as_deref(), Some("Health"));
564 assert_eq!(props.borders, Borders::All);
565 assert!(children.is_empty());
566 }
567 _ => panic!("expected Block"),
568 }
569 }
570
571 #[test]
572 fn parses_block_with_rounded_border() {
573 let json = json!(["Block", {"border_type": "Rounded"}]);
574 let node = parse_fragment(&json, &HashMap::new()).unwrap();
575 match node {
576 WidgetNode::Block { props, .. } => {
577 assert_eq!(props.border_type, BorderType::Rounded);
578 }
579 _ => panic!("expected Block"),
580 }
581 }
582
583 #[test]
584 fn parses_paragraph_with_lines() {
585 let json = json!(["Paragraph", {"alignment": "Center"},
586 ["Line", {},
587 ["Span", {"style": {"fg": "Green"}}, "hello"]
588 ]
589 ]);
590 let node = parse_fragment(&json, &HashMap::new()).unwrap();
591 match node {
592 WidgetNode::Paragraph { props, lines } => {
593 assert_eq!(props.alignment, Alignment::Center);
594 assert_eq!(lines.len(), 1);
595 assert_eq!(lines[0].spans.len(), 1);
596 }
597 _ => panic!("expected Paragraph"),
598 }
599 }
600
601 #[test]
602 fn parses_gauge_with_ratio_bind() {
603 let json = json!(["Gauge", {
604 "data-bind": "health",
605 "ratio_bind": {"numerator": "health", "denominator": "maxHealth"},
606 "gauge_style": {"fg": "Green"}
607 }]);
608 let node = parse_fragment(&json, &HashMap::new()).unwrap();
609 match node {
610 WidgetNode::Gauge { props } => {
611 assert_eq!(props.data_bind.as_deref(), Some("health"));
612 let rb = props.ratio_bind.unwrap();
613 assert_eq!(rb.numerator, "health");
614 assert_eq!(rb.denominator, "maxHealth");
615 }
616 _ => panic!("expected Gauge"),
617 }
618 }
619
620 #[test]
621 fn parses_layout_with_constraints() {
622 let json = json!(["Layout", {
623 "direction": "Vertical",
624 "constraints": ["Length:1", "Length:2", "Fill:1"]
625 }]);
626 let node = parse_fragment(&json, &HashMap::new()).unwrap();
627 match node {
628 WidgetNode::Layout { props, .. } => {
629 assert_eq!(props.direction, Direction::Vertical);
630 assert_eq!(props.constraints.len(), 3);
631 }
632 _ => panic!("expected Layout"),
633 }
634 }
635
636 #[test]
637 fn parses_list_items() {
638 let json = json!(["List", {"style": {"fg": "White"}}, "Item 1", "Item 2", "Item 3"]);
639 let node = parse_fragment(&json, &HashMap::new()).unwrap();
640 match node {
641 WidgetNode::List { props: _, items } => {
642 assert_eq!(items, vec!["Item 1", "Item 2", "Item 3"]);
643 }
644 _ => panic!("expected List"),
645 }
646 }
647
648 #[test]
649 fn parses_table_with_header_and_rows() {
650 let json = json!(["Table", {
651 "header": ["Name", "Value"],
652 "widths": ["Percentage:50", "Percentage:50"]
653 },
654 ["Alice", "100"],
655 ["Bob", "200"]
656 ]);
657 let node = parse_fragment(&json, &HashMap::new()).unwrap();
658 match node {
659 WidgetNode::Table { props, rows } => {
660 assert_eq!(props.header.as_ref().unwrap(), &["Name", "Value"]);
661 assert_eq!(props.widths.len(), 2);
662 assert_eq!(rows.len(), 2);
663 assert_eq!(rows[0], vec!["Alice", "100"]);
664 }
665 _ => panic!("expected Table"),
666 }
667 }
668
669 #[test]
670 fn parses_sparkline_with_data() {
671 let json = json!(["Sparkline", {
672 "data": [1, 3, 5, 2, 8],
673 "style": {"fg": "Yellow"}
674 }]);
675 let node = parse_fragment(&json, &HashMap::new()).unwrap();
676 match node {
677 WidgetNode::Sparkline { props } => {
678 assert_eq!(props.data, vec![1, 3, 5, 2, 8]);
679 }
680 _ => panic!("expected Sparkline"),
681 }
682 }
683
684 #[test]
685 fn resolves_data_bind_on_span() {
686 let mut bindings = HashMap::new();
687 bindings.insert("name".to_string(), json!("Hero"));
688
689 let json = json!(["Paragraph", {},
690 ["Line", {},
691 ["Span", {"data-bind": "name"}, "Unknown"]
692 ]
693 ]);
694 let node = parse_fragment(&json, &bindings).unwrap();
695 match node {
696 WidgetNode::Paragraph { lines, .. } => {
697 match &lines[0].spans[0] {
698 SpanNode::Styled { text, .. } => assert_eq!(text, "Hero"),
699 _ => panic!("expected Styled span"),
700 }
701 }
702 _ => panic!("expected Paragraph"),
703 }
704 }
705
706 #[test]
707 fn preserves_data_if_expression() {
708 let json = json!(["Paragraph", {"data-if": "health < 50"},
709 ["Line", {}, ["Span", {}, "Warning!"]]
710 ]);
711 let node = parse_fragment(&json, &HashMap::new()).unwrap();
712 match node {
713 WidgetNode::Paragraph { props, .. } => {
714 assert_eq!(props.data_if.as_deref(), Some("health < 50"));
715 }
716 _ => panic!("expected Paragraph"),
717 }
718 }
719
720 #[test]
721 fn parses_nested_block_with_layout() {
722 let json = json!(["Block", {"title": "Panel", "borders": "ALL"},
723 ["Layout", {"direction": "Vertical", "constraints": ["Length:1"]},
724 ["Paragraph", {}, ["Line", {}, ["Span", {}, "Content"]]]
725 ]
726 ]);
727 let node = parse_fragment(&json, &HashMap::new()).unwrap();
728 match node {
729 WidgetNode::Block { children, .. } => {
730 assert_eq!(children.len(), 1);
731 assert!(matches!(children[0], WidgetNode::Layout { .. }));
732 }
733 _ => panic!("expected Block"),
734 }
735 }
736
737 #[test]
738 fn returns_none_for_unknown_widget() {
739 let json = json!(["UnknownWidget", {}]);
740 assert!(parse_fragment(&json, &HashMap::new()).is_none());
741 }
742
743 #[test]
744 fn returns_none_for_empty_array() {
745 let json = json!([]);
746 assert!(parse_fragment(&json, &HashMap::new()).is_none());
747 }
748
749 #[test]
750 fn returns_none_for_number() {
751 let json = json!(42);
752 assert!(parse_fragment(&json, &HashMap::new()).is_none());
753 }
754
755 #[test]
756 fn parses_widget_without_attrs_object() {
757 let json = json!(["List", "Item 1", "Item 2"]);
758 let node = parse_fragment(&json, &HashMap::new()).unwrap();
759 match node {
760 WidgetNode::List { items, .. } => {
761 assert_eq!(items, vec!["Item 1", "Item 2"]);
762 }
763 _ => panic!("expected List"),
764 }
765 }
766
767 #[test]
768 fn value_to_display_string_handles_types() {
769 assert_eq!(value_to_display_string(&json!("hello")), "hello");
770 assert_eq!(value_to_display_string(&json!(42)), "42");
771 assert_eq!(value_to_display_string(&json!(null)), "");
772 assert_eq!(value_to_display_string(&json!(true)), "true");
773 }
774}