1use 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
20pub struct SurfaceRenderer<'a> {
22 surface: &'a SurfaceModel,
23 registry: &'a ComponentRegistry,
24 catalog: &'a Catalog,
25}
26
27impl<'a> SurfaceRenderer<'a> {
28 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 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 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 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 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 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 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
168fn 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 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 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 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
263fn 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 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 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, ®istry, &render_catalog);
358 renderer.render(frame, frame.area(), None);
359 })
360 .unwrap();
361 terminal.backend().buffer().clone()
362 }
363
364 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, ®istry, &render_catalog);
399 renderer.render(frame, frame.area(), focused_id);
400 })
401 .unwrap();
402 terminal.backend().buffer().clone()
403 }
404
405 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 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, ®istry, &render_catalog).measure(width)
438 }
439
440 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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, ®istry, &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}