1use presentar_core::{
4 widget::{FontStyle, FontWeight, LayoutResult, TextStyle},
5 Brick, BrickAssertion, BrickBudget, BrickVerification, Canvas, Color, Constraints, Event, Rect,
6 Size, TypeId, Widget,
7};
8use serde::{Deserialize, Serialize};
9use std::any::Any;
10use std::time::Duration;
11
12#[derive(Clone, Serialize, Deserialize)]
14pub struct Text {
15 content: String,
17 color: Color,
19 font_size: f32,
21 font_weight: FontWeight,
23 font_style: FontStyle,
25 line_height: f32,
27 max_width: Option<f32>,
29 test_id_value: Option<String>,
31 #[serde(skip)]
33 bounds: Rect,
34}
35
36impl Text {
37 #[must_use]
39 pub fn new(content: impl Into<String>) -> Self {
40 Self {
41 content: content.into(),
42 color: Color::BLACK,
43 font_size: 16.0,
44 font_weight: FontWeight::Normal,
45 font_style: FontStyle::Normal,
46 line_height: 1.2,
47 max_width: None,
48 test_id_value: None,
49 bounds: Rect::default(),
50 }
51 }
52
53 #[must_use]
55 pub const fn color(mut self, color: Color) -> Self {
56 self.color = color;
57 self
58 }
59
60 #[must_use]
62 pub const fn font_size(mut self, size: f32) -> Self {
63 self.font_size = size;
64 self
65 }
66
67 #[must_use]
69 pub const fn font_weight(mut self, weight: FontWeight) -> Self {
70 self.font_weight = weight;
71 self
72 }
73
74 #[must_use]
76 pub const fn font_style(mut self, style: FontStyle) -> Self {
77 self.font_style = style;
78 self
79 }
80
81 #[must_use]
83 pub const fn line_height(mut self, multiplier: f32) -> Self {
84 self.line_height = multiplier;
85 self
86 }
87
88 #[must_use]
90 pub const fn max_width(mut self, width: f32) -> Self {
91 self.max_width = Some(width);
92 self
93 }
94
95 #[must_use]
97 pub fn with_test_id(mut self, id: impl Into<String>) -> Self {
98 self.test_id_value = Some(id.into());
99 self
100 }
101
102 #[must_use]
104 pub fn content(&self) -> &str {
105 &self.content
106 }
107
108 fn estimate_size(&self, max_width: f32) -> Size {
110 let char_width = self.font_size * 0.6;
112 let line_height = self.font_size * self.line_height;
113
114 if self.content.is_empty() {
115 return Size::new(0.0, line_height);
116 }
117
118 let total_width = self.content.len() as f32 * char_width;
119
120 if let Some(max_w) = self.max_width {
121 let effective_max = max_w.min(max_width);
122 if total_width > effective_max {
123 let lines = (total_width / effective_max).ceil();
124 return Size::new(effective_max, lines * line_height);
125 }
126 }
127
128 Size::new(total_width.min(max_width), line_height)
129 }
130}
131
132impl Widget for Text {
133 fn type_id(&self) -> TypeId {
134 TypeId::of::<Self>()
135 }
136
137 fn measure(&self, constraints: Constraints) -> Size {
138 let size = self.estimate_size(constraints.max_width);
139 constraints.constrain(size)
140 }
141
142 fn layout(&mut self, bounds: Rect) -> LayoutResult {
143 self.bounds = bounds;
144 LayoutResult {
145 size: bounds.size(),
146 }
147 }
148
149 fn paint(&self, canvas: &mut dyn Canvas) {
150 let style = TextStyle {
151 size: self.font_size,
152 color: self.color,
153 weight: self.font_weight,
154 style: self.font_style,
155 };
156
157 canvas.draw_text(&self.content, self.bounds.origin(), &style);
158 }
159
160 fn event(&mut self, _event: &Event) -> Option<Box<dyn Any + Send>> {
161 None }
163
164 fn children(&self) -> &[Box<dyn Widget>] {
165 &[]
166 }
167
168 fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
169 &mut []
170 }
171
172 fn test_id(&self) -> Option<&str> {
173 self.test_id_value.as_deref()
174 }
175}
176
177impl Brick for Text {
179 fn brick_name(&self) -> &'static str {
180 "Text"
181 }
182
183 fn assertions(&self) -> &[BrickAssertion] {
184 &[
185 BrickAssertion::TextVisible,
186 BrickAssertion::MaxLatencyMs(16),
187 ]
188 }
189
190 fn budget(&self) -> BrickBudget {
191 BrickBudget::uniform(16)
192 }
193
194 fn verify(&self) -> BrickVerification {
195 let mut passed = Vec::new();
196 let mut failed = Vec::new();
197
198 if self.content.is_empty() {
200 failed.push((BrickAssertion::TextVisible, "Text content is empty".into()));
201 } else {
202 passed.push(BrickAssertion::TextVisible);
203 }
204
205 passed.push(BrickAssertion::MaxLatencyMs(16));
207
208 BrickVerification {
209 passed,
210 failed,
211 verification_time: Duration::from_micros(10),
212 }
213 }
214
215 fn to_html(&self) -> String {
216 let test_id = self.test_id_value.as_deref().unwrap_or("text");
217 format!(
218 r#"<span class="brick-text" data-testid="{}">{}</span>"#,
219 test_id, self.content
220 )
221 }
222
223 fn to_css(&self) -> String {
224 format!(
225 r".brick-text {{
226 color: {};
227 font-size: {}px;
228 line-height: {};
229 display: inline-block;
230}}",
231 self.color.to_hex(),
232 self.font_size,
233 self.line_height
234 )
235 }
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241 use presentar_core::draw::DrawCommand;
242 use presentar_core::widget::AccessibleRole;
243 use presentar_core::{Brick, BrickAssertion, Point, RecordingCanvas, Widget};
244
245 #[test]
246 fn test_text_new() {
247 let t = Text::new("Hello");
248 assert_eq!(t.content(), "Hello");
249 assert_eq!(t.font_size, 16.0);
250 }
251
252 #[test]
253 fn test_text_builder() {
254 let t = Text::new("Test")
255 .color(Color::WHITE)
256 .font_size(24.0)
257 .font_weight(FontWeight::Bold)
258 .with_test_id("my-text");
259
260 assert_eq!(t.color, Color::WHITE);
261 assert_eq!(t.font_size, 24.0);
262 assert_eq!(t.font_weight, FontWeight::Bold);
263 assert_eq!(Widget::test_id(&t), Some("my-text"));
264 }
265
266 #[test]
267 fn test_text_measure() {
268 let t = Text::new("Hello");
269 let size = t.measure(Constraints::loose(Size::new(1000.0, 1000.0)));
270 assert!(size.width > 0.0);
271 assert!(size.height > 0.0);
272 }
273
274 #[test]
275 fn test_text_empty() {
276 let t = Text::new("");
277 let size = t.measure(Constraints::loose(Size::new(1000.0, 1000.0)));
278 assert_eq!(size.width, 0.0);
279 assert!(size.height > 0.0); }
281
282 #[test]
285 fn test_text_paint_draws_text() {
286 let mut text = Text::new("Hello World");
287 text.layout(Rect::new(10.0, 20.0, 200.0, 30.0));
288
289 let mut canvas = RecordingCanvas::new();
290 text.paint(&mut canvas);
291
292 assert_eq!(canvas.command_count(), 1);
293 match &canvas.commands()[0] {
294 DrawCommand::Text {
295 content, position, ..
296 } => {
297 assert_eq!(content, "Hello World");
298 assert_eq!(*position, Point::new(10.0, 20.0));
299 }
300 _ => panic!("Expected Text command"),
301 }
302 }
303
304 #[test]
305 fn test_text_paint_uses_color() {
306 let mut text = Text::new("Colored").color(Color::RED);
307 text.layout(Rect::new(0.0, 0.0, 100.0, 20.0));
308
309 let mut canvas = RecordingCanvas::new();
310 text.paint(&mut canvas);
311
312 match &canvas.commands()[0] {
313 DrawCommand::Text { style, .. } => {
314 assert_eq!(style.color, Color::RED);
315 }
316 _ => panic!("Expected Text command"),
317 }
318 }
319
320 #[test]
321 fn test_text_paint_uses_font_size() {
322 let mut text = Text::new("Large").font_size(32.0);
323 text.layout(Rect::new(0.0, 0.0, 200.0, 40.0));
324
325 let mut canvas = RecordingCanvas::new();
326 text.paint(&mut canvas);
327
328 match &canvas.commands()[0] {
329 DrawCommand::Text { style, .. } => {
330 assert_eq!(style.size, 32.0);
331 }
332 _ => panic!("Expected Text command"),
333 }
334 }
335
336 #[test]
337 fn test_text_paint_uses_font_weight() {
338 let mut text = Text::new("Bold").font_weight(FontWeight::Bold);
339 text.layout(Rect::new(0.0, 0.0, 100.0, 20.0));
340
341 let mut canvas = RecordingCanvas::new();
342 text.paint(&mut canvas);
343
344 match &canvas.commands()[0] {
345 DrawCommand::Text { style, .. } => {
346 assert_eq!(style.weight, FontWeight::Bold);
347 }
348 _ => panic!("Expected Text command"),
349 }
350 }
351
352 #[test]
353 fn test_text_paint_uses_font_style() {
354 let mut text = Text::new("Italic").font_style(FontStyle::Italic);
355 text.layout(Rect::new(0.0, 0.0, 100.0, 20.0));
356
357 let mut canvas = RecordingCanvas::new();
358 text.paint(&mut canvas);
359
360 match &canvas.commands()[0] {
361 DrawCommand::Text { style, .. } => {
362 assert_eq!(style.style, FontStyle::Italic);
363 }
364 _ => panic!("Expected Text command"),
365 }
366 }
367
368 #[test]
369 fn test_text_paint_empty() {
370 let mut text = Text::new("");
371 text.layout(Rect::new(0.0, 0.0, 100.0, 20.0));
372
373 let mut canvas = RecordingCanvas::new();
374 text.paint(&mut canvas);
375
376 assert_eq!(canvas.command_count(), 1);
378 match &canvas.commands()[0] {
379 DrawCommand::Text { content, .. } => {
380 assert!(content.is_empty());
381 }
382 _ => panic!("Expected Text command"),
383 }
384 }
385
386 #[test]
387 fn test_text_paint_position_from_layout() {
388 let mut text = Text::new("Positioned");
389 text.layout(Rect::new(50.0, 100.0, 200.0, 30.0));
390
391 let mut canvas = RecordingCanvas::new();
392 text.paint(&mut canvas);
393
394 match &canvas.commands()[0] {
395 DrawCommand::Text { position, .. } => {
396 assert_eq!(position.x, 50.0);
397 assert_eq!(position.y, 100.0);
398 }
399 _ => panic!("Expected Text command"),
400 }
401 }
402
403 #[test]
406 fn test_text_type_id() {
407 let t = Text::new("test");
408 assert_eq!(Widget::type_id(&t), TypeId::of::<Text>());
409 }
410
411 #[test]
412 fn test_text_layout_sets_bounds() {
413 let mut t = Text::new("test");
414 let result = t.layout(Rect::new(10.0, 20.0, 100.0, 30.0));
415 assert_eq!(result.size, Size::new(100.0, 30.0));
416 assert_eq!(t.bounds, Rect::new(10.0, 20.0, 100.0, 30.0));
417 }
418
419 #[test]
420 fn test_text_children_empty() {
421 let t = Text::new("test");
422 assert!(t.children().is_empty());
423 }
424
425 #[test]
426 fn test_text_event_returns_none() {
427 let mut t = Text::new("test");
428 t.layout(Rect::new(0.0, 0.0, 100.0, 20.0));
429 let result = t.event(&Event::MouseEnter);
430 assert!(result.is_none());
431 }
432
433 #[test]
434 fn test_text_line_height() {
435 let t = Text::new("test").line_height(1.5);
436 assert_eq!(t.line_height, 1.5);
437 }
438
439 #[test]
440 fn test_text_max_width() {
441 let t = Text::new("test").max_width(200.0);
442 assert_eq!(t.max_width, Some(200.0));
443 }
444
445 #[test]
446 fn test_text_measure_with_max_width() {
447 let t = Text::new("A very long text that should wrap").max_width(50.0);
448 let size = t.measure(Constraints::loose(Size::new(1000.0, 1000.0)));
449 assert!(size.width <= 50.0);
450 assert!(size.height > t.font_size); }
452
453 #[test]
454 fn test_text_content_accessor() {
455 let t = Text::new("Hello World");
456 assert_eq!(t.content(), "Hello World");
457 }
458
459 #[test]
462 fn test_text_is_interactive() {
463 let t = Text::new("Test");
464 assert!(!t.is_interactive());
465 }
466
467 #[test]
468 fn test_text_is_focusable() {
469 let t = Text::new("Test");
470 assert!(!t.is_focusable());
471 }
472
473 #[test]
474 fn test_text_accessible_role() {
475 let t = Text::new("Test");
476 assert_eq!(t.accessible_role(), AccessibleRole::Generic);
477 }
478
479 #[test]
480 fn test_text_accessible_name() {
481 let t = Text::new("Accessible Text");
482 assert!(Widget::accessible_name(&t).is_none());
484 }
485
486 #[test]
487 fn test_text_children_mut() {
488 let mut t = Text::new("Test");
489 assert!(t.children_mut().is_empty());
490 }
491
492 #[test]
495 fn test_text_brick_name() {
496 let t = Text::new("Test");
497 assert_eq!(t.brick_name(), "Text");
498 }
499
500 #[test]
501 fn test_text_brick_assertions() {
502 let t = Text::new("Test");
503 let assertions = t.assertions();
504 assert_eq!(assertions.len(), 2);
505 assert!(assertions.contains(&BrickAssertion::MaxLatencyMs(16)));
506 assert!(assertions.contains(&BrickAssertion::TextVisible));
507 }
508
509 #[test]
510 fn test_text_brick_budget() {
511 let t = Text::new("Test");
512 let budget = t.budget();
513 assert!(budget.measure_ms > 0);
514 assert!(budget.layout_ms > 0);
515 assert!(budget.paint_ms > 0);
516 }
517
518 #[test]
519 fn test_text_brick_verify_with_content() {
520 let t = Text::new("Visible Text");
521 let verification = t.verify();
522 assert!(verification.passed.contains(&BrickAssertion::TextVisible));
523 assert!(verification
524 .passed
525 .contains(&BrickAssertion::MaxLatencyMs(16)));
526 assert!(verification.failed.is_empty());
527 }
528
529 #[test]
530 fn test_text_brick_verify_empty_content() {
531 let t = Text::new("");
532 let verification = t.verify();
533 assert!(verification
535 .failed
536 .iter()
537 .any(|(a, _)| *a == BrickAssertion::TextVisible));
538 }
539
540 #[test]
541 fn test_text_to_html() {
542 let t = Text::new("Hello World").with_test_id("greeting");
543 let html = t.to_html();
544 assert!(html.contains("brick-text"));
545 assert!(html.contains("data-testid=\"greeting\""));
546 assert!(html.contains("Hello World"));
547 }
548
549 #[test]
550 fn test_text_to_html_default_test_id() {
551 let t = Text::new("Hello");
552 let html = t.to_html();
553 assert!(html.contains("data-testid=\"text\""));
554 }
555
556 #[test]
557 fn test_text_to_css() {
558 let t = Text::new("Text")
559 .font_size(20.0)
560 .color(Color::RED)
561 .line_height(1.5);
562 let css = t.to_css();
563 assert!(css.contains("brick-text"));
564 assert!(css.contains("font-size: 20px"));
565 assert!(css.contains("line-height: 1.5"));
566 }
567
568 #[test]
569 fn test_text_default_values() {
570 let t = Text::new("");
571 assert!(t.content.is_empty());
572 assert_eq!(t.font_size, 16.0);
573 assert_eq!(t.line_height, 1.2);
574 }
575
576 #[test]
577 fn test_text_font_weight_default() {
578 let t = Text::new("Test");
579 assert_eq!(t.font_weight, FontWeight::Normal);
580 }
581
582 #[test]
583 fn test_text_font_style_default() {
584 let t = Text::new("Test");
585 assert_eq!(t.font_style, FontStyle::Normal);
586 }
587
588 #[test]
589 fn test_text_clone() {
590 let t = Text::new("Clone Me").font_size(20.0).color(Color::BLUE);
591 let cloned = t.clone();
592 assert_eq!(cloned.content(), "Clone Me");
593 assert_eq!(cloned.font_size, 20.0);
594 assert_eq!(cloned.color, Color::BLUE);
595 }
596}