use std::collections::HashMap;
use ratatui::{
Frame,
layout::Rect,
widgets::{Block, Paragraph},
};
use a2ui_base::catalog::function_api::FunctionImplementation;
use a2ui_base::catalog::Catalog;
use a2ui_base::model::component_context::ComponentContext;
use a2ui_base::model::components_model::SurfaceComponentsModel;
use a2ui_base::model::data_model::DataModel;
use a2ui_base::model::surface_model::SurfaceModel;
use super::component_impl::ComponentRegistry;
use super::component_impl::TuiComponent;
pub struct SurfaceRenderer<'a> {
surface: &'a SurfaceModel,
registry: &'a ComponentRegistry,
catalog: &'a Catalog,
}
impl<'a> SurfaceRenderer<'a> {
pub fn new(
surface: &'a SurfaceModel,
registry: &'a ComponentRegistry,
catalog: &'a Catalog,
) -> Self {
Self {
surface,
registry,
catalog,
}
}
pub fn render(&self, frame: &mut Frame, area: Rect, focused_id: Option<&str>) {
let data_model = self.surface.data_model.borrow();
let components = self.surface.components.borrow();
let surface_id = &self.surface.id;
if !components.contains("root") {
let widget = Paragraph::new("No root component").block(Block::bordered());
frame.render_widget(widget, area);
return;
}
let root_is_container = matches!(
components.get("root").map(|m| m.component_type.as_str()),
Some("Column") | Some("Row") | Some("List")
);
let root_area = if root_is_container {
area
} else {
match measure_node(
"root",
surface_id,
"",
area.width,
&data_model,
&components,
self.registry,
&self.catalog.functions,
focused_id,
) {
Some(natural) => {
let h = natural.min(area.height);
let y = if natural > area.height {
area.y
} else {
area.y + area.height.saturating_sub(h) / 2
};
Rect {
x: area.x,
y,
width: area.width,
height: h,
}
}
None => area,
}
};
render_node(
"root",
surface_id,
"",
root_area,
frame,
&data_model,
&components,
self.registry,
&self.catalog.functions,
focused_id,
);
}
pub fn measure(&self, available_width: u16) -> Option<u16> {
let data_model = self.surface.data_model.borrow();
let components = self.surface.components.borrow();
let surface_id = &self.surface.id;
if !components.contains("root") {
return None;
}
measure_node(
"root",
surface_id,
"",
available_width,
&data_model,
&components,
self.registry,
&self.catalog.functions,
None,
)
}
pub fn render_child_by_id(
&self,
child_id: &str,
surface_id: &str,
base_path: &str,
area: Rect,
frame: &mut Frame,
data_model: &DataModel,
components: &SurfaceComponentsModel,
focused_id: Option<&str>,
) {
render_node(
child_id,
surface_id,
base_path,
area,
frame,
data_model,
components,
self.registry,
&self.catalog.functions,
focused_id,
);
}
}
fn render_node(
component_id: &str,
surface_id: &str,
base_path: &str,
area: Rect,
frame: &mut Frame,
data_model: &DataModel,
components: &SurfaceComponentsModel,
registry: &ComponentRegistry,
functions: &HashMap<String, Box<dyn FunctionImplementation>>,
focused_id: Option<&str>,
) {
let comp_model = match components.get(component_id) {
Some(m) => m,
None => {
let msg = format!("Component not found: {}", component_id);
let widget = Paragraph::new(msg).block(Block::bordered());
frame.render_widget(widget, area);
return;
}
};
let ctx = ComponentContext::new(
component_id.to_string(),
surface_id.to_string(),
data_model,
components,
functions,
base_path,
focused_id.map(|s| s.to_string()),
);
let mut render_child = |child_id: &str, child_area: Rect, child_frame: &mut Frame, child_base_path: &str| {
render_node(
child_id,
surface_id,
child_base_path,
child_area,
child_frame,
data_model,
components,
registry,
functions,
focused_id,
);
};
let mut measure_child = |child_id: &str, child_base_path: &str, available_width: u16| -> Option<u16> {
measure_node(
child_id,
surface_id,
child_base_path,
available_width,
data_model,
components,
registry,
functions,
focused_id,
)
};
let tui_comp = match registry.get(&comp_model.component_type) {
Some(c) => c,
None => {
super::components::GenericComponent.render(
&ctx,
area,
frame,
&mut render_child,
&mut measure_child,
);
return;
}
};
tui_comp.render(&ctx, area, frame, &mut render_child, &mut measure_child);
}
fn measure_node(
component_id: &str,
surface_id: &str,
base_path: &str,
available_width: u16,
data_model: &DataModel,
components: &SurfaceComponentsModel,
registry: &ComponentRegistry,
functions: &HashMap<String, Box<dyn FunctionImplementation>>,
focused_id: Option<&str>,
) -> Option<u16> {
let comp_model = components.get(component_id)?;
let ctx = ComponentContext::new(
component_id.to_string(),
surface_id.to_string(),
data_model,
components,
functions,
base_path,
focused_id.map(|s| s.to_string()),
);
let tui_comp = match registry.get(&comp_model.component_type) {
Some(c) => c,
None => return None,
};
let mut measure_child = |child_id: &str, child_base_path: &str, width: u16| -> Option<u16> {
measure_node(
child_id,
surface_id,
child_base_path,
width,
data_model,
components,
registry,
functions,
focused_id,
)
};
let mut height = tui_comp.natural_height(&ctx, available_width, &mut measure_child);
if let Some(min) = comp_model.min_height() {
height = Some(height.unwrap_or(0).max(min));
}
height
}
#[cfg(test)]
mod render_tests {
use super::*;
use a2ui_base::message_processor::MessageProcessor;
use crate::catalogs::basic::{build_basic_catalog, build_basic_registry};
use ratatui::backend::TestBackend;
fn render_to_buffer(components_json: serde_json::Value, cols: u16, rows: u16) -> ratatui::buffer::Buffer {
let registry = build_basic_registry();
let mut processor = MessageProcessor::new(vec![build_basic_catalog()]);
let create = serde_json::json!({
"version": "v1.0",
"createSurface": {
"surfaceId": "test",
"catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json",
"dataModel": {}
}
});
processor
.process_message(MessageProcessor::parse_message(&create.to_string()).unwrap())
.unwrap();
let update = serde_json::json!({
"version": "v1.0",
"updateComponents": { "surfaceId": "test", "components": components_json }
});
processor
.process_message(MessageProcessor::parse_message(&update.to_string()).unwrap())
.unwrap();
let surface = processor.model.get_surface("test").expect("surface exists");
let backend = TestBackend::new(cols, rows);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
let render_catalog = Catalog::new("placeholder");
terminal
.draw(|frame| {
let renderer = SurfaceRenderer::new(surface, ®istry, &render_catalog);
renderer.render(frame, frame.area(), None);
})
.unwrap();
terminal.backend().buffer().clone()
}
fn render_to_buffer_focused(
components_json: serde_json::Value,
cols: u16,
rows: u16,
focused_id: Option<&str>,
) -> ratatui::buffer::Buffer {
let registry = build_basic_registry();
let mut processor = MessageProcessor::new(vec![build_basic_catalog()]);
let create = serde_json::json!({
"version": "v1.0",
"createSurface": {
"surfaceId": "test",
"catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json",
"dataModel": {}
}
});
processor
.process_message(MessageProcessor::parse_message(&create.to_string()).unwrap())
.unwrap();
let update = serde_json::json!({
"version": "v1.0",
"updateComponents": { "surfaceId": "test", "components": components_json }
});
processor
.process_message(MessageProcessor::parse_message(&update.to_string()).unwrap())
.unwrap();
let surface = processor.model.get_surface("test").expect("surface exists");
let backend = TestBackend::new(cols, rows);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
let render_catalog = Catalog::new("placeholder");
terminal
.draw(|frame| {
let renderer = SurfaceRenderer::new(surface, ®istry, &render_catalog);
renderer.render(frame, frame.area(), focused_id);
})
.unwrap();
terminal.backend().buffer().clone()
}
fn row_is_blank(buf: &ratatui::buffer::Buffer, y: u16, width: u16) -> bool {
(0..width).all(|x| buf[(x, y)].symbol() == " ")
}
fn measure_root(components_json: serde_json::Value, width: u16) -> Option<u16> {
let registry = build_basic_registry();
let mut processor = MessageProcessor::new(vec![build_basic_catalog()]);
let create = serde_json::json!({
"version": "v1.0",
"createSurface": {
"surfaceId": "test",
"catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json",
"dataModel": {}
}
});
processor
.process_message(MessageProcessor::parse_message(&create.to_string()).unwrap())
.unwrap();
let update = serde_json::json!({
"version": "v1.0",
"updateComponents": { "surfaceId": "test", "components": components_json }
});
processor
.process_message(MessageProcessor::parse_message(&update.to_string()).unwrap())
.unwrap();
let surface = processor.model.get_surface("test").expect("surface exists");
let render_catalog = Catalog::new("placeholder");
SurfaceRenderer::new(surface, ®istry, &render_catalog).measure(width)
}
fn non_blank_row_count(buf: &ratatui::buffer::Buffer, cols: u16, rows: u16) -> u16 {
(0..rows).filter(|&y| !row_is_blank(buf, y, cols)).count() as u16
}
#[test]
fn card_root_does_not_fill_screen() {
let components = serde_json::json!([
{ "id": "root", "component": "Card", "child": "inner" },
{ "id": "inner", "component": "Column", "children": ["a", "b"] },
{ "id": "a", "component": "Text", "text": "Title" },
{ "id": "b", "component": "Text", "text": "Body" }
]);
let buf = render_to_buffer(components, 40, 24);
assert!(row_is_blank(&buf, 0, 40), "top edge should be blank — card shrink-wrapped");
assert!(row_is_blank(&buf, 23, 40), "bottom edge should be blank — card shrink-wrapped");
let used = non_blank_row_count(&buf, 40, 24);
assert!(used <= 12, "card content should occupy <=12 rows, used {used}");
}
#[test]
fn measure_card_root_returns_natural_height() {
let components = serde_json::json!([
{ "id": "root", "component": "Card", "child": "inner" },
{ "id": "inner", "component": "Column", "children": ["a", "b"] },
{ "id": "a", "component": "Text", "text": "Title" },
{ "id": "b", "component": "Text", "text": "Body" }
]);
assert_eq!(
measure_root(components, 40),
Some(10),
"Card>Column>[Text,Text] natural height = 6 content + 4 chrome"
);
}
#[test]
fn measure_column_root_sums_children() {
let components = serde_json::json!([
{ "id": "root", "component": "Column", "children": ["a", "b", "c"] },
{ "id": "a", "component": "Text", "text": "One" },
{ "id": "b", "component": "Text", "text": "Two" },
{ "id": "c", "component": "Text", "text": "Three" }
]);
assert_eq!(measure_root(components, 40), Some(9));
}
#[test]
fn measure_text_wraps_with_width() {
let components = serde_json::json!([
{ "id": "root", "component": "Text", "text": "alpha beta gamma delta epsilon zeta eta theta" }
]);
let narrow = measure_root(components.clone(), 12).expect("narrow measured");
let wide = measure_root(components, 60).expect("wide measured");
assert!(
narrow > wide,
"narrow width should wrap to more rows than wide: narrow={narrow} wide={wide}"
);
assert!(wide >= 3, "wide text still has the +2 margin floor");
}
#[test]
fn focused_textfield_border_is_colored_only_when_focused() {
use ratatui::style::Color;
let components = serde_json::json!([
{ "id": "root", "component": "Column", "children": ["user", "pass"] },
{ "id": "user", "component": "TextField", "label": "User", "value": {"path":"/u"} },
{ "id": "pass", "component": "TextField", "label": "Pass", "value": {"path":"/p"} }
]);
let any_yellow = |buf: &ratatui::buffer::Buffer| {
(0..24u16).any(|y| (0..40u16).any(|x| buf[(x, y)].fg == Color::Yellow))
};
let focused = render_to_buffer_focused(components.clone(), 40, 24, Some("user"));
assert!(any_yellow(&focused), "focused TextField should paint a yellow border");
let plain = render_to_buffer_focused(components, 40, 24, None);
assert!(!any_yellow(&plain), "no focus passed → no yellow highlight anywhere");
}
#[test]
fn textfield_in_column_renders_a_proper_box() {
let components = serde_json::json!([
{ "id": "root", "component": "Column", "children": ["field"] },
{ "id": "field", "component": "TextField", "label": "Username", "value": "alice" }
]);
let buf = render_to_buffer(components, 40, 24);
let used = non_blank_row_count(&buf, 40, 24);
assert!(
(3..=6).contains(&used),
"TextField should render a ~3-line box (border/content/border), used {used} rows"
);
let border_rows: Vec<u16> = (0..24)
.filter(|&y| (0..40).any(|x| buf[(x, y)].symbol() == "─"))
.collect();
assert!(
border_rows.len() >= 2,
"TextField box should have ≥2 border rows, found {border_rows:?}"
);
}
#[test]
fn column_root_fills_viewport_vertically() {
let components = serde_json::json!([
{ "id": "root", "component": "Column", "children": ["top", "bottom"], "justify": "stretch" },
{ "id": "top", "component": "Text", "text": "TOP" },
{ "id": "bottom", "component": "Text", "text": "BOTTOM" }
]);
let buf = render_to_buffer(components, 40, 24);
let top_filled = (0..6u16).any(|y| !row_is_blank(&buf, y, 40));
let bottom_filled = (12..24u16).any(|y| !row_is_blank(&buf, y, 40));
assert!(top_filled, "first child should render near the top of a filling column");
assert!(bottom_filled, "second child should render near the bottom of a filling column");
}
#[test]
fn login_form_inputs_render_as_full_boxes() {
let components = serde_json::json!([
{ "id": "root", "component": "Card", "child": "form" },
{ "id": "form", "component": "Column", "children": ["title", "user", "pass", "submit"] },
{ "id": "title", "component": "Text", "text": "Welcome Back" },
{ "id": "user", "component": "TextField", "label": "Username", "value": "" },
{ "id": "pass", "component": "TextField", "label": "Password", "value": "" },
{ "id": "submit", "component": "Button", "child": "submit_label" },
{ "id": "submit_label", "component": "Text", "text": "Sign In" }
]);
let buf = render_to_buffer(components, 80, 24);
let mut screen = String::new();
for y in 0..24u16 {
for x in 0..80u16 {
screen.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
}
}
assert!(screen.contains("Sign In"), "Button label 'Sign In' should render");
let border_rows = (0..24u16)
.filter(|&y| (0..80u16).any(|x| buf[(x, y)].symbol() == "─"))
.count();
assert!(
border_rows >= 8,
"2 TextFields + Button + Card ⇒ ≥8 border rows, found {border_rows}"
);
}
#[test]
fn templated_children_expand_from_data_array() {
use crate::catalogs::minimal::{build_minimal_catalog, build_minimal_registry};
let registry = build_minimal_registry();
let mut processor = MessageProcessor::new(vec![build_minimal_catalog()]);
let create = serde_json::json!({
"version": "v1.0",
"createSurface": {
"surfaceId": "example_7",
"catalogId": "https://a2ui.org/specification/v1_0/catalogs/minimal/catalog.json"
}
});
processor
.process_message(MessageProcessor::parse_message(&create.to_string()).unwrap())
.unwrap();
let set_data = serde_json::json!({
"version": "v1.0",
"updateDataModel": {
"surfaceId": "example_7",
"path": "/",
"value": { "restaurants": [
{ "title": "The Golden Fork", "subtitle": "Fine Dining & Spirits", "address": "123 Gastronomy Lane" },
{ "title": "Ocean's Bounty", "subtitle": "Fresh Daily Seafood", "address": "456 Shoreline Dr" }
] }
}
});
processor
.process_message(MessageProcessor::parse_message(&set_data.to_string()).unwrap())
.unwrap();
let update = serde_json::json!({
"version": "v1.0",
"updateComponents": {
"surfaceId": "example_7",
"components": [
{ "id": "root", "component": "Column", "children": { "path": "/restaurants", "componentId": "restaurant_card" } },
{ "id": "restaurant_card", "component": "Column", "children": ["rc_title", "rc_subtitle", "rc_address"] },
{ "id": "rc_title", "component": "Text", "text": { "path": "title" } },
{ "id": "rc_subtitle", "component": "Text", "text": { "path": "subtitle" } },
{ "id": "rc_address", "component": "Text", "text": { "path": "address" } }
]
}
});
processor
.process_message(MessageProcessor::parse_message(&update.to_string()).unwrap())
.unwrap();
let surface = processor.model.get_surface("example_7").expect("surface exists");
{
let components = surface.components.borrow();
let root = components.get("root").expect("root exists");
match root.children() {
Some(a2ui_base::protocol::common_types::ChildList::Template { component_id, path }) => {
assert_eq!(component_id, "restaurant_card");
assert_eq!(path, "/restaurants");
}
other => panic!("root.children should be Template, got {other:?}"),
}
}
let backend = TestBackend::new(60, 24);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
let render_catalog = Catalog::new("placeholder");
terminal
.draw(|frame| {
let renderer = SurfaceRenderer::new(surface, ®istry, &render_catalog);
renderer.render(frame, frame.area(), None);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let mut screen = String::new();
for y in 0..24u16 {
for x in 0..60u16 {
screen.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
}
screen.push('\n');
}
assert!(screen.contains("The Golden Fork"), "first restaurant title should render:\n{screen}");
assert!(screen.contains("Ocean's Bounty"), "second restaurant title should render:\n{screen}");
assert!(screen.contains("Fine Dining & Spirits"), "first restaurant subtitle should render:\n{screen}");
assert!(screen.contains("456 Shoreline Dr"), "second restaurant address should render:\n{screen}");
}
}