1use ratatui::{
4 Frame,
5 layout::Rect,
6 style::{Modifier, Style},
7 widgets::{Paragraph, Wrap},
8};
9use unicode_width::UnicodeWidthStr;
10
11use a2ui_base::model::component_context::ComponentContext;
12use a2ui_base::protocol::common_types::DynamicString;
13use crate::component_impl::TuiComponent;
14
15pub struct TextComponent;
20
21impl TuiComponent for TextComponent {
22 fn name(&self) -> &'static str {
23 "Text"
24 }
25
26 fn render(
27 &self,
28 ctx: &ComponentContext,
29 area: Rect,
30 frame: &mut Frame,
31 _render_child: &mut dyn FnMut(&str, Rect, &mut Frame, &str),
32 _measure_child: &mut dyn FnMut(&str, &str, u16) -> Option<u16>,
33 ) {
34 let comp_model = match ctx.components.get(&ctx.component_id) {
35 Some(m) => m,
36 None => return,
37 };
38
39 let text_content = match comp_model.get_property::<DynamicString>("text") {
41 Some(ds) => ctx.data_context.resolve_dynamic_string(&ds),
42 None => String::new(),
43 };
44
45 let variant: Option<String> = comp_model.get_property("variant");
47 let style = match variant.as_deref() {
48 Some("h1") => Style::default()
49 .add_modifier(Modifier::BOLD)
50 .fg(ratatui::style::Color::Cyan),
51 Some("h2") => Style::default()
52 .add_modifier(Modifier::BOLD)
53 .fg(ratatui::style::Color::Green),
54 Some("h3") => Style::default().add_modifier(Modifier::BOLD),
55 Some("h4") => Style::default().add_modifier(Modifier::UNDERLINED),
56 Some("h5") => Style::default().add_modifier(Modifier::ITALIC),
57 Some("caption") => Style::default().add_modifier(Modifier::DIM),
58 Some("body") | None => Style::default(),
59 _ => Style::default(),
60 };
61
62 let inner = crate::layout_engine::padded_content(area);
65
66 if inner.width == 0 || inner.height == 0 {
67 return;
68 }
69
70 let paragraph = Paragraph::new(text_content)
71 .style(style)
72 .wrap(Wrap { trim: false });
73 frame.render_widget(paragraph, inner);
74 }
75
76 fn natural_height(
77 &self,
78 ctx: &ComponentContext,
79 available_width: u16,
80 _measure_child: &mut dyn FnMut(&str, &str, u16) -> Option<u16>,
81 ) -> Option<u16> {
82 let comp_model = ctx.components.get(&ctx.component_id);
83
84 let content = match comp_model.and_then(|m| m.get_property::<DynamicString>("text")) {
86 Some(ds) => ctx.data_context.resolve_dynamic_string(&ds),
87 None => String::new(),
88 };
89
90 let content_width = if available_width > 2 {
93 available_width.saturating_sub(2)
94 } else {
95 available_width
96 };
97 let content_width = content_width.max(1) as usize;
99
100 let mut total: usize = 0;
101 for line in content.split('\n') {
102 total += wrapped_row_count(line, content_width);
103 }
104
105 Some((total as u16).saturating_add(2))
108 }
109}
110
111fn wrapped_row_count(line: &str, content_width: usize) -> usize {
125 if line.is_empty() {
131 return 1;
132 }
133
134 let mut rows: usize = 0;
135 let mut line_width: usize = 0; let mut started = false; let push_sep = |w: &mut usize, started: &mut bool| {
139 if *started {
141 *w += 1;
142 }
143 };
144
145 for word in line.split(' ') {
148 let word_w = UnicodeWidthStr::width(word);
149
150 if word.is_empty() {
151 push_sep(&mut line_width, &mut started);
154 started = true;
158 continue;
159 }
160
161 let sep = if started { 1 } else { 0 };
164 if line_width + sep + word_w <= content_width {
165 line_width += sep + word_w;
167 started = true;
168 continue;
169 }
170
171 if word_w <= content_width {
175 rows += 1; line_width = word_w;
177 started = true;
178 continue;
179 }
180
181 let mut remaining = word_w;
187 if started && line_width < content_width {
188 let fits = content_width - line_width; let _ = fits; remaining = remaining.saturating_sub(content_width - line_width);
194 rows += 1; started = false;
196 line_width = 0;
197 }
198 while remaining > 0 {
200 if remaining > content_width {
201 rows += 1;
202 remaining -= content_width;
203 } else {
204 line_width = remaining;
206 started = true;
207 remaining = 0;
208 }
209 }
210 }
211
212 rows + 1
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219 use a2ui_base::catalog::Catalog;
220 use a2ui_base::message_processor::MessageProcessor;
221 use crate::catalogs::basic::{build_basic_catalog, build_basic_registry};
222 use crate::component_impl::TuiComponent;
223 use crate::surface::SurfaceRenderer;
224 use ratatui::backend::TestBackend;
225 use std::collections::HashMap;
226
227 fn render_text_to_buffer(text: &str, cols: u16, rows: u16) -> ratatui::buffer::Buffer {
231 let registry = build_basic_registry();
232 let mut processor = MessageProcessor::new(vec![build_basic_catalog()]);
233
234 let create = serde_json::json!({
235 "version": "v1.0",
236 "createSurface": {
237 "surfaceId": "test",
238 "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json",
239 "dataModel": {}
240 }
241 });
242 processor
243 .process_message(MessageProcessor::parse_message(&create.to_string()).unwrap())
244 .unwrap();
245 let update = serde_json::json!({
246 "version": "v1.0",
247 "updateComponents": {
248 "surfaceId": "test",
249 "components": [
250 { "id": "root", "component": "Text", "text": text }
251 ]
252 }
253 });
254 processor
255 .process_message(MessageProcessor::parse_message(&update.to_string()).unwrap())
256 .unwrap();
257
258 let surface = processor.model.get_surface("test").expect("surface exists");
259 let backend = TestBackend::new(cols, rows);
260 let mut terminal = ratatui::Terminal::new(backend).unwrap();
261 let render_catalog = Catalog::new("placeholder");
262 terminal
263 .draw(|frame| {
264 let renderer = SurfaceRenderer::new(surface, ®istry, &render_catalog);
265 renderer.render(frame, frame.area(), None);
266 })
267 .unwrap();
268 terminal.backend().buffer().clone()
269 }
270
271 fn non_blank_row_count(buf: &ratatui::buffer::Buffer, cols: u16, rows: u16) -> u16 {
274 (0..rows)
275 .filter(|&y| (0..cols).any(|x| buf[(x, y)].symbol() != " "))
276 .count() as u16
277 }
278
279 fn measure_text(text: &str, available_width: u16) -> u16 {
283 let mut processor = MessageProcessor::new(vec![build_basic_catalog()]);
284
285 let create = serde_json::json!({
286 "version": "v1.0",
287 "createSurface": {
288 "surfaceId": "test",
289 "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json",
290 "dataModel": {}
291 }
292 });
293 processor
294 .process_message(MessageProcessor::parse_message(&create.to_string()).unwrap())
295 .unwrap();
296 let update = serde_json::json!({
297 "version": "v1.0",
298 "updateComponents": {
299 "surfaceId": "test",
300 "components": [
301 { "id": "root", "component": "Text", "text": text }
302 ]
303 }
304 });
305 processor
306 .process_message(MessageProcessor::parse_message(&update.to_string()).unwrap())
307 .unwrap();
308
309 let surface = processor.model.get_surface("test").expect("surface exists");
310 let components = surface.components.borrow();
311 let data_model = surface.data_model.borrow();
312 let functions: HashMap<String, Box<dyn a2ui_base::catalog::function_api::FunctionImplementation>> =
313 HashMap::new();
314 let ctx = ComponentContext::new(
315 "root".to_string(),
316 "test".to_string(),
317 &data_model,
318 &components,
319 &functions,
320 "",
321 None,
322 );
323
324 let mut measure_child = |_id: &str, _base: &str, _w: u16| -> Option<u16> { None };
325 TextComponent
326 .natural_height(&ctx, available_width, &mut measure_child)
327 .expect("natural_height returns Some")
328 }
329
330 fn assert_consistent(text: &str, cols: u16, rows: u16) {
337 let buf = render_text_to_buffer(text, cols, rows);
338 let rendered = non_blank_row_count(&buf, cols, rows);
339 let measured = measure_text(text, cols);
340 assert_eq!(
341 measured,
342 rendered + 2,
343 "measure/render mismatch at {cols}x{rows}: natural_height returned {measured} (full \
344 footprint), but rendered {rendered} non-blank content rows ⇒ footprint should be \
345 {} (rows + 2 margin)\n\
346 text={text:?}",
347 rendered + 2,
348 );
349 }
350
351 #[test]
352 fn natural_height_matches_render_narrow() {
353 let text = "the quick brown fox jumps over the lazy dog and runs away fast";
355 assert_consistent(text, 20, 30);
356 }
357
358 #[test]
359 fn natural_height_matches_render_wide() {
360 let text = "the quick brown fox jumps over the lazy dog and runs away fast";
362 assert_consistent(text, 40, 30);
363 }
364
365 #[test]
366 fn natural_height_matches_render_multiline() {
367 let text = "first line\nsecond line here that is longer than the narrow width allows";
369 assert_consistent(text, 20, 30);
370 assert_consistent(text, 40, 20);
371 }
372
373 #[test]
374 fn natural_height_matches_long_word() {
375 let text = "supercalifragilisticexpialidocious is a very long word indeed";
377 assert_consistent(text, 20, 30);
378 assert_consistent(text, 16, 30);
379 }
380}