presentar_widgets/
text.rs

1//! Text widget for displaying text content.
2
3use presentar_core::{
4    widget::{FontStyle, FontWeight, LayoutResult, TextStyle},
5    Canvas, Color, Constraints, Event, Rect, Size, TypeId, Widget,
6};
7use serde::{Deserialize, Serialize};
8use std::any::Any;
9
10/// Text widget for displaying styled text.
11#[derive(Clone, Serialize, Deserialize)]
12pub struct Text {
13    /// Text content
14    content: String,
15    /// Text color
16    color: Color,
17    /// Font size in pixels
18    font_size: f32,
19    /// Font weight
20    font_weight: FontWeight,
21    /// Font style
22    font_style: FontStyle,
23    /// Line height multiplier
24    line_height: f32,
25    /// Maximum width before wrapping (None = no wrapping)
26    max_width: Option<f32>,
27    /// Test ID
28    test_id_value: Option<String>,
29    /// Cached bounds
30    #[serde(skip)]
31    bounds: Rect,
32}
33
34impl Text {
35    /// Create new text widget.
36    #[must_use]
37    pub fn new(content: impl Into<String>) -> Self {
38        Self {
39            content: content.into(),
40            color: Color::BLACK,
41            font_size: 16.0,
42            font_weight: FontWeight::Normal,
43            font_style: FontStyle::Normal,
44            line_height: 1.2,
45            max_width: None,
46            test_id_value: None,
47            bounds: Rect::default(),
48        }
49    }
50
51    /// Set text color.
52    #[must_use]
53    pub const fn color(mut self, color: Color) -> Self {
54        self.color = color;
55        self
56    }
57
58    /// Set font size.
59    #[must_use]
60    pub const fn font_size(mut self, size: f32) -> Self {
61        self.font_size = size;
62        self
63    }
64
65    /// Set font weight.
66    #[must_use]
67    pub const fn font_weight(mut self, weight: FontWeight) -> Self {
68        self.font_weight = weight;
69        self
70    }
71
72    /// Set font style.
73    #[must_use]
74    pub const fn font_style(mut self, style: FontStyle) -> Self {
75        self.font_style = style;
76        self
77    }
78
79    /// Set line height multiplier.
80    #[must_use]
81    pub const fn line_height(mut self, multiplier: f32) -> Self {
82        self.line_height = multiplier;
83        self
84    }
85
86    /// Set maximum width for text wrapping.
87    #[must_use]
88    pub const fn max_width(mut self, width: f32) -> Self {
89        self.max_width = Some(width);
90        self
91    }
92
93    /// Set test ID.
94    #[must_use]
95    pub fn with_test_id(mut self, id: impl Into<String>) -> Self {
96        self.test_id_value = Some(id.into());
97        self
98    }
99
100    /// Get the text content.
101    #[must_use]
102    pub fn content(&self) -> &str {
103        &self.content
104    }
105
106    /// Estimate text size (simplified - real implementation would use font metrics).
107    fn estimate_size(&self, max_width: f32) -> Size {
108        // Simplified: assume ~0.6 em width per character
109        let char_width = self.font_size * 0.6;
110        let line_height = self.font_size * self.line_height;
111
112        if self.content.is_empty() {
113            return Size::new(0.0, line_height);
114        }
115
116        let total_width = self.content.len() as f32 * char_width;
117
118        if let Some(max_w) = self.max_width {
119            let effective_max = max_w.min(max_width);
120            if total_width > effective_max {
121                let lines = (total_width / effective_max).ceil();
122                return Size::new(effective_max, lines * line_height);
123            }
124        }
125
126        Size::new(total_width.min(max_width), line_height)
127    }
128}
129
130impl Widget for Text {
131    fn type_id(&self) -> TypeId {
132        TypeId::of::<Self>()
133    }
134
135    fn measure(&self, constraints: Constraints) -> Size {
136        let size = self.estimate_size(constraints.max_width);
137        constraints.constrain(size)
138    }
139
140    fn layout(&mut self, bounds: Rect) -> LayoutResult {
141        self.bounds = bounds;
142        LayoutResult {
143            size: bounds.size(),
144        }
145    }
146
147    fn paint(&self, canvas: &mut dyn Canvas) {
148        let style = TextStyle {
149            size: self.font_size,
150            color: self.color,
151            weight: self.font_weight,
152            style: self.font_style,
153        };
154
155        canvas.draw_text(&self.content, self.bounds.origin(), &style);
156    }
157
158    fn event(&mut self, _event: &Event) -> Option<Box<dyn Any + Send>> {
159        None // Text is not interactive
160    }
161
162    fn children(&self) -> &[Box<dyn Widget>] {
163        &[]
164    }
165
166    fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
167        &mut []
168    }
169
170    fn test_id(&self) -> Option<&str> {
171        self.test_id_value.as_deref()
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use presentar_core::draw::DrawCommand;
179    use presentar_core::{Point, RecordingCanvas, Widget};
180
181    #[test]
182    fn test_text_new() {
183        let t = Text::new("Hello");
184        assert_eq!(t.content(), "Hello");
185        assert_eq!(t.font_size, 16.0);
186    }
187
188    #[test]
189    fn test_text_builder() {
190        let t = Text::new("Test")
191            .color(Color::WHITE)
192            .font_size(24.0)
193            .font_weight(FontWeight::Bold)
194            .with_test_id("my-text");
195
196        assert_eq!(t.color, Color::WHITE);
197        assert_eq!(t.font_size, 24.0);
198        assert_eq!(t.font_weight, FontWeight::Bold);
199        assert_eq!(Widget::test_id(&t), Some("my-text"));
200    }
201
202    #[test]
203    fn test_text_measure() {
204        let t = Text::new("Hello");
205        let size = t.measure(Constraints::loose(Size::new(1000.0, 1000.0)));
206        assert!(size.width > 0.0);
207        assert!(size.height > 0.0);
208    }
209
210    #[test]
211    fn test_text_empty() {
212        let t = Text::new("");
213        let size = t.measure(Constraints::loose(Size::new(1000.0, 1000.0)));
214        assert_eq!(size.width, 0.0);
215        assert!(size.height > 0.0); // Line height
216    }
217
218    // ===== Paint Tests =====
219
220    #[test]
221    fn test_text_paint_draws_text() {
222        let mut text = Text::new("Hello World");
223        text.layout(Rect::new(10.0, 20.0, 200.0, 30.0));
224
225        let mut canvas = RecordingCanvas::new();
226        text.paint(&mut canvas);
227
228        assert_eq!(canvas.command_count(), 1);
229        match &canvas.commands()[0] {
230            DrawCommand::Text {
231                content, position, ..
232            } => {
233                assert_eq!(content, "Hello World");
234                assert_eq!(*position, Point::new(10.0, 20.0));
235            }
236            _ => panic!("Expected Text command"),
237        }
238    }
239
240    #[test]
241    fn test_text_paint_uses_color() {
242        let mut text = Text::new("Colored").color(Color::RED);
243        text.layout(Rect::new(0.0, 0.0, 100.0, 20.0));
244
245        let mut canvas = RecordingCanvas::new();
246        text.paint(&mut canvas);
247
248        match &canvas.commands()[0] {
249            DrawCommand::Text { style, .. } => {
250                assert_eq!(style.color, Color::RED);
251            }
252            _ => panic!("Expected Text command"),
253        }
254    }
255
256    #[test]
257    fn test_text_paint_uses_font_size() {
258        let mut text = Text::new("Large").font_size(32.0);
259        text.layout(Rect::new(0.0, 0.0, 200.0, 40.0));
260
261        let mut canvas = RecordingCanvas::new();
262        text.paint(&mut canvas);
263
264        match &canvas.commands()[0] {
265            DrawCommand::Text { style, .. } => {
266                assert_eq!(style.size, 32.0);
267            }
268            _ => panic!("Expected Text command"),
269        }
270    }
271
272    #[test]
273    fn test_text_paint_uses_font_weight() {
274        let mut text = Text::new("Bold").font_weight(FontWeight::Bold);
275        text.layout(Rect::new(0.0, 0.0, 100.0, 20.0));
276
277        let mut canvas = RecordingCanvas::new();
278        text.paint(&mut canvas);
279
280        match &canvas.commands()[0] {
281            DrawCommand::Text { style, .. } => {
282                assert_eq!(style.weight, FontWeight::Bold);
283            }
284            _ => panic!("Expected Text command"),
285        }
286    }
287
288    #[test]
289    fn test_text_paint_uses_font_style() {
290        let mut text = Text::new("Italic").font_style(FontStyle::Italic);
291        text.layout(Rect::new(0.0, 0.0, 100.0, 20.0));
292
293        let mut canvas = RecordingCanvas::new();
294        text.paint(&mut canvas);
295
296        match &canvas.commands()[0] {
297            DrawCommand::Text { style, .. } => {
298                assert_eq!(style.style, FontStyle::Italic);
299            }
300            _ => panic!("Expected Text command"),
301        }
302    }
303
304    #[test]
305    fn test_text_paint_empty() {
306        let mut text = Text::new("");
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        // Should still draw (empty text)
313        assert_eq!(canvas.command_count(), 1);
314        match &canvas.commands()[0] {
315            DrawCommand::Text { content, .. } => {
316                assert!(content.is_empty());
317            }
318            _ => panic!("Expected Text command"),
319        }
320    }
321
322    #[test]
323    fn test_text_paint_position_from_layout() {
324        let mut text = Text::new("Positioned");
325        text.layout(Rect::new(50.0, 100.0, 200.0, 30.0));
326
327        let mut canvas = RecordingCanvas::new();
328        text.paint(&mut canvas);
329
330        match &canvas.commands()[0] {
331            DrawCommand::Text { position, .. } => {
332                assert_eq!(position.x, 50.0);
333                assert_eq!(position.y, 100.0);
334            }
335            _ => panic!("Expected Text command"),
336        }
337    }
338
339    // ===== Widget Trait Tests =====
340
341    #[test]
342    fn test_text_type_id() {
343        let t = Text::new("test");
344        assert_eq!(Widget::type_id(&t), TypeId::of::<Text>());
345    }
346
347    #[test]
348    fn test_text_layout_sets_bounds() {
349        let mut t = Text::new("test");
350        let result = t.layout(Rect::new(10.0, 20.0, 100.0, 30.0));
351        assert_eq!(result.size, Size::new(100.0, 30.0));
352        assert_eq!(t.bounds, Rect::new(10.0, 20.0, 100.0, 30.0));
353    }
354
355    #[test]
356    fn test_text_children_empty() {
357        let t = Text::new("test");
358        assert!(t.children().is_empty());
359    }
360
361    #[test]
362    fn test_text_event_returns_none() {
363        let mut t = Text::new("test");
364        t.layout(Rect::new(0.0, 0.0, 100.0, 20.0));
365        let result = t.event(&Event::MouseEnter);
366        assert!(result.is_none());
367    }
368
369    #[test]
370    fn test_text_line_height() {
371        let t = Text::new("test").line_height(1.5);
372        assert_eq!(t.line_height, 1.5);
373    }
374
375    #[test]
376    fn test_text_max_width() {
377        let t = Text::new("test").max_width(200.0);
378        assert_eq!(t.max_width, Some(200.0));
379    }
380
381    #[test]
382    fn test_text_measure_with_max_width() {
383        let t = Text::new("A very long text that should wrap").max_width(50.0);
384        let size = t.measure(Constraints::loose(Size::new(1000.0, 1000.0)));
385        assert!(size.width <= 50.0);
386        assert!(size.height > t.font_size); // Multiple lines
387    }
388
389    #[test]
390    fn test_text_content_accessor() {
391        let t = Text::new("Hello World");
392        assert_eq!(t.content(), "Hello World");
393    }
394}