Skip to main content

a2ui_tui/
surface.rs

1//! Surface renderer — entry point for rendering an A2UI component tree into a ratatui frame.
2
3use std::collections::HashMap;
4
5use ratatui::{
6    Frame,
7    layout::Rect,
8    widgets::{Block, Paragraph},
9};
10
11use a2ui_base::catalog::function_api::FunctionImplementation;
12use a2ui_base::catalog::Catalog;
13use a2ui_base::model::component_context::ComponentContext;
14use a2ui_base::model::components_model::SurfaceComponentsModel;
15use a2ui_base::model::data_model::DataModel;
16use a2ui_base::model::surface_model::SurfaceModel;
17use super::component_impl::ComponentRegistry;
18use super::component_impl::TuiComponent;
19
20/// Renders a [`SurfaceModel`] into a ratatui frame by walking the component tree.
21pub struct SurfaceRenderer<'a> {
22    surface: &'a SurfaceModel,
23    registry: &'a ComponentRegistry,
24    catalog: &'a Catalog,
25}
26
27impl<'a> SurfaceRenderer<'a> {
28    /// Create a new renderer for the given surface.
29    pub fn new(
30        surface: &'a SurfaceModel,
31        registry: &'a ComponentRegistry,
32        catalog: &'a Catalog,
33    ) -> Self {
34        Self {
35            surface,
36            registry,
37            catalog,
38        }
39    }
40
41    /// Main entry point: render the component tree into the frame.
42    pub fn render(&self, frame: &mut Frame, area: Rect, focused_id: Option<&str>) {
43        let data_model = self.surface.data_model.borrow();
44        let components = self.surface.components.borrow();
45        let surface_id = &self.surface.id;
46
47        // Look up the root component.
48        if !components.contains("root") {
49            let widget = Paragraph::new("No root component").block(Block::bordered());
50            frame.render_widget(widget, area);
51            return;
52        }
53
54        // Root sizing: layout containers (Column/Row/List) fill the viewport so a
55        // full-screen app just uses one of them as root; every other (content)
56        // component — Card, Text, TextField, … — shrink-wraps to its natural height
57        // and centers vertically. Full width is kept (natural width is not measured).
58        let root_is_container = matches!(
59            components.get("root").map(|m| m.component_type.as_str()),
60            Some("Column") | Some("Row") | Some("List")
61        );
62        let root_area = if root_is_container {
63            area
64        } else {
65            match measure_node(
66                "root",
67                surface_id,
68                "",
69                area.width,
70                &data_model,
71                &components,
72                self.registry,
73                &self.catalog.functions,
74                focused_id,
75            ) {
76                Some(natural) => {
77                    let h = natural.min(area.height);
78                    // Top-anchor when content overflows (natural > panel height) so the
79                    // top of the content stays visible; otherwise center vertically.
80                    let y = if natural > area.height {
81                        area.y
82                    } else {
83                        area.y + area.height.saturating_sub(h) / 2
84                    };
85                    Rect {
86                        x: area.x,
87                        y,
88                        width: area.width,
89                        height: h,
90                    }
91                }
92                None => area,
93            }
94        };
95
96        render_node(
97            "root",
98            surface_id,
99            "",
100            root_area,
101            frame,
102            &data_model,
103            &components,
104            self.registry,
105            &self.catalog.functions,
106            focused_id,
107        );
108    }
109
110    /// Measure the root component's natural content height (including its own
111    /// chrome: margins/borders), given `available_width` cells.
112    ///
113    /// Layout-container roots (Column/Row/List) return the sum/max of their
114    /// children's natural heights; unknown/unmeasurable roots return `None`.
115    /// This is the public counterpart of the internal measure pass, so callers
116    /// that stack surfaces (e.g. a chat UI) can size each surface to its content.
117    pub fn measure(&self, available_width: u16) -> Option<u16> {
118        let data_model = self.surface.data_model.borrow();
119        let components = self.surface.components.borrow();
120        let surface_id = &self.surface.id;
121        if !components.contains("root") {
122            return None;
123        }
124        measure_node(
125            "root",
126            surface_id,
127            "",
128            available_width,
129            &data_model,
130            &components,
131            self.registry,
132            &self.catalog.functions,
133            None,
134        )
135    }
136
137    /// Convenience method to render a child by ID with an explicit base path.
138    ///
139    /// Useful for template-based rendering where a container iterates over a
140    /// data array and renders the same component for each item with a nested
141    /// data path.
142    pub fn render_child_by_id(
143        &self,
144        child_id: &str,
145        surface_id: &str,
146        base_path: &str,
147        area: Rect,
148        frame: &mut Frame,
149        data_model: &DataModel,
150        components: &SurfaceComponentsModel,
151        focused_id: Option<&str>,
152    ) {
153        render_node(
154            child_id,
155            surface_id,
156            base_path,
157            area,
158            frame,
159            data_model,
160            components,
161            self.registry,
162            &self.catalog.functions,
163            focused_id,
164        );
165    }
166}
167
168/// Recursively render a single component node.
169///
170/// This free function is the core of the renderer. Each call:
171/// 1. Looks up the component model by ID.
172/// 2. Builds a [`ComponentContext`] for it.
173/// 3. Finds the matching [`TuiComponent`](super::component_impl::TuiComponent) in the registry.
174/// 4. Passes a `render_child` closure that re-enters this same function for any children.
175fn render_node(
176    component_id: &str,
177    surface_id: &str,
178    base_path: &str,
179    area: Rect,
180    frame: &mut Frame,
181    data_model: &DataModel,
182    components: &SurfaceComponentsModel,
183    registry: &ComponentRegistry,
184    functions: &HashMap<String, Box<dyn FunctionImplementation>>,
185    focused_id: Option<&str>,
186) {
187    let comp_model = match components.get(component_id) {
188        Some(m) => m,
189        None => {
190            let msg = format!("Component not found: {}", component_id);
191            let widget = Paragraph::new(msg).block(Block::bordered());
192            frame.render_widget(widget, area);
193            return;
194        }
195    };
196
197    let ctx = ComponentContext::new(
198        component_id.to_string(),
199        surface_id.to_string(),
200        data_model,
201        components,
202        functions,
203        base_path,
204        focused_id.map(|s| s.to_string()),
205    );
206
207    // The render_child closure simply re-enters render_node for each child,
208    // giving unbounded recursion depth without code duplication.
209    //
210    // Defined before the registry lookup so the generic fallback renderer can
211    // also recurse into any child/children of an unknown component type.
212    let mut render_child = |child_id: &str, child_area: Rect, child_frame: &mut Frame, child_base_path: &str| {
213        render_node(
214            child_id,
215            surface_id,
216            child_base_path,
217            child_area,
218            child_frame,
219            data_model,
220            components,
221            registry,
222            functions,
223            focused_id,
224        );
225    };
226
227    // The measure_child closure re-enters measure_node so containers can query a
228    // child's natural height while laying out (render) and while measuring self.
229    let mut measure_child = |child_id: &str, child_base_path: &str, available_width: u16| -> Option<u16> {
230        measure_node(
231            child_id,
232            surface_id,
233            child_base_path,
234            available_width,
235            data_model,
236            components,
237            registry,
238            functions,
239            focused_id,
240        )
241    };
242
243    let tui_comp = match registry.get(&comp_model.component_type) {
244        Some(c) => c,
245        None => {
246            // No native renderer for this component type (e.g. a component
247            // declared in an inline catalog). Fall back to the generic renderer
248            // so the tree is still visible instead of a static "unknown" stub.
249            super::components::GenericComponent.render(
250                &ctx,
251                area,
252                frame,
253                &mut render_child,
254                &mut measure_child,
255            );
256            return;
257        }
258    };
259
260    tui_comp.render(&ctx, area, frame, &mut render_child, &mut measure_child);
261}
262
263/// Measure a single component node's natural height (measure pass counterpart of
264/// [`render_node`]). Builds a child context, dispatches to the registered
265/// [`TuiComponent`](super::component_impl::TuiComponent)'s `natural_height`, and
266/// applies the component's optional `minHeight` floor centrally. Returns `None`
267/// for unknown component types (treated as legacy fill by callers).
268fn measure_node(
269    component_id: &str,
270    surface_id: &str,
271    base_path: &str,
272    available_width: u16,
273    data_model: &DataModel,
274    components: &SurfaceComponentsModel,
275    registry: &ComponentRegistry,
276    functions: &HashMap<String, Box<dyn FunctionImplementation>>,
277    focused_id: Option<&str>,
278) -> Option<u16> {
279    let comp_model = components.get(component_id)?;
280    let ctx = ComponentContext::new(
281        component_id.to_string(),
282        surface_id.to_string(),
283        data_model,
284        components,
285        functions,
286        base_path,
287        focused_id.map(|s| s.to_string()),
288    );
289
290    let tui_comp = match registry.get(&comp_model.component_type) {
291        Some(c) => c,
292        None => return None,
293    };
294
295    let mut measure_child = |child_id: &str, child_base_path: &str, width: u16| -> Option<u16> {
296        measure_node(
297            child_id,
298            surface_id,
299            child_base_path,
300            width,
301            data_model,
302            components,
303            registry,
304            functions,
305            focused_id,
306        )
307    };
308
309    let mut height = tui_comp.natural_height(&ctx, available_width, &mut measure_child);
310
311    // Central minHeight floor (total footprint, incl. margins/borders).
312    if let Some(min) = comp_model.min_height() {
313        height = Some(height.unwrap_or(0).max(min));
314    }
315    height
316}
317
318#[cfg(test)]
319mod render_tests {
320    use super::*;
321    use a2ui_base::message_processor::MessageProcessor;
322    use crate::catalogs::basic::{build_basic_catalog, build_basic_registry};
323    use ratatui::backend::TestBackend;
324
325    /// Build a surface whose `root` is described by `components_json` (an array of
326    /// component objects), then render it into a fresh `cols x rows` TestBackend
327    /// buffer and return the buffer.
328    fn render_to_buffer(components_json: serde_json::Value, cols: u16, rows: u16) -> ratatui::buffer::Buffer {
329        let registry = build_basic_registry();
330        let mut processor = MessageProcessor::new(vec![build_basic_catalog()]);
331
332        let create = serde_json::json!({
333            "version": "v1.0",
334            "createSurface": {
335                "surfaceId": "test",
336                "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json",
337                "dataModel": {}
338            }
339        });
340        processor
341            .process_message(MessageProcessor::parse_message(&create.to_string()).unwrap())
342            .unwrap();
343        let update = serde_json::json!({
344            "version": "v1.0",
345            "updateComponents": { "surfaceId": "test", "components": components_json }
346        });
347        processor
348            .process_message(MessageProcessor::parse_message(&update.to_string()).unwrap())
349            .unwrap();
350
351        let surface = processor.model.get_surface("test").expect("surface exists");
352        let backend = TestBackend::new(cols, rows);
353        let mut terminal = ratatui::Terminal::new(backend).unwrap();
354        let render_catalog = Catalog::new("placeholder");
355        terminal
356            .draw(|frame| {
357                let renderer = SurfaceRenderer::new(surface, &registry, &render_catalog);
358                renderer.render(frame, frame.area(), None);
359            })
360            .unwrap();
361        terminal.backend().buffer().clone()
362    }
363
364    /// Like [`render_to_buffer`], but passes a `focused_id` so focus-driven
365    /// styling (e.g. a TextField's yellow border) can be asserted in tests.
366    fn render_to_buffer_focused(
367        components_json: serde_json::Value,
368        cols: u16,
369        rows: u16,
370        focused_id: Option<&str>,
371    ) -> ratatui::buffer::Buffer {
372        let registry = build_basic_registry();
373        let mut processor = MessageProcessor::new(vec![build_basic_catalog()]);
374        let create = serde_json::json!({
375            "version": "v1.0",
376            "createSurface": {
377                "surfaceId": "test",
378                "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json",
379                "dataModel": {}
380            }
381        });
382        processor
383            .process_message(MessageProcessor::parse_message(&create.to_string()).unwrap())
384            .unwrap();
385        let update = serde_json::json!({
386            "version": "v1.0",
387            "updateComponents": { "surfaceId": "test", "components": components_json }
388        });
389        processor
390            .process_message(MessageProcessor::parse_message(&update.to_string()).unwrap())
391            .unwrap();
392        let surface = processor.model.get_surface("test").expect("surface exists");
393        let backend = TestBackend::new(cols, rows);
394        let mut terminal = ratatui::Terminal::new(backend).unwrap();
395        let render_catalog = Catalog::new("placeholder");
396        terminal
397            .draw(|frame| {
398                let renderer = SurfaceRenderer::new(surface, &registry, &render_catalog);
399                renderer.render(frame, frame.area(), focused_id);
400            })
401            .unwrap();
402        terminal.backend().buffer().clone()
403    }
404
405    /// True if every cell in row `y` (across `width` columns) is a blank/space.
406    fn row_is_blank(buf: &ratatui::buffer::Buffer, y: u16, width: u16) -> bool {
407        (0..width).all(|x| buf[(x, y)].symbol() == " ")
408    }
409
410    /// Build a surface whose `root` is described by `components_json` and return
411    /// its measured natural height via the public `SurfaceRenderer::measure` API.
412    fn measure_root(components_json: serde_json::Value, width: u16) -> Option<u16> {
413        let registry = build_basic_registry();
414        let mut processor = MessageProcessor::new(vec![build_basic_catalog()]);
415
416        let create = serde_json::json!({
417            "version": "v1.0",
418            "createSurface": {
419                "surfaceId": "test",
420                "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json",
421                "dataModel": {}
422            }
423        });
424        processor
425            .process_message(MessageProcessor::parse_message(&create.to_string()).unwrap())
426            .unwrap();
427        let update = serde_json::json!({
428            "version": "v1.0",
429            "updateComponents": { "surfaceId": "test", "components": components_json }
430        });
431        processor
432            .process_message(MessageProcessor::parse_message(&update.to_string()).unwrap())
433            .unwrap();
434
435        let surface = processor.model.get_surface("test").expect("surface exists");
436        let render_catalog = Catalog::new("placeholder");
437        SurfaceRenderer::new(surface, &registry, &render_catalog).measure(width)
438    }
439
440    /// Count rows that contain any non-blank content.
441    fn non_blank_row_count(buf: &ratatui::buffer::Buffer, cols: u16, rows: u16) -> u16 {
442        (0..rows).filter(|&y| !row_is_blank(buf, y, cols)).count() as u16
443    }
444
445    #[test]
446    fn card_root_does_not_fill_screen() {
447        // Card > Column > [Text, Text]. Natural height = 6 (two texts) + 4 (card chrome) = 10.
448        // In a 24-tall area it shrink-wraps to ~10 and centers → top and bottom edges blank.
449        let components = serde_json::json!([
450            { "id": "root", "component": "Card", "child": "inner" },
451            { "id": "inner", "component": "Column", "children": ["a", "b"] },
452            { "id": "a", "component": "Text", "text": "Title" },
453            { "id": "b", "component": "Text", "text": "Body" }
454        ]);
455        let buf = render_to_buffer(components, 40, 24);
456
457        // Before the measure pass the Card filled all 24 rows; now the top and bottom
458        // edge rows must be blank (card is centered & content-sized).
459        assert!(row_is_blank(&buf, 0, 40), "top edge should be blank — card shrink-wrapped");
460        assert!(row_is_blank(&buf, 23, 40), "bottom edge should be blank — card shrink-wrapped");
461        // And the content occupies only a fraction of the screen.
462        let used = non_blank_row_count(&buf, 40, 24);
463        assert!(used <= 12, "card content should occupy <=12 rows, used {used}");
464    }
465
466    #[test]
467    fn measure_card_root_returns_natural_height() {
468        // Card > Column > [Text, Text]. Each Text = 1 content line + 2 margin = 3;
469        // Column = 3 + 3 = 6; Card adds 4 chrome → natural height 10.
470        let components = serde_json::json!([
471            { "id": "root", "component": "Card", "child": "inner" },
472            { "id": "inner", "component": "Column", "children": ["a", "b"] },
473            { "id": "a", "component": "Text", "text": "Title" },
474            { "id": "b", "component": "Text", "text": "Body" }
475        ]);
476        assert_eq!(
477            measure_root(components, 40),
478            Some(10),
479            "Card>Column>[Text,Text] natural height = 6 content + 4 chrome"
480        );
481    }
482
483    #[test]
484    fn measure_column_root_sums_children() {
485        // Column > [Text × 3] → 3 + 3 + 3 = 9.
486        let components = serde_json::json!([
487            { "id": "root", "component": "Column", "children": ["a", "b", "c"] },
488            { "id": "a", "component": "Text", "text": "One" },
489            { "id": "b", "component": "Text", "text": "Two" },
490            { "id": "c", "component": "Text", "text": "Three" }
491        ]);
492        assert_eq!(measure_root(components, 40), Some(9));
493    }
494
495    #[test]
496    fn measure_text_wraps_with_width() {
497        // A single long Text line wraps across more rows at narrow width and fewer
498        // at wide width — proving measure is width-aware (the streaming-text fix).
499        let components = serde_json::json!([
500            { "id": "root", "component": "Text", "text": "alpha beta gamma delta epsilon zeta eta theta" }
501        ]);
502        let narrow = measure_root(components.clone(), 12).expect("narrow measured");
503        let wide = measure_root(components, 60).expect("wide measured");
504        assert!(
505            narrow > wide,
506            "narrow width should wrap to more rows than wide: narrow={narrow} wide={wide}"
507        );
508        assert!(wide >= 3, "wide text still has the +2 margin floor");
509    }
510
511    #[test]
512    fn focused_textfield_border_is_colored_only_when_focused() {
513        // Two TextFields; focusing the first must color its border (the
514        // component paints a yellow border when ctx.focused_id matches), while
515        // passing no focus paints nothing yellow. This is the invariant 04_login_form
516        // violated by passing `None` to SurfaceRenderer::render.
517        use ratatui::style::Color;
518        let components = serde_json::json!([
519            { "id": "root", "component": "Column", "children": ["user", "pass"] },
520            { "id": "user", "component": "TextField", "label": "User", "value": {"path":"/u"} },
521            { "id": "pass", "component": "TextField", "label": "Pass", "value": {"path":"/p"} }
522        ]);
523        let any_yellow = |buf: &ratatui::buffer::Buffer| {
524            (0..24u16).any(|y| (0..40u16).any(|x| buf[(x, y)].fg == Color::Yellow))
525        };
526        let focused = render_to_buffer_focused(components.clone(), 40, 24, Some("user"));
527        assert!(any_yellow(&focused), "focused TextField should paint a yellow border");
528
529        let plain = render_to_buffer_focused(components, 40, 24, None);
530        assert!(!any_yellow(&plain), "no focus passed → no yellow highlight anywhere");
531    }
532
533    #[test]
534    fn textfield_in_column_renders_a_proper_box() {
535        // Column > [TextField]. The TextField draws a margin + a bordered block, so it
536        // needs ≥5 rows to show its top border, content, and bottom border.
537        // Before the height fix it collapsed to 1-2 lines (border only); now it must
538        // render a real 3-line box (top border / content / bottom border).
539        let components = serde_json::json!([
540            { "id": "root", "component": "Column", "children": ["field"] },
541            { "id": "field", "component": "TextField", "label": "Username", "value": "alice" }
542        ]);
543        let buf = render_to_buffer(components, 40, 24);
544
545        let used = non_blank_row_count(&buf, 40, 24);
546        assert!(
547            (3..=6).contains(&used),
548            "TextField should render a ~3-line box (border/content/border), used {used} rows"
549        );
550        // The box must show both a top and bottom horizontal border (`─`).
551        let border_rows: Vec<u16> = (0..24)
552            .filter(|&y| (0..40).any(|x| buf[(x, y)].symbol() == "─"))
553            .collect();
554        assert!(
555            border_rows.len() >= 2,
556            "TextField box should have ≥2 border rows, found {border_rows:?}"
557        );
558    }
559
560    #[test]
561    fn column_root_fills_viewport_vertically() {
562        // A Column root fills the viewport (unlike a Card root). With justify=stretch,
563        // two Text children are spread across the full height: one near the top, one
564        // near the bottom — proving the column did not compact to the center.
565        let components = serde_json::json!([
566            { "id": "root", "component": "Column", "children": ["top", "bottom"], "justify": "stretch" },
567            { "id": "top", "component": "Text", "text": "TOP" },
568            { "id": "bottom", "component": "Text", "text": "BOTTOM" }
569        ]);
570        let buf = render_to_buffer(components, 40, 24);
571
572        let top_filled = (0..6u16).any(|y| !row_is_blank(&buf, y, 40));
573        let bottom_filled = (12..24u16).any(|y| !row_is_blank(&buf, y, 40));
574        assert!(top_filled, "first child should render near the top of a filling column");
575        assert!(bottom_filled, "second child should render near the bottom of a filling column");
576    }
577
578    #[test]
579    fn login_form_inputs_render_as_full_boxes() {
580        // Mirrors examples/04_login_form.rs: Card > Column > [Text, TextField, TextField,
581        // Button]. Before the height fix the inputs collapsed to 1-2 lines; now each
582        // bordered input/button must render a real box (top + bottom border rows).
583        let components = serde_json::json!([
584            { "id": "root", "component": "Card", "child": "form" },
585            { "id": "form", "component": "Column", "children": ["title", "user", "pass", "submit"] },
586            { "id": "title", "component": "Text", "text": "Welcome Back" },
587            { "id": "user", "component": "TextField", "label": "Username", "value": "" },
588            { "id": "pass", "component": "TextField", "label": "Password", "value": "" },
589            { "id": "submit", "component": "Button", "child": "submit_label" },
590            { "id": "submit_label", "component": "Text", "text": "Sign In" }
591        ]);
592        let buf = render_to_buffer(components, 80, 24);
593
594        // The Button's child text "Sign In" must be visible — it only renders when the
595        // Button gets enough height (≥5) to show border + content. Before the
596        // nested-margin fix the label vanished.
597        let mut screen = String::new();
598        for y in 0..24u16 {
599            for x in 0..80u16 {
600                screen.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
601            }
602        }
603        assert!(screen.contains("Sign In"), "Button label 'Sign In' should render");
604
605        let border_rows = (0..24u16)
606            .filter(|&y| (0..80u16).any(|x| buf[(x, y)].symbol() == "─"))
607            .count();
608        assert!(
609            border_rows >= 8,
610            "2 TextFields + Button + Card ⇒ ≥8 border rows, found {border_rows}"
611        );
612    }
613
614    #[test]
615    fn templated_children_expand_from_data_array() {
616        // Mirrors the "Incremental List" sample (minimal catalog): a root Column
617        // whose `children` is a template `{path, componentId}` bound to a data
618        // array. Each array element must instantiate the template component with
619        // its own nested data path.
620        //
621        // Regression: the `componentId` (camelCase) key did not deserialize into
622        // `ChildList::Template` (snake_case `component_id` with no serde rename),
623        // so `children()` returned `None` and the Column rendered a blank panel.
624        use crate::catalogs::minimal::{build_minimal_catalog, build_minimal_registry};
625
626        let registry = build_minimal_registry();
627        let mut processor = MessageProcessor::new(vec![build_minimal_catalog()]);
628
629        let create = serde_json::json!({
630            "version": "v1.0",
631            "createSurface": {
632                "surfaceId": "example_7",
633                "catalogId": "https://a2ui.org/specification/v1_0/catalogs/minimal/catalog.json"
634            }
635        });
636        processor
637            .process_message(MessageProcessor::parse_message(&create.to_string()).unwrap())
638            .unwrap();
639
640        let set_data = serde_json::json!({
641            "version": "v1.0",
642            "updateDataModel": {
643                "surfaceId": "example_7",
644                "path": "/",
645                "value": { "restaurants": [
646                    { "title": "The Golden Fork", "subtitle": "Fine Dining & Spirits", "address": "123 Gastronomy Lane" },
647                    { "title": "Ocean's Bounty", "subtitle": "Fresh Daily Seafood", "address": "456 Shoreline Dr" }
648                ] }
649            }
650        });
651        processor
652            .process_message(MessageProcessor::parse_message(&set_data.to_string()).unwrap())
653            .unwrap();
654
655        let update = serde_json::json!({
656            "version": "v1.0",
657            "updateComponents": {
658                "surfaceId": "example_7",
659                "components": [
660                    { "id": "root", "component": "Column", "children": { "path": "/restaurants", "componentId": "restaurant_card" } },
661                    { "id": "restaurant_card", "component": "Column", "children": ["rc_title", "rc_subtitle", "rc_address"] },
662                    { "id": "rc_title", "component": "Text", "text": { "path": "title" } },
663                    { "id": "rc_subtitle", "component": "Text", "text": { "path": "subtitle" } },
664                    { "id": "rc_address", "component": "Text", "text": { "path": "address" } }
665                ]
666            }
667        });
668        processor
669            .process_message(MessageProcessor::parse_message(&update.to_string()).unwrap())
670            .unwrap();
671
672        // Confirm the children parsed as a Template, not None.
673        let surface = processor.model.get_surface("example_7").expect("surface exists");
674        {
675            let components = surface.components.borrow();
676            let root = components.get("root").expect("root exists");
677            match root.children() {
678                Some(a2ui_base::protocol::common_types::ChildList::Template { component_id, path }) => {
679                    assert_eq!(component_id, "restaurant_card");
680                    assert_eq!(path, "/restaurants");
681                }
682                other => panic!("root.children should be Template, got {other:?}"),
683            }
684        }
685
686        let backend = TestBackend::new(60, 24);
687        let mut terminal = ratatui::Terminal::new(backend).unwrap();
688        let render_catalog = Catalog::new("placeholder");
689        terminal
690            .draw(|frame| {
691                let renderer = SurfaceRenderer::new(surface, &registry, &render_catalog);
692                renderer.render(frame, frame.area(), None);
693            })
694            .unwrap();
695
696        let buf = terminal.backend().buffer().clone();
697        let mut screen = String::new();
698        for y in 0..24u16 {
699            for x in 0..60u16 {
700                screen.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
701            }
702            screen.push('\n');
703        }
704
705        assert!(screen.contains("The Golden Fork"), "first restaurant title should render:\n{screen}");
706        assert!(screen.contains("Ocean's Bounty"), "second restaurant title should render:\n{screen}");
707        assert!(screen.contains("Fine Dining & Spirits"), "first restaurant subtitle should render:\n{screen}");
708        assert!(screen.contains("456 Shoreline Dr"), "second restaurant address should render:\n{screen}");
709    }
710}