Skip to main content

a2ui_tui/components/
text.rs

1//! Text component — renders a styled paragraph.
2
3use 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
15/// Text component implementation.
16///
17/// Renders a `ratatui::widgets::Paragraph` with variant-based styling.
18/// Applies a default margin of 1 cell on all sides (leaf component).
19pub 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        // Resolve the text content.
40        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        // Determine variant styling.
46        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        // Apply default margin of 1 cell on all sides, but never collapse to zero so a
63        // Text nested in a tight area (e.g. a Button label) still renders.
64        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        // Resolve the text content (None → empty string).
85        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        // Mirror `padded_content`: subtract 1 cell per side (→ 2 total) only when
91        // the available width is > 2; otherwise the render pass keeps the full width.
92        let content_width = if available_width > 2 {
93            available_width.saturating_sub(2)
94        } else {
95            available_width
96        };
97        // Avoid division-by-zero; ratatui can't render anything in 0 cols anyway.
98        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        // +2 for the margin the render adds via `padded_content` (1 cell top + 1 bottom),
106        // matching the horizontal subtraction above.
107        Some((total as u16).saturating_add(2))
108    }
109}
110
111/// Count how many display rows `line` occupies when wrapped at `content_width`
112/// using a greedy word-wrap that mirrors ratatui's `Wrap { trim: false }`.
113///
114/// Rules (matching ratatui 0.30 behaviour for `trim: false`):
115/// - Words are split on ASCII spaces (`' '`). Consecutive spaces are preserved
116///   (trim:false keeps leading/repeated whitespace).
117/// - A single separating space (width 1) is kept between words on the same row.
118/// - When the next word (+ a preceding space when the line is non-empty) no
119///   longer fits, it starts a new row.
120/// - A word wider than `content_width` is broken greedily at `content_width`
121///   boundaries (ratatui breaks long words), and any leftover width carries
122///   over to continue the row.
123/// - An empty line counts as 1 row.
124fn wrapped_row_count(line: &str, content_width: usize) -> usize {
125    // Split keeping the words; multiple consecutive spaces become empty "words"
126    // but each space between real words still consumes width in the line. We
127    // rebuild by iterating over the original spacing using split(' ') which
128    // yields empty strings for consecutive delimiters — those empty strings are
129    // treated as width-1 space separators (trim:false keeps them).
130    if line.is_empty() {
131        return 1;
132    }
133
134    let mut rows: usize = 0;
135    let mut line_width: usize = 0; // current row's used width
136    let mut started = false; // has any content been placed on the current row?
137
138    let push_sep = |w: &mut usize, started: &mut bool| {
139        // A separator space only counts when continuing a row that already has a word.
140        if *started {
141            *w += 1;
142        }
143    };
144
145    // Iterate words split on ' '. Track gaps via the empty-string fragments that
146    // split(' ') emits for consecutive delimiters.
147    for word in line.split(' ') {
148        let word_w = UnicodeWidthStr::width(word);
149
150        if word.is_empty() {
151            // A bare separator space (consecutive delimiters, or leading space).
152            // It belongs to the current row: add its width; if it overflows, wrap.
153            push_sep(&mut line_width, &mut started);
154            // The separator itself is 1 cell; if the row now exceeds width, it
155            // forces a wrap. With trim:false ratatui keeps the space on the
156            // current row (it does not move it down), so just mark started.
157            started = true;
158            continue;
159        }
160
161        // Tentative width if we append this word (with a separating space when
162        // the current row already has content).
163        let sep = if started { 1 } else { 0 };
164        if line_width + sep + word_w <= content_width {
165            // Fits on the current row.
166            line_width += sep + word_w;
167            started = true;
168            continue;
169        }
170
171        // Doesn't fit. Two sub-cases:
172        // (a) The word itself fits within content_width → start a fresh row.
173        // (b) The word is wider than content_width → it must be broken across rows.
174        if word_w <= content_width {
175            rows += 1; // close the previous row
176            line_width = word_w;
177            started = true;
178            continue;
179        }
180
181        // Long word: break greedily.
182        // First, if the current row has leftover space, ratatui fills it with as
183        // much of the word as fits, then continues on new rows. We account for
184        // the partial fill that fits in the remaining space (no separator here
185        // since we're mid-word).
186        let mut remaining = word_w;
187        if started && line_width < content_width {
188            // Fill the rest of the current row with the head of the word.
189            let fits = content_width - line_width; // no separator: continuing the word
190            // `fits` cells consumed from the word.
191            let _ = fits; // consumed implicitly: subtract below
192            // Consume `fits` worth of the word off the front.
193            remaining = remaining.saturating_sub(content_width - line_width);
194            rows += 1; // close this (now-full) row
195            started = false;
196            line_width = 0;
197        }
198        // Now break the rest of the word into full-width chunks.
199        while remaining > 0 {
200            if remaining > content_width {
201                rows += 1;
202                remaining -= content_width;
203            } else {
204                // Tail of the word fits on one row.
205                line_width = remaining;
206                started = true;
207                remaining = 0;
208            }
209        }
210    }
211
212    // Account for the final (open) row.
213    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    /// Build a surface whose `root` is a single Text with the given `text`,
228    /// render it into a `cols x rows` TestBackend buffer, and return the buffer
229    /// plus the non-blank row count over the whole area.
230    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, &registry, &render_catalog);
265                renderer.render(frame, frame.area(), None);
266            })
267            .unwrap();
268        terminal.backend().buffer().clone()
269    }
270
271    /// Count rows (within `rows` tall, scanning all `cols` columns) that contain
272    /// any non-blank cell.
273    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    /// Measure the Text root's natural height at a given available width by
280    /// invoking `TextComponent.natural_height` directly with a no-op
281    /// measure_child closure (Text is a leaf, ignores measure_child).
282    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    /// The Text root is shrink-wrapped & vertically centered by the surface
331    /// renderer, so the buffer it draws equals the natural height the renderer
332    /// measured. `natural_height` is the FULL footprint (text rows + 1-cell
333    /// margin top + 1-cell margin bottom = +2), while `non_blank_row_count`
334    /// only counts rows that have content — the two margin rows are blank. So
335    /// the locked invariant is: `measured == rendered_non_blank + 2`.
336    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        // ~60 chars of words, wrapped at a narrow width (cols=20 ⇒ content_width=18).
354        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        // Same string at a wider width (cols=40 ⇒ content_width=38).
361        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        // Explicit newlines plus a long final line that still wraps.
368        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        // A single word wider than content_width forces word-breaking.
376        let text = "supercalifragilisticexpialidocious is a very long word indeed";
377        assert_consistent(text, 20, 30);
378        assert_consistent(text, 16, 30);
379    }
380}