1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
//! Text component — renders a styled paragraph.
use ratatui::{
Frame,
layout::Rect,
style::{Modifier, Style},
widgets::{Paragraph, Wrap},
};
use unicode_width::UnicodeWidthStr;
use a2ui_base::model::component_context::ComponentContext;
use a2ui_base::protocol::common_types::DynamicString;
use crate::component_impl::TuiComponent;
/// Text component implementation.
///
/// Renders a `ratatui::widgets::Paragraph` with variant-based styling.
/// Applies a default margin of 1 cell on all sides (leaf component).
pub struct TextComponent;
impl TuiComponent for TextComponent {
fn name(&self) -> &'static str {
"Text"
}
fn render(
&self,
ctx: &ComponentContext,
area: Rect,
frame: &mut Frame,
_render_child: &mut dyn FnMut(&str, Rect, &mut Frame, &str),
_measure_child: &mut dyn FnMut(&str, &str, u16) -> Option<u16>,
) {
let comp_model = match ctx.components.get(&ctx.component_id) {
Some(m) => m,
None => return,
};
// Resolve the text content.
let text_content = match comp_model.get_property::<DynamicString>("text") {
Some(ds) => ctx.data_context.resolve_dynamic_string(&ds),
None => String::new(),
};
// Determine variant styling.
let variant: Option<String> = comp_model.get_property("variant");
let style = match variant.as_deref() {
Some("h1") => Style::default()
.add_modifier(Modifier::BOLD)
.fg(ratatui::style::Color::Cyan),
Some("h2") => Style::default()
.add_modifier(Modifier::BOLD)
.fg(ratatui::style::Color::Green),
Some("h3") => Style::default().add_modifier(Modifier::BOLD),
Some("h4") => Style::default().add_modifier(Modifier::UNDERLINED),
Some("h5") => Style::default().add_modifier(Modifier::ITALIC),
Some("caption") => Style::default().add_modifier(Modifier::DIM),
Some("body") | None => Style::default(),
_ => Style::default(),
};
// Apply default margin of 1 cell on all sides, but never collapse to zero so a
// Text nested in a tight area (e.g. a Button label) still renders.
let inner = crate::layout_engine::padded_content(area);
if inner.width == 0 || inner.height == 0 {
return;
}
let paragraph = Paragraph::new(text_content)
.style(style)
.wrap(Wrap { trim: false });
frame.render_widget(paragraph, inner);
}
fn natural_height(
&self,
ctx: &ComponentContext,
available_width: u16,
_measure_child: &mut dyn FnMut(&str, &str, u16) -> Option<u16>,
) -> Option<u16> {
let comp_model = ctx.components.get(&ctx.component_id);
// Resolve the text content (None → empty string).
let content = match comp_model.and_then(|m| m.get_property::<DynamicString>("text")) {
Some(ds) => ctx.data_context.resolve_dynamic_string(&ds),
None => String::new(),
};
// Mirror `padded_content`: subtract 1 cell per side (→ 2 total) only when
// the available width is > 2; otherwise the render pass keeps the full width.
let content_width = if available_width > 2 {
available_width.saturating_sub(2)
} else {
available_width
};
// Avoid division-by-zero; ratatui can't render anything in 0 cols anyway.
let content_width = content_width.max(1) as usize;
let mut total: usize = 0;
for line in content.split('\n') {
total += wrapped_row_count(line, content_width);
}
// +2 for the margin the render adds via `padded_content` (1 cell top + 1 bottom),
// matching the horizontal subtraction above.
Some((total as u16).saturating_add(2))
}
}
/// Count how many display rows `line` occupies when wrapped at `content_width`
/// using a greedy word-wrap that mirrors ratatui's `Wrap { trim: false }`.
///
/// Rules (matching ratatui 0.30 behaviour for `trim: false`):
/// - Words are split on ASCII spaces (`' '`). Consecutive spaces are preserved
/// (trim:false keeps leading/repeated whitespace).
/// - A single separating space (width 1) is kept between words on the same row.
/// - When the next word (+ a preceding space when the line is non-empty) no
/// longer fits, it starts a new row.
/// - A word wider than `content_width` is broken greedily at `content_width`
/// boundaries (ratatui breaks long words), and any leftover width carries
/// over to continue the row.
/// - An empty line counts as 1 row.
fn wrapped_row_count(line: &str, content_width: usize) -> usize {
// Split keeping the words; multiple consecutive spaces become empty "words"
// but each space between real words still consumes width in the line. We
// rebuild by iterating over the original spacing using split(' ') which
// yields empty strings for consecutive delimiters — those empty strings are
// treated as width-1 space separators (trim:false keeps them).
if line.is_empty() {
return 1;
}
let mut rows: usize = 0;
let mut line_width: usize = 0; // current row's used width
let mut started = false; // has any content been placed on the current row?
let push_sep = |w: &mut usize, started: &mut bool| {
// A separator space only counts when continuing a row that already has a word.
if *started {
*w += 1;
}
};
// Iterate words split on ' '. Track gaps via the empty-string fragments that
// split(' ') emits for consecutive delimiters.
for word in line.split(' ') {
let word_w = UnicodeWidthStr::width(word);
if word.is_empty() {
// A bare separator space (consecutive delimiters, or leading space).
// It belongs to the current row: add its width; if it overflows, wrap.
push_sep(&mut line_width, &mut started);
// The separator itself is 1 cell; if the row now exceeds width, it
// forces a wrap. With trim:false ratatui keeps the space on the
// current row (it does not move it down), so just mark started.
started = true;
continue;
}
// Tentative width if we append this word (with a separating space when
// the current row already has content).
let sep = if started { 1 } else { 0 };
if line_width + sep + word_w <= content_width {
// Fits on the current row.
line_width += sep + word_w;
started = true;
continue;
}
// Doesn't fit. Two sub-cases:
// (a) The word itself fits within content_width → start a fresh row.
// (b) The word is wider than content_width → it must be broken across rows.
if word_w <= content_width {
rows += 1; // close the previous row
line_width = word_w;
started = true;
continue;
}
// Long word: break greedily.
// First, if the current row has leftover space, ratatui fills it with as
// much of the word as fits, then continues on new rows. We account for
// the partial fill that fits in the remaining space (no separator here
// since we're mid-word).
let mut remaining = word_w;
if started && line_width < content_width {
// Fill the rest of the current row with the head of the word.
let fits = content_width - line_width; // no separator: continuing the word
// `fits` cells consumed from the word.
let _ = fits; // consumed implicitly: subtract below
// Consume `fits` worth of the word off the front.
remaining = remaining.saturating_sub(content_width - line_width);
rows += 1; // close this (now-full) row
started = false;
line_width = 0;
}
// Now break the rest of the word into full-width chunks.
while remaining > 0 {
if remaining > content_width {
rows += 1;
remaining -= content_width;
} else {
// Tail of the word fits on one row.
line_width = remaining;
started = true;
remaining = 0;
}
}
}
// Account for the final (open) row.
rows + 1
}
#[cfg(test)]
mod tests {
use super::*;
use a2ui_base::catalog::Catalog;
use a2ui_base::message_processor::MessageProcessor;
use crate::catalogs::basic::{build_basic_catalog, build_basic_registry};
use crate::component_impl::TuiComponent;
use crate::surface::SurfaceRenderer;
use ratatui::backend::TestBackend;
use std::collections::HashMap;
/// Build a surface whose `root` is a single Text with the given `text`,
/// render it into a `cols x rows` TestBackend buffer, and return the buffer
/// plus the non-blank row count over the whole area.
fn render_text_to_buffer(text: &str, 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": [
{ "id": "root", "component": "Text", "text": text }
]
}
});
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()
}
/// Count rows (within `rows` tall, scanning all `cols` columns) that contain
/// any non-blank cell.
fn non_blank_row_count(buf: &ratatui::buffer::Buffer, cols: u16, rows: u16) -> u16 {
(0..rows)
.filter(|&y| (0..cols).any(|x| buf[(x, y)].symbol() != " "))
.count() as u16
}
/// Measure the Text root's natural height at a given available width by
/// invoking `TextComponent.natural_height` directly with a no-op
/// measure_child closure (Text is a leaf, ignores measure_child).
fn measure_text(text: &str, available_width: u16) -> u16 {
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": [
{ "id": "root", "component": "Text", "text": text }
]
}
});
processor
.process_message(MessageProcessor::parse_message(&update.to_string()).unwrap())
.unwrap();
let surface = processor.model.get_surface("test").expect("surface exists");
let components = surface.components.borrow();
let data_model = surface.data_model.borrow();
let functions: HashMap<String, Box<dyn a2ui_base::catalog::function_api::FunctionImplementation>> =
HashMap::new();
let ctx = ComponentContext::new(
"root".to_string(),
"test".to_string(),
&data_model,
&components,
&functions,
"",
None,
);
let mut measure_child = |_id: &str, _base: &str, _w: u16| -> Option<u16> { None };
TextComponent
.natural_height(&ctx, available_width, &mut measure_child)
.expect("natural_height returns Some")
}
/// The Text root is shrink-wrapped & vertically centered by the surface
/// renderer, so the buffer it draws equals the natural height the renderer
/// measured. `natural_height` is the FULL footprint (text rows + 1-cell
/// margin top + 1-cell margin bottom = +2), while `non_blank_row_count`
/// only counts rows that have content — the two margin rows are blank. So
/// the locked invariant is: `measured == rendered_non_blank + 2`.
fn assert_consistent(text: &str, cols: u16, rows: u16) {
let buf = render_text_to_buffer(text, cols, rows);
let rendered = non_blank_row_count(&buf, cols, rows);
let measured = measure_text(text, cols);
assert_eq!(
measured,
rendered + 2,
"measure/render mismatch at {cols}x{rows}: natural_height returned {measured} (full \
footprint), but rendered {rendered} non-blank content rows ⇒ footprint should be \
{} (rows + 2 margin)\n\
text={text:?}",
rendered + 2,
);
}
#[test]
fn natural_height_matches_render_narrow() {
// ~60 chars of words, wrapped at a narrow width (cols=20 ⇒ content_width=18).
let text = "the quick brown fox jumps over the lazy dog and runs away fast";
assert_consistent(text, 20, 30);
}
#[test]
fn natural_height_matches_render_wide() {
// Same string at a wider width (cols=40 ⇒ content_width=38).
let text = "the quick brown fox jumps over the lazy dog and runs away fast";
assert_consistent(text, 40, 30);
}
#[test]
fn natural_height_matches_render_multiline() {
// Explicit newlines plus a long final line that still wraps.
let text = "first line\nsecond line here that is longer than the narrow width allows";
assert_consistent(text, 20, 30);
assert_consistent(text, 40, 20);
}
#[test]
fn natural_height_matches_long_word() {
// A single word wider than content_width forces word-breaking.
let text = "supercalifragilisticexpialidocious is a very long word indeed";
assert_consistent(text, 20, 30);
assert_consistent(text, 16, 30);
}
}