presentar_widgets/
text.rs

1//! Text widget for displaying text content.
2
3use 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/// Text widget for displaying styled text.
13#[derive(Clone, Serialize, Deserialize)]
14pub struct Text {
15    /// Text content
16    content: String,
17    /// Text color
18    color: Color,
19    /// Font size in pixels
20    font_size: f32,
21    /// Font weight
22    font_weight: FontWeight,
23    /// Font style
24    font_style: FontStyle,
25    /// Line height multiplier
26    line_height: f32,
27    /// Maximum width before wrapping (None = no wrapping)
28    max_width: Option<f32>,
29    /// Test ID
30    test_id_value: Option<String>,
31    /// Cached bounds
32    #[serde(skip)]
33    bounds: Rect,
34}
35
36impl Text {
37    /// Create new text widget.
38    #[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    /// Set text color.
54    #[must_use]
55    pub const fn color(mut self, color: Color) -> Self {
56        self.color = color;
57        self
58    }
59
60    /// Set font size.
61    #[must_use]
62    pub const fn font_size(mut self, size: f32) -> Self {
63        self.font_size = size;
64        self
65    }
66
67    /// Set font weight.
68    #[must_use]
69    pub const fn font_weight(mut self, weight: FontWeight) -> Self {
70        self.font_weight = weight;
71        self
72    }
73
74    /// Set font style.
75    #[must_use]
76    pub const fn font_style(mut self, style: FontStyle) -> Self {
77        self.font_style = style;
78        self
79    }
80
81    /// Set line height multiplier.
82    #[must_use]
83    pub const fn line_height(mut self, multiplier: f32) -> Self {
84        self.line_height = multiplier;
85        self
86    }
87
88    /// Set maximum width for text wrapping.
89    #[must_use]
90    pub const fn max_width(mut self, width: f32) -> Self {
91        self.max_width = Some(width);
92        self
93    }
94
95    /// Set test ID.
96    #[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    /// Get the text content.
103    #[must_use]
104    pub fn content(&self) -> &str {
105        &self.content
106    }
107
108    /// Estimate text size (simplified - real implementation would use font metrics).
109    fn estimate_size(&self, max_width: f32) -> Size {
110        // Simplified: assume ~0.6 em width per character
111        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 // Text is not interactive
162    }
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
177// PROBAR-SPEC-009: Brick Architecture - Tests define interface
178impl 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        // Verify text visibility
199        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        // Latency assertion always passes at verification time
206        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); // Line height
280    }
281
282    // ===== Paint Tests =====
283
284    #[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        // Should still draw (empty text)
377        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    // ===== Widget Trait Tests =====
404
405    #[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); // Multiple lines
451    }
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    // ===== Additional Coverage Tests =====
460
461    #[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        // Text widget doesn't implement custom accessible_name
483        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    // ===== Brick Trait Tests =====
493
494    #[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        // Empty text should fail TextVisible
534        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}