Skip to main content

a2ui_tui/components/
generic.rs

1//! Generic fallback component.
2//!
3//! When the renderer encounters a component type that has no registered native
4//! [`TuiComponent`](crate::component_impl::TuiComponent) (for example, a
5//! component declared in an *inline catalog* that the client received but did
6//! not implement natively), it falls back to [`GenericComponent`].
7//!
8//! The generic renderer draws a bordered block titled with the (unknown)
9//! component type, lists every property as `key: resolved-value`, and then
10//! renders any `child`/`children` below the property dump so nested trees are
11//! still visible. It never panics on missing fields.
12
13use ratatui::{
14    Frame,
15    layout::{Alignment, Rect},
16    style::{Color, Style},
17    text::{Line, Span},
18    widgets::{Block, Paragraph},
19};
20
21use a2ui_base::model::component_context::ComponentContext;
22use a2ui_base::protocol::common_types::{ChildList, DynamicString};
23use crate::component_impl::TuiComponent;
24
25/// A stateless, zero-sized fallback renderer for unknown component types.
26pub struct GenericComponent;
27
28impl TuiComponent for GenericComponent {
29    fn name(&self) -> &'static str {
30        "Generic"
31    }
32
33    fn render(
34        &self,
35        ctx: &ComponentContext,
36        area: Rect,
37        frame: &mut Frame,
38        render_child: &mut dyn FnMut(&str, Rect, &mut Frame, &str),
39        _measure_child: &mut dyn FnMut(&str, &str, u16) -> Option<u16>,
40    ) {
41        let comp_model = match ctx.components.get(&ctx.component_id) {
42            Some(m) => m,
43            None => return,
44        };
45
46        // --- Build the block + property dump ---
47        let title = format!("{} (unknown)", comp_model.component_type);
48        let block = Block::bordered()
49            .title(title)
50            .border_style(Style::default().fg(Color::Yellow))
51            .style(Style::default().fg(Color::Gray));
52
53        let inner = block.inner(area);
54        // Render the block first so the border is always drawn.
55        frame.render_widget(block, area);
56        if inner.width == 0 || inner.height == 0 {
57            return;
58        }
59
60        // Build lines of "key: value" for each property, resolving
61        // DynamicString values that look like a string binding/function.
62        let mut lines: Vec<Line> = Vec::new();
63        for (key, val) in comp_model.properties.iter() {
64            let resolved = resolve_value_for_display(val, ctx);
65            let line = Line::from(vec![
66                Span::styled(format!("{key}: "), Style::default().fg(Color::Cyan)),
67                Span::raw(resolved),
68            ]);
69            lines.push(line);
70        }
71
72        if lines.is_empty() {
73            lines.push(Line::from("(no properties)").alignment(Alignment::Center));
74        }
75
76        // Reserve the bottom row(s) for children if present.
77        let child_ids = collect_child_ids(comp_model);
78        let child_row_count = if child_ids.is_empty() { 0 } else { 1 };
79
80        let prop_area = Rect {
81            x: inner.x,
82            y: inner.y,
83            width: inner.width,
84            height: inner.height.saturating_sub(child_row_count),
85        };
86
87        if prop_area.height > 0 {
88            frame.render_widget(Paragraph::new(lines), prop_area);
89        }
90
91        // --- Render children (if any) in a single stacked row below ---
92        if !child_ids.is_empty() && child_row_count > 0 && inner.height > child_row_count {
93            let child_area = Rect {
94                x: inner.x,
95                y: inner.y + prop_area.height,
96                width: inner.width,
97                height: child_row_count as u16,
98            };
99            // Give each child an equal horizontal slice.
100            let count = child_ids.len() as u16;
101            let slice_w = child_area.width / count.max(1);
102            for (i, cid) in child_ids.iter().enumerate() {
103                let ca = Rect {
104                    x: child_area.x + (slice_w * i as u16),
105                    y: child_area.y,
106                    width: slice_w,
107                    height: child_area.height,
108                };
109                if ca.width > 0 && ca.height > 0 {
110                    render_child(cid, ca, frame, "");
111                }
112            }
113        }
114    }
115
116    fn natural_height(
117        &self,
118        ctx: &ComponentContext,
119        _available_width: u16,
120        _measure_child: &mut dyn FnMut(&str, &str, u16) -> Option<u16>,
121    ) -> Option<u16> {
122        let comp_model = ctx.components.get(&ctx.component_id)?;
123        let prop_count = comp_model.properties.len().max(1) as u16;
124        let has_children = comp_model.child().is_some()
125            || matches!(comp_model.children(), Some(a2ui_base::protocol::common_types::ChildList::Static(v)) if !v.is_empty());
126        let mut h = prop_count.saturating_add(2);
127        if has_children {
128            h = h.saturating_add(1);
129        }
130        Some(h)
131    }
132}
133
134/// Render a property value as a human-readable string. String-typed dynamic
135/// values are resolved through the data context; everything else is shown as
136/// its raw JSON so the developer can see exactly what the server sent.
137fn resolve_value_for_display(
138    val: &serde_json::Value,
139    ctx: &ComponentContext,
140) -> String {
141    // If it's a string, it might be a DynamicString (literal/binding/function).
142    if let serde_json::Value::String(_) = val {
143        if let Ok(ds) = serde_json::from_value::<DynamicString>(val.clone()) {
144            return ctx.data_context.resolve_dynamic_string(&ds);
145        }
146    }
147    // Fall back to the raw JSON representation.
148    val.to_string()
149}
150
151/// Collect the IDs of any `child` (single) or `children` (list) to render
152/// beneath the property dump. Robust to missing/malformed fields.
153fn collect_child_ids(
154    comp_model: &a2ui_base::model::component_model::ComponentModel,
155) -> Vec<String> {
156    let mut ids = Vec::new();
157    if let Some(single) = comp_model.child() {
158        ids.push(single);
159    }
160    if let Some(list) = comp_model.children() {
161        match list {
162            ChildList::Static(v) => ids.extend(v),
163            // Templates can't be expanded here without data iteration; skip.
164            ChildList::Template { .. } => {}
165        }
166    }
167    ids
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use a2ui_base::model::component_model::ComponentModel;
174    use serde_json::json;
175
176    #[test]
177    fn collect_child_ids_single() {
178        let cm = ComponentModel::from_json(&json!({
179            "id": "x",
180            "component": "Mystery",
181            "child": "label"
182        }))
183        .unwrap();
184        assert_eq!(collect_child_ids(&cm), vec!["label".to_string()]);
185    }
186
187    #[test]
188    fn collect_child_ids_list() {
189        let cm = ComponentModel::from_json(&json!({
190            "id": "x",
191            "component": "Mystery",
192            "children": ["a", "b"]
193        }))
194        .unwrap();
195        assert_eq!(collect_child_ids(&cm), vec!["a".to_string(), "b".to_string()]);
196    }
197
198    #[test]
199    fn collect_child_ids_empty() {
200        let cm = ComponentModel::from_json(&json!({
201            "id": "x",
202            "component": "Mystery"
203        }))
204        .unwrap();
205        assert!(collect_child_ids(&cm).is_empty());
206    }
207
208    #[test]
209    fn name_is_generic() {
210        assert_eq!(GenericComponent.name(), "Generic");
211    }
212}