Seregon/StratoSDK

StratoSDK is a framework with a declarative approach similar to Flutter/React, written and designed entirely for Rust.

Rust/27.3 KB/No license
crates/strato-widgets/src/text.rs
1//! Text widget implementation
2//!
3//! Provides text display components with various styles, formatting, and layout options.
4 
5use crate::widget::{generate_id, Widget, WidgetId};
6use std::{any::Any, sync::Arc, sync::OnceLock};
7use strato_core::{
8 event::{Event, EventResult},
9 layout::{Constraints, Layout, Size},
10 state::Signal,
11 theme::Theme,
12 types::{Color, Point, Rect},
13};
14use strato_renderer::{
15 batch::RenderBatch, gpu::texture_mgr::GlyphRasterizer, vertex::VertexBuilder,
16};
17 
18// Helper for text measurement
19fn get_rasterizer() -> &'static GlyphRasterizer {
20 static RASTERIZER: OnceLock<GlyphRasterizer> = OnceLock::new();
21 RASTERIZER.get_or_init(|| {
22 GlyphRasterizer::new().expect("Failed to create GlyphRasterizer for text measurement")
23 })
24}
25 
26fn measure_char_width(c: char, font_size: f32) -> f32 {
27 let rasterizer = get_rasterizer();
28 if c == ' ' {
29 // Match drawing.rs logic for space width: 0.3 * font_size
30 font_size * 0.3
31 } else {
32 rasterizer.font.metrics(c, font_size).advance_width
33 }
34}
35 
36/// Measure the width of a single line of text
37pub fn measure_text_width(text: &str, font_size: f32, letter_spacing: f32) -> f32 {
38 measure_line_width(text, font_size, letter_spacing)
39}
40 
41fn measure_line_width(line: &str, font_size: f32, letter_spacing: f32) -> f32 {
42 let mut width = 0.0;
43 for c in line.chars() {
44 width += measure_char_width(c, font_size);
45 }
46 if !line.is_empty() {
47 width += letter_spacing * (line.len() as f32);
48 }
49 width
50}
51 
52/// Text alignment options
53#[derive(Debug, Clone, Copy, PartialEq)]
54pub enum TextAlign {
55 Left,
56 Center,
57 Right,
58 Justify,
59}
60 
61/// Text vertical alignment options
62#[derive(Debug, Clone, Copy, PartialEq)]
63pub enum VerticalAlign {
64 Top,
65 Middle,
66 Bottom,
67 Baseline,
68}
69 
70/// Text overflow behavior
71#[derive(Debug, Clone, Copy, PartialEq)]
72pub enum TextOverflow {
73 Clip,
74 Ellipsis,
75 Wrap,
76 Scroll,
77}
78 
79/// Text decoration options
80#[derive(Debug, Clone, Copy, PartialEq)]
81pub enum TextDecoration {
82 None,
83 Underline,
84 Overline,
85 LineThrough,
86}
87 
88/// Font weight options
89#[derive(Debug, Clone, Copy, PartialEq)]
90pub enum FontWeight {
91 Thin,
92 ExtraLight,
93 Light,
94 Normal,
95 Medium,
96 SemiBold,
97 Bold,
98 ExtraBold,
99 Black,
100}
101 
102impl FontWeight {
103 pub fn to_numeric(&self) -> u16 {
104 match self {
105 FontWeight::Thin => 100,
106 FontWeight::ExtraLight => 200,
107 FontWeight::Light => 300,
108 FontWeight::Normal => 400,
109 FontWeight::Medium => 500,
110 FontWeight::SemiBold => 600,
111 FontWeight::Bold => 700,
112 FontWeight::ExtraBold => 800,
113 FontWeight::Black => 900,
114 }
115 }
116}
117 
118/// Font style options
119#[derive(Debug, Clone, Copy, PartialEq)]
120pub enum FontStyle {
121 Normal,
122 Italic,
123 Oblique,
124}
125 
126/// Text style configuration
127#[derive(Debug, Clone)]
128pub struct TextStyle {
129 pub font_family: String,
130 pub font_size: f32,
131 pub font_weight: FontWeight,
132 pub font_style: FontStyle,
133 pub color: Color,
134 pub line_height: f32,
135 pub letter_spacing: f32,
136 pub word_spacing: f32,
137 pub text_align: TextAlign,
138 pub vertical_align: VerticalAlign,
139 pub text_decoration: TextDecoration,
140 pub decoration_color: Color,
141 pub text_overflow: TextOverflow,
142 pub max_lines: Option<usize>,
143 pub selectable: bool,
144}
145 
146impl Default for TextStyle {
147 fn default() -> Self {
148 // Use platform-specific default fonts instead of generic "system-ui"
149 #[cfg(target_os = "windows")]
150 let default_family = "Segoe UI";
151 
152 #[cfg(target_os = "macos")]
153 let default_family = "SF Pro Display";
154 
155 #[cfg(target_os = "linux")]
156 let default_family = "Ubuntu";
157 
158 #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
159 let default_family = "Arial";
160 
161 Self {
162 font_family: default_family.to_string(),
163 font_size: 14.0,
164 font_weight: FontWeight::Normal,
165 font_style: FontStyle::Normal,
166 color: Color::rgba(0.0, 0.0, 0.0, 1.0), // Black text for better readability
167 line_height: 1.4,
168 letter_spacing: 0.0,
169 word_spacing: 0.0,
170 text_align: TextAlign::Left,
171 vertical_align: VerticalAlign::Top,
172 text_decoration: TextDecoration::None,
173 decoration_color: Color::rgba(0.0, 0.0, 0.0, 1.0), // Black decoration
174 text_overflow: TextOverflow::Clip,
175 max_lines: None,
176 selectable: false,
177 }
178 }
179}
180 
181impl TextStyle {
182 /// Create a heading style
183 pub fn heading(level: u8) -> Self {
184 let font_size = match level {
185 1 => 32.0,
186 2 => 24.0,
187 3 => 20.0,
188 4 => 18.0,
189 5 => 16.0,
190 6 => 14.0,
191 _ => 14.0,
192 };
193 
194 Self {
195 font_size,
196 font_weight: FontWeight::Bold,
197 line_height: 1.2,
198 ..Default::default()
199 }
200 }
201 
202 /// Create a body text style
203 pub fn body() -> Self {
204 Self {
205 font_size: 14.0,
206 line_height: 1.5,
207 ..Default::default()
208 }
209 }
210 
211 /// Create a caption style
212 pub fn caption() -> Self {
213 Self {
214 font_size: 12.0,
215 color: Color::rgba(0.5, 0.5, 0.5, 1.0),
216 line_height: 1.3,
217 ..Default::default()
218 }
219 }
220 
221 /// Create a code style
222 pub fn code() -> Self {
223 Self {
224 font_family: "monospace".to_string(),
225 font_size: 13.0,
226 color: Color::rgba(0.0, 1.0, 1.0, 1.0), // Bright cyan for code
227 letter_spacing: 0.5,
228 ..Default::default()
229 }
230 }
231 
232 /// Create a link style
233 pub fn link() -> Self {
234 Self {
235 color: Color::rgba(1.0, 0.0, 1.0, 1.0), // Bright magenta for links
236 text_decoration: TextDecoration::Underline,
237 decoration_color: Color::rgba(0.0, 0.4, 0.8, 1.0),
238 ..Default::default()
239 }
240 }
241}
242 
243/// Text span for rich text formatting
244#[derive(Debug, Clone)]
245pub struct TextSpan {
246 pub text: String,
247 pub style: Option<TextStyle>,
248 pub start: usize,
249 pub end: usize,
250}
251 
252impl TextSpan {
253 pub fn new(text: impl Into<String>) -> Self {
254 let text = text.into();
255 let len = text.len();
256 Self {
257 text,
258 style: None,
259 start: 0,
260 end: len,
261 }
262 }
263 
264 pub fn with_style(mut self, style: TextStyle) -> Self {
265 self.style = Some(style);
266 self
267 }
268 
269 pub fn with_range(mut self, start: usize, end: usize) -> Self {
270 self.start = start;
271 self.end = end;
272 self
273 }
274}
275 
276/// Text widget
277#[derive(Debug)]
278pub struct Text {
279 id: WidgetId,
280 content: Signal<String>,
281 spans: Vec<TextSpan>,
282 style: TextStyle,
283 bounds: Signal<Rect>,
284 visible: Signal<bool>,
285 selectable: Signal<bool>,
286 selection_start: Signal<Option<usize>>,
287 selection_end: Signal<Option<usize>>,
288 theme: Option<Arc<Theme>>,
289 measured_size: Signal<Size>,
290 cached_lines: Signal<Vec<String>>,
291}
292 
293impl Text {
294 /// Create a new text widget
295 pub fn new(content: impl Into<String>) -> Self {
296 Self {
297 id: generate_id(),
298 content: Signal::new(content.into()),
299 spans: Vec::new(),
300 style: TextStyle::default(),
301 bounds: Signal::new(Rect::new(0.0, 0.0, 0.0, 0.0)),
302 visible: Signal::new(true),
303 selectable: Signal::new(false),
304 selection_start: Signal::new(None),
305 selection_end: Signal::new(None),
306 theme: None,
307 measured_size: Signal::new(Size::new(0.0, 0.0)),
308 cached_lines: Signal::new(Vec::new()),
309 }
310 }
311 
312 /// Bind text content to a signal
313 pub fn bind(mut self, signal: Signal<String>) -> Self {
314 self.content = signal;
315 self
316 }
317 
318 /// Set text style
319 pub fn style(mut self, style: TextStyle) -> Self {
320 self.style = style;
321 self
322 }
323 
324 /// Set as heading
325 pub fn heading(mut self, level: u8) -> Self {
326 self.style = TextStyle::heading(level);
327 self
328 }
329 
330 /// Set as body text
331 pub fn body(mut self) -> Self {
332 self.style = TextStyle::body();
333 self
334 }
335 
336 /// Set as caption
337 pub fn caption(mut self) -> Self {
338 self.style = TextStyle::caption();
339 self
340 }
341 
342 /// Set as code
343 pub fn code(mut self) -> Self {
344 self.style = TextStyle::code();
345 self
346 }
347 
348 /// Set as link
349 pub fn link(mut self) -> Self {
350 self.style = TextStyle::link();
351 self
352 }
353 
354 /// Set text color
355 pub fn color(mut self, color: Color) -> Self {
356 self.style.color = color;
357 self
358 }
359 
360 /// Set font size
361 pub fn font_size(mut self, size: f32) -> Self {
362 self.style.font_size = size;
363 self
364 }
365 
366 /// Set font weight
367 pub fn font_weight(mut self, weight: FontWeight) -> Self {
368 self.style.font_weight = weight;
369 self
370 }
371 
372 /// Set text alignment
373 pub fn align(mut self, align: TextAlign) -> Self {
374 self.style.text_align = align;
375 self
376 }
377 
378 /// Set text overflow behavior
379 pub fn overflow(mut self, overflow: TextOverflow) -> Self {
380 self.style.text_overflow = overflow;
381 self
382 }
383 
384 /// Set maximum lines
385 pub fn max_lines(mut self, lines: usize) -> Self {
386 self.style.max_lines = Some(lines);
387 self
388 }
389 
390 /// Set selectable
391 pub fn selectable(self, selectable: bool) -> Self {
392 self.selectable.set(selectable);
393 self
394 }
395 
396 /// Set visible state
397 pub fn visible(self, visible: bool) -> Self {
398 self.visible.set(visible);
399 self
400 }
401 
402 /// Set theme
403 pub fn theme(mut self, theme: Arc<Theme>) -> Self {
404 self.theme = Some(theme);
405 self
406 }
407 
408 /// Set text size (font size)
409 pub fn size(mut self, size: f32) -> Self {
410 self.style.font_size = size;
411 self
412 }
413 
414 /// Add a text span for rich formatting
415 pub fn add_span(mut self, span: TextSpan) -> Self {
416 self.spans.push(span);
417 self
418 }
419 
420 /// Get text content
421 pub fn content(&self) -> String {
422 self.content.get()
423 }
424 
425 /// Set text content
426 pub fn set_content(&mut self, content: impl Into<String>) {
427 self.content.set(content.into());
428 self.invalidate_layout();
429 }
430 
431 /// Check if text is visible
432 pub fn is_visible(&self) -> bool {
433 self.visible.get()
434 }
435 
436 /// Check if text is selectable
437 pub fn is_selectable(&self) -> bool {
438 self.selectable.get()
439 }
440 
441 /// Get current selection
442 pub fn get_selection(&self) -> Option<(usize, usize)> {
443 match (self.selection_start.get(), self.selection_end.get()) {
444 (Some(start), Some(end)) => Some((start.min(end), start.max(end))),
445 _ => None,
446 }
447 }
448 
449 /// Set text selection
450 pub fn set_selection(&self, start: Option<usize>, end: Option<usize>) {
451 self.selection_start.set(start);
452 self.selection_end.set(end);
453 }
454 
455 /// Clear selection
456 pub fn clear_selection(&self) {
457 self.selection_start.set(None);
458 self.selection_end.set(None);
459 }
460 
461 /// Invalidate layout (force remeasurement)
462 fn invalidate_layout(&self) {
463 self.measured_size.set(Size::new(0.0, 0.0));
464 self.cached_lines.set(Vec::new());
465 }
466 
467 /// Measure text size
468 pub fn measure_text(&self, available_width: f32) -> Size {
469 // Accurate text measurement
470 let line_height = self.style.font_size * self.style.line_height;
471 
472 let mut lines = Vec::new();
473 let mut current_line = String::new();
474 let mut current_line_width = 0.0;
475 
476 let content = self.content.get();
477 let words: Vec<&str> = content.split_whitespace().collect();
478 let space_width = measure_char_width(' ', self.style.font_size) + self.style.letter_spacing;
479 
480 for (i, word) in words.iter().enumerate() {
481 let word_width =
482 measure_line_width(word, self.style.font_size, self.style.letter_spacing);
483 
484 // Check if adding this word would exceed available width
485 // (Only if we already have content on this line)
486 if !current_line.is_empty()
487 && current_line_width + space_width + word_width > available_width
488 {
489 // Push current line and start new one
490 lines.push(current_line);
491 current_line = String::from(*word);
492 current_line_width = word_width;
493 } else {
494 if !current_line.is_empty() {
495 current_line.push(' ');
496 current_line_width += space_width;
497 }
498 current_line.push_str(word);
499 current_line_width += word_width;
500 }
501 }
502 
503 if !current_line.is_empty() {
504 lines.push(current_line);
505 }
506 
507 // If no content but we have text, treat as one line (e.g. single word too long or empty)
508 if lines.is_empty() && !content.is_empty() {
509 lines.push(content.clone());
510 }
511 
512 // Apply max_lines constraint
513 if let Some(max_lines) = self.style.max_lines {
514 if lines.len() > max_lines {
515 lines.truncate(max_lines);
516 }
517 }
518 
519 let width = if lines.is_empty() {
520 0.0
521 } else {
522 // Calculate max width of lines
523 lines
524 .iter()
525 .map(|line| {
526 measure_line_width(line, self.style.font_size, self.style.letter_spacing)
527 })
528 .fold(0.0, f32::max)
529 .min(available_width)
530 };
531 
532 let height = lines.len() as f32 * line_height;
533 
534 let size = Size::new(width, height);
535 self.measured_size.set(size);
536 self.cached_lines.set(lines);
537 
538 size
539 }
540 
541 /// Calculate text size
542 pub fn calculate_size(&self, available_size: Size) -> Size {
543 let measured = self.measured_size.get();
544 if measured.width > 0.0 && measured.height > 0.0 {
545 return Size::new(
546 measured.width.min(available_size.width),
547 measured.height.min(available_size.height),
548 );
549 }
550 
551 self.measure_text(available_size.width)
552 }
553 
554 /// Layout the text
555 pub fn layout(&self, bounds: Rect) {
556 self.bounds.set(bounds);
557 self.measure_text(bounds.width);
558 }
559 
560 /// Handle mouse press for text selection
561 pub fn on_mouse_press(&self, point: Point) -> bool {
562 if !self.is_selectable() || !self.is_visible() {
563 return false;
564 }
565 
566 let bounds = self.bounds.get();
567 if bounds.contains(point) {
568 // Calculate character position (simplified)
569 let relative_x = point.x - bounds.x;
570 let relative_y = point.y - bounds.y;
571 
572 let char_width = self.style.font_size * 0.6;
573 let line_height = self.style.font_size * self.style.line_height;
574 
575 let line = (relative_y / line_height) as usize;
576 let char_in_line = (relative_x / char_width) as usize;
577 
578 // Simple character position calculation
579 let position = char_in_line.min(self.content.get().len());
580 
581 self.set_selection(Some(position), Some(position));
582 return true;
583 }
584 false
585 }
586 
587 /// Handle mouse drag for text selection
588 pub fn on_mouse_drag(&self, point: Point) -> bool {
589 if !self.is_selectable() || !self.is_visible() {
590 return false;
591 }
592 
593 if let Some(start) = self.selection_start.get() {
594 let bounds = self.bounds.get();
595 if bounds.contains(point) {
596 let relative_x = point.x - bounds.x;
597 let char_width = self.style.font_size * 0.6;
598 let position = (relative_x / char_width) as usize;
599 
600 self.selection_end
601 .set(Some(position.min(self.content.get().len())));
602 return true;
603 }
604 }
605 false
606 }
607 
608 /// Render the text
609 pub fn render(&self, batch: &mut RenderBatch) {
610 if !self.is_visible() {
611 return;
612 }
613 
614 let bounds = self.bounds.get();
615 
616 // Apply clipping if needed
617 let should_clip = matches!(
618 self.style.text_overflow,
619 TextOverflow::Clip | TextOverflow::Scroll
620 );
621 if should_clip {
622 batch.push_clip(bounds);
623 }
624 
625 // Render selection background if any
626 if let Some((start, end)) = self.get_selection() {
627 if start != end {
628 let selection_color = Color::rgba(0.0, 0.4, 0.8, 0.3);
629 
630 // Simple selection rendering (would need proper text metrics)
631 let char_width = self.style.font_size * 0.6;
632 let selection_x = bounds.x + start as f32 * char_width;
633 let selection_width = (end - start) as f32 * char_width;
634 
635 let (vertices, indices) = VertexBuilder::rectangle(
636 selection_x,
637 bounds.y,
638 selection_width,
639 bounds.height,
640 selection_color.to_array(),
641 );
642 batch.add_vertices(&vertices, &indices);
643 }
644 }
645 
646 let lines = self.cached_lines.get();
647 let line_height = self.style.font_size * self.style.line_height;
648 
649 for (i, line) in lines.iter().enumerate() {
650 let line_width =
651 measure_line_width(line, self.style.font_size, self.style.letter_spacing);
652 
653 // Calculate text position based on alignment
654 let text_x = match self.style.text_align {
655 TextAlign::Left => bounds.x,
656 TextAlign::Center => bounds.x + (bounds.width - line_width) / 2.0,
657 TextAlign::Right => bounds.x + bounds.width - line_width,
658 TextAlign::Justify => bounds.x, // Simplified
659 };
660 
661 let text_y = match self.style.vertical_align {
662 VerticalAlign::Top => bounds.y + (i as f32 * line_height),
663 VerticalAlign::Middle => {
664 bounds.y
665 + (bounds.height - (lines.len() as f32 * line_height)) / 2.0
666 + (i as f32 * line_height)
667 }
668 VerticalAlign::Bottom => {
669 bounds.y + bounds.height - (lines.len() as f32 * line_height)
670 + (i as f32 * line_height)
671 }
672 VerticalAlign::Baseline => {
673 bounds.y + (i as f32 * line_height) + self.style.font_size * 0.8
674 }
675 };
676 
677 // Render line
678 batch.add_text(
679 line.clone(),
680 (text_x, text_y),
681 self.style.color,
682 self.style.font_size,
683 self.style.letter_spacing,
684 );
685 
686 // Render text decoration if any
687 if self.style.text_decoration != TextDecoration::None {
688 let decoration_y = match self.style.text_decoration {
689 TextDecoration::Underline => text_y + self.style.font_size + 2.0,
690 TextDecoration::Overline => text_y - 2.0,
691 TextDecoration::LineThrough => text_y + self.style.font_size / 2.0,
692 TextDecoration::None => text_y,
693 };
694 
695 let (vertices, indices) = VertexBuilder::line(
696 text_x,
697 decoration_y,
698 text_x + line_width,
699 decoration_y,
700 1.0,
701 self.style.decoration_color.to_array(),
702 );
703 batch.add_vertices(&vertices, &indices);
704 }
705 }
706 
707 if should_clip {
708 batch.pop_clip();
709 }
710 
711 // TODO: Re-implement TextSpan support for multi-line text
712 // This requires mapping lines back to original string indices
713 /*
714 // Render spans if any (rich text)
715 for span in &self.spans {
716 if let Some(span_style) = &span.style {
717 let span_text = &span.text[span.start..span.end.min(span.text.len())];
718 let span_x = text_x + span.start as f32 * self.style.font_size * 0.6;
719 
720 batch.add_text(
721 span_text.to_string(),
722 (span_x, text_y),
723 span_style.color,
724 span_style.font_size,
725 span_style.letter_spacing,
726 );
727 }
728 }
729 */
730 }
731 
732 /// Apply theme to text
733 pub fn apply_theme(&mut self, theme: &Theme) {
734 self.style.font_family = theme.typography.font_family.clone();
735 self.style.font_size = theme.typography.base_size;
736 self.style.color = theme.colors.on_surface.to_types_color();
737 }
738}
739 
740/// Text builder for fluent API
741pub struct TextBuilder {
742 text: Text,
743}
744 
745impl TextBuilder {
746 /// Create a new text builder
747 pub fn new(content: impl Into<String>) -> Self {
748 Self {
749 text: Text::new(content),
750 }
751 }
752 
753 /// Set style
754 pub fn style(mut self, style: TextStyle) -> Self {
755 self.text = self.text.style(style);
756 self
757 }
758 
759 /// Set as heading
760 pub fn heading(mut self, level: u8) -> Self {
761 self.text = self.text.heading(level);
762 self
763 }
764 
765 /// Set as body text
766 pub fn body(mut self) -> Self {
767 self.text = self.text.body();
768 self
769 }
770 
771 /// Set as caption
772 pub fn caption(mut self) -> Self {
773 self.text = self.text.caption();
774 self
775 }
776 
777 /// Set as code
778 pub fn code(mut self) -> Self {
779 self.text = self.text.code();
780 self
781 }
782 
783 /// Set as link
784 pub fn link(mut self) -> Self {
785 self.text = self.text.link();
786 self
787 }
788 
789 /// Set text color
790 pub fn color(mut self, color: Color) -> Self {
791 self.text = self.text.color(color);
792 self
793 }
794 
795 /// Set font size
796 pub fn font_size(mut self, size: f32) -> Self {
797 self.text = self.text.font_size(size);
798 self
799 }
800 
801 /// Set font weight
802 pub fn font_weight(mut self, weight: FontWeight) -> Self {
803 self.text = self.text.font_weight(weight);
804 self
805 }
806 
807 /// Set text alignment
808 pub fn align(mut self, align: TextAlign) -> Self {
809 self.text = self.text.align(align);
810 self
811 }
812 
813 /// Set text overflow behavior
814 pub fn overflow(mut self, overflow: TextOverflow) -> Self {
815 self.text = self.text.overflow(overflow);
816 self
817 }
818 
819 /// Set maximum lines
820 pub fn max_lines(mut self, lines: usize) -> Self {
821 self.text = self.text.max_lines(lines);
822 self
823 }
824 
825 /// Set selectable
826 pub fn selectable(mut self, selectable: bool) -> Self {
827 self.text = self.text.selectable(selectable);
828 self
829 }
830 
831 /// Set visible state
832 pub fn visible(mut self, visible: bool) -> Self {
833 self.text = self.text.visible(visible);
834 self
835 }
836 
837 /// Set theme
838 pub fn theme(mut self, theme: Arc<Theme>) -> Self {
839 self.text = self.text.theme(theme);
840 self
841 }
842 
843 /// Set text size (font size)
844 pub fn size(mut self, size: f32) -> Self {
845 self.text = self.text.size(size);
846 self
847 }
848 
849 /// Add a text span for rich formatting
850 pub fn add_span(mut self, span: TextSpan) -> Self {
851 self.text = self.text.add_span(span);
852 self
853 }
854 
855 /// Build the text widget
856 pub fn build(self) -> Text {
857 self.text
858 }
859}
860 
861#[cfg(test)]
862mod tests {
863 use super::*;
864 
865 #[test]
866 fn test_text_creation() {
867 let text = Text::new("Hello, World!");
868 assert_eq!(text.content(), "Hello, World!");
869 assert!(text.is_visible());
870 assert!(!text.is_selectable());
871 }
872 
873 #[test]
874 fn test_text_styles() {
875 let heading = Text::new("Heading").heading(1);
876 let body = Text::new("Body").body();
877 let caption = Text::new("Caption").caption();
878 
879 assert!(heading.style.font_size > body.style.font_size);
880 assert!(body.style.font_size > caption.style.font_size);
881 }
882 
883 #[test]
884 fn test_text_selection() {
885 let text = Text::new("Test selection").selectable(true);
886 
887 assert!(text.is_selectable());
888 assert_eq!(text.get_selection(), None);
889 
890 text.set_selection(Some(0), Some(4));
891 assert_eq!(text.get_selection(), Some((0, 4)));
892 
893 text.clear_selection();
894 assert_eq!(text.get_selection(), None);
895 }
896 
897 #[test]
898 fn test_text_builder() {
899 let text = TextBuilder::new("Builder Test")
900 .heading(2)
901 .color(Color::rgba(1.0, 0.0, 0.0, 1.0))
902 .selectable(true)
903 .build();
904 
905 assert_eq!(text.content(), "Builder Test");
906 assert!(text.is_selectable());
907 assert_eq!(text.style.color, Color::rgba(1.0, 0.0, 0.0, 1.0));
908 }
909 
910 #[test]
911 fn test_text_measurement() {
912 let text = Text::new("Test measurement");
913 let available = Size::new(200.0, 100.0);
914 let size = text.calculate_size(available);
915 
916 assert!(size.width > 0.0);
917 assert!(size.height > 0.0);
918 assert!(size.width <= available.width);
919 assert!(size.height <= available.height);
920 }
921}
922 
923// Implement Widget trait for Text
924impl Widget for Text {
925 fn id(&self) -> WidgetId {
926 self.id
927 }
928 
929 fn layout(&mut self, constraints: Constraints) -> Size {
930 self.measure_text(constraints.max_width)
931 }
932 
933 fn render(&self, batch: &mut RenderBatch, layout: Layout) {
934 // Update bounds based on layout
935 self.bounds.set(Rect::new(
936 layout.position.x,
937 layout.position.y,
938 layout.size.width,
939 layout.size.height,
940 ));
941 
942 // Ensure text is measured/wrapped for these bounds
943 // Note: layout() should have been called before render(), but we need to ensure
944 // cached_lines are up to date for the current width.
945 // Since render() is const, we rely on layout() having populated cached_lines.
946 // If layout wasn't called or width changed, we might render stale lines.
947 // Ideally measure_text should be called here if needed, but we can't mutate.
948 
949 self.render(batch);
950 }
951 
952 fn handle_event(&mut self, event: &Event) -> EventResult {
953 match event {
954 Event::MouseDown(mouse_event) => {
955 if mouse_event.button == Some(strato_core::event::MouseButton::Left) {
956 if self.on_mouse_press(mouse_event.position.into()) {
957 return EventResult::Handled;
958 }
959 }
960 }
961 Event::MouseMove(mouse_event) => {
962 if self.on_mouse_drag(mouse_event.position.into()) {
963 return EventResult::Handled;
964 }
965 }
966 _ => {}
967 }
968 EventResult::Ignored
969 }
970 
971 fn as_any(&self) -> &dyn Any {
972 self
973 }
974 
975 fn as_any_mut(&mut self) -> &mut dyn Any {
976 self
977 }
978 
979 fn clone_widget(&self) -> Box<dyn Widget> {
980 Box::new(Text {
981 id: generate_id(),
982 content: self.content.clone(),
983 spans: self.spans.clone(),
984 style: self.style.clone(),
985 bounds: Signal::new(self.bounds.get()),
986 visible: Signal::new(self.visible.get()),
987 selectable: Signal::new(self.selectable.get()),
988 selection_start: Signal::new(self.selection_start.get()),
989 selection_end: Signal::new(self.selection_end.get()),
990 theme: self.theme.clone(),
991 measured_size: Signal::new(self.measured_size.get()),
992 cached_lines: Signal::new(self.cached_lines.get()),
993 })
994 }
995}
996