Skip to main content

astrelis_text/
rich_text.rs

1//! Rich text formatting with styled spans.
2//!
3//! Provides support for mixing multiple styles within a single text block.
4//!
5//! # Example
6//!
7//! ```ignore
8//! use astrelis_text::*;
9//!
10//! // Create rich text with spans
11//! let mut rich = RichText::new();
12//! rich.push("This is ", TextSpanStyle::default());
13//! rich.push_bold("bold");
14//! rich.push(" and ");
15//! rich.push_italic("italic");
16//! rich.push(" text.");
17//!
18//! // Or use the builder pattern
19//! let rich = RichTextBuilder::new()
20//!     .text("This is ")
21//!     .bold("bold")
22//!     .text(" and ")
23//!     .italic("italic")
24//!     .text(" text.")
25//!     .build();
26//!
27//! // Parse from markup
28//! let rich = RichText::from_markup("This is **bold** and *italic* text.");
29//! ```
30
31use crate::font::{FontAttributes, FontStyle, FontWeight};
32use crate::text::{LineBreakConfig, Text, TextAlign, TextWrap, VerticalAlign};
33use astrelis_render::Color;
34
35/// A span of text with specific styling.
36#[derive(Debug, Clone)]
37pub struct TextSpan {
38    /// The text content
39    pub text: String,
40    /// The style for this span
41    pub style: TextSpanStyle,
42}
43
44impl TextSpan {
45    /// Create a new text span.
46    pub fn new(text: impl Into<String>, style: TextSpanStyle) -> Self {
47        Self {
48            text: text.into(),
49            style,
50        }
51    }
52}
53
54/// Style attributes for a text span.
55#[derive(Debug, Clone, Default)]
56pub struct TextSpanStyle {
57    /// Font size (None = inherit from parent)
58    pub font_size: Option<f32>,
59    /// Text color (None = inherit from parent)
60    pub color: Option<Color>,
61    /// Font weight (None = inherit from parent)
62    pub weight: Option<FontWeight>,
63    /// Font style (None = inherit from parent)
64    pub style: Option<FontStyle>,
65    /// Font family (None = inherit from parent)
66    pub font_family: Option<String>,
67    /// Underline flag
68    pub underline: bool,
69    /// Strikethrough flag
70    pub strikethrough: bool,
71    /// Background color (None = no background)
72    pub background: Option<Color>,
73    /// Scale factor relative to parent font size (1.0 = same size, 0.5 = half size, 2.0 = double)
74    pub scale: Option<f32>,
75}
76
77impl TextSpanStyle {
78    /// Create a new default text span style.
79    pub fn new() -> Self {
80        Self::default()
81    }
82
83    /// Set font size.
84    pub fn with_size(mut self, size: f32) -> Self {
85        self.font_size = Some(size);
86        self
87    }
88
89    /// Set color.
90    pub fn with_color(mut self, color: Color) -> Self {
91        self.color = Some(color);
92        self
93    }
94
95    /// Set font weight.
96    pub fn with_weight(mut self, weight: FontWeight) -> Self {
97        self.weight = Some(weight);
98        self
99    }
100
101    /// Make bold.
102    pub fn bold(mut self) -> Self {
103        self.weight = Some(FontWeight::Bold);
104        self
105    }
106
107    /// Set font style.
108    pub fn with_style(mut self, style: FontStyle) -> Self {
109        self.style = Some(style);
110        self
111    }
112
113    /// Make italic.
114    pub fn italic(mut self) -> Self {
115        self.style = Some(FontStyle::Italic);
116        self
117    }
118
119    /// Set font family.
120    pub fn with_family(mut self, family: impl Into<String>) -> Self {
121        self.font_family = Some(family.into());
122        self
123    }
124
125    /// Set underline.
126    pub fn with_underline(mut self, underline: bool) -> Self {
127        self.underline = underline;
128        self
129    }
130
131    /// Set strikethrough.
132    pub fn with_strikethrough(mut self, strikethrough: bool) -> Self {
133        self.strikethrough = strikethrough;
134        self
135    }
136
137    /// Set background color.
138    pub fn with_background(mut self, color: Color) -> Self {
139        self.background = Some(color);
140        self
141    }
142
143    /// Set scale factor.
144    pub fn with_scale(mut self, scale: f32) -> Self {
145        self.scale = Some(scale);
146        self
147    }
148}
149
150/// Rich text with multiple styled spans.
151#[derive(Debug, Clone)]
152pub struct RichText {
153    /// The text spans
154    spans: Vec<TextSpan>,
155    /// Default font size for unspecified spans
156    default_font_size: f32,
157    /// Default color for unspecified spans
158    default_color: Color,
159    /// Default font attributes
160    default_font_attrs: FontAttributes,
161    /// Text alignment
162    align: TextAlign,
163    /// Vertical alignment
164    vertical_align: VerticalAlign,
165    /// Text wrapping mode
166    wrap: TextWrap,
167    /// Whether to allow breaks at hyphens
168    break_at_hyphens: bool,
169    /// Maximum width
170    max_width: Option<f32>,
171    /// Maximum height
172    max_height: Option<f32>,
173    /// Line height multiplier
174    line_height: f32,
175}
176
177impl RichText {
178    /// Create a new rich text instance.
179    pub fn new() -> Self {
180        Self {
181            spans: Vec::new(),
182            default_font_size: 16.0,
183            default_color: Color::WHITE,
184            default_font_attrs: FontAttributes::default(),
185            align: TextAlign::Left,
186            vertical_align: VerticalAlign::Top,
187            wrap: TextWrap::Word,
188            break_at_hyphens: true,
189            max_width: None,
190            max_height: None,
191            line_height: 1.2,
192        }
193    }
194
195    /// Add a text span.
196    pub fn push(&mut self, text: impl Into<String>, style: TextSpanStyle) {
197        self.spans.push(TextSpan::new(text, style));
198    }
199
200    /// Add plain text with default styling.
201    pub fn push_str(&mut self, text: impl Into<String>) {
202        self.spans
203            .push(TextSpan::new(text, TextSpanStyle::default()));
204    }
205
206    /// Add bold text.
207    pub fn push_bold(&mut self, text: impl Into<String>) {
208        self.spans
209            .push(TextSpan::new(text, TextSpanStyle::default().bold()));
210    }
211
212    /// Add italic text.
213    pub fn push_italic(&mut self, text: impl Into<String>) {
214        self.spans
215            .push(TextSpan::new(text, TextSpanStyle::default().italic()));
216    }
217
218    /// Add colored text.
219    pub fn push_colored(&mut self, text: impl Into<String>, color: Color) {
220        self.spans.push(TextSpan::new(
221            text,
222            TextSpanStyle::default().with_color(color),
223        ));
224    }
225
226    /// Add a span.
227    pub fn push_span(&mut self, span: TextSpan) {
228        self.spans.push(span);
229    }
230
231    /// Get all spans.
232    pub fn spans(&self) -> &[TextSpan] {
233        &self.spans
234    }
235
236    /// Set default font size.
237    pub fn set_default_font_size(&mut self, size: f32) {
238        self.default_font_size = size;
239    }
240
241    /// Set default color.
242    pub fn set_default_color(&mut self, color: Color) {
243        self.default_color = color;
244    }
245
246    /// Set default font attributes.
247    pub fn set_default_font_attrs(&mut self, attrs: FontAttributes) {
248        self.default_font_attrs = attrs;
249    }
250
251    /// Set text alignment.
252    pub fn set_align(&mut self, align: TextAlign) {
253        self.align = align;
254    }
255
256    /// Set vertical alignment.
257    pub fn set_vertical_align(&mut self, align: VerticalAlign) {
258        self.vertical_align = align;
259    }
260
261    /// Set text wrapping.
262    pub fn set_wrap(&mut self, wrap: TextWrap) {
263        self.wrap = wrap;
264    }
265
266    /// Set line breaking configuration.
267    ///
268    /// This provides more control than `set_wrap()` alone, allowing configuration
269    /// of hyphen breaks and future UAX#14 options.
270    pub fn set_line_break(&mut self, config: LineBreakConfig) {
271        self.wrap = config.wrap;
272        self.break_at_hyphens = config.break_at_hyphens;
273    }
274
275    /// Set maximum width.
276    pub fn set_max_width(&mut self, width: Option<f32>) {
277        self.max_width = width;
278    }
279
280    /// Set maximum height.
281    pub fn set_max_height(&mut self, height: Option<f32>) {
282        self.max_height = height;
283    }
284
285    /// Set line height multiplier.
286    pub fn set_line_height(&mut self, height: f32) {
287        self.line_height = height;
288    }
289
290    /// Get the full text content (concatenated spans).
291    pub fn full_text(&self) -> String {
292        self.spans.iter().map(|s| s.text.as_str()).collect()
293    }
294
295    /// Convert to a series of Text objects (one per span).
296    ///
297    /// This is used for rendering - each span becomes a separate Text that can be rendered.
298    /// Note: This is a simplified conversion. For true rich text rendering, you'd want to
299    /// integrate with cosmic-text's attributed string support.
300    pub fn to_text_segments(&self) -> Vec<(Text, TextSpanStyle)> {
301        let mut segments = Vec::new();
302
303        for span in &self.spans {
304            let mut text = Text::new(&span.text)
305                .size(
306                    span.style
307                        .font_size
308                        .or(span.style.scale.map(|s| self.default_font_size * s))
309                        .unwrap_or(self.default_font_size),
310                )
311                .color(span.style.color.unwrap_or(self.default_color))
312                .align(self.align)
313                .vertical_align(self.vertical_align)
314                .wrap(self.wrap)
315                .line_height(self.line_height);
316
317            if let Some(weight) = span.style.weight {
318                text = text.weight(weight);
319            } else {
320                text = text.weight(self.default_font_attrs.weight);
321            }
322
323            if let Some(style) = span.style.style {
324                text = text.style(style);
325            } else {
326                text = text.style(self.default_font_attrs.style);
327            }
328
329            if let Some(ref family) = span.style.font_family {
330                text = text.font(family.clone());
331            } else if !self.default_font_attrs.family.is_empty() {
332                text = text.font(self.default_font_attrs.family.clone());
333            }
334
335            if let Some(width) = self.max_width {
336                text = text.max_width(width);
337            }
338
339            if let Some(height) = self.max_height {
340                text = text.max_height(height);
341            }
342
343            segments.push((text, span.style.clone()));
344        }
345
346        segments
347    }
348
349    /// Parse markdown-like markup into rich text.
350    ///
351    /// Supported syntax:
352    /// - `**bold**` for bold text
353    /// - `*italic*` for italic text
354    /// - `__underline__` for underlined text
355    /// - `~~strikethrough~~` for strikethrough text
356    ///
357    /// # Example
358    ///
359    /// ```ignore
360    /// let rich = RichText::from_markup("This is **bold** and *italic* text.");
361    /// ```
362    pub fn from_markup(markup: &str) -> Self {
363        let mut rich = RichText::new();
364        let mut current = String::new();
365        let mut chars = markup.chars().peekable();
366
367        while let Some(ch) = chars.next() {
368            match ch {
369                '*' => {
370                    if chars.peek() == Some(&'*') {
371                        // Bold: **text**
372                        chars.next(); // consume second *
373
374                        if !current.is_empty() {
375                            rich.push_str(current.clone());
376                            current.clear();
377                        }
378
379                        let mut bold_text = String::new();
380                        let mut found_end = false;
381
382                        while let Some(ch) = chars.next() {
383                            if ch == '*' && chars.peek() == Some(&'*') {
384                                chars.next(); // consume second *
385                                found_end = true;
386                                break;
387                            }
388                            bold_text.push(ch);
389                        }
390
391                        if found_end {
392                            rich.push_bold(bold_text);
393                        } else {
394                            current.push_str("**");
395                            current.push_str(&bold_text);
396                        }
397                    } else {
398                        // Italic: *text*
399                        if !current.is_empty() {
400                            rich.push_str(current.clone());
401                            current.clear();
402                        }
403
404                        let mut italic_text = String::new();
405                        let mut found_end = false;
406
407                        for ch in chars.by_ref() {
408                            if ch == '*' {
409                                found_end = true;
410                                break;
411                            }
412                            italic_text.push(ch);
413                        }
414
415                        if found_end {
416                            rich.push_italic(italic_text);
417                        } else {
418                            current.push('*');
419                            current.push_str(&italic_text);
420                        }
421                    }
422                }
423                '_' => {
424                    if chars.peek() == Some(&'_') {
425                        // Underline: __text__
426                        chars.next(); // consume second _
427
428                        if !current.is_empty() {
429                            rich.push_str(current.clone());
430                            current.clear();
431                        }
432
433                        let mut underline_text = String::new();
434                        let mut found_end = false;
435
436                        while let Some(ch) = chars.next() {
437                            if ch == '_' && chars.peek() == Some(&'_') {
438                                chars.next(); // consume second _
439                                found_end = true;
440                                break;
441                            }
442                            underline_text.push(ch);
443                        }
444
445                        if found_end {
446                            rich.push(
447                                underline_text,
448                                TextSpanStyle::default().with_underline(true),
449                            );
450                        } else {
451                            current.push_str("__");
452                            current.push_str(&underline_text);
453                        }
454                    } else {
455                        current.push(ch);
456                    }
457                }
458                '~' => {
459                    if chars.peek() == Some(&'~') {
460                        // Strikethrough: ~~text~~
461                        chars.next(); // consume second ~
462
463                        if !current.is_empty() {
464                            rich.push_str(current.clone());
465                            current.clear();
466                        }
467
468                        let mut strike_text = String::new();
469                        let mut found_end = false;
470
471                        while let Some(ch) = chars.next() {
472                            if ch == '~' && chars.peek() == Some(&'~') {
473                                chars.next(); // consume second ~
474                                found_end = true;
475                                break;
476                            }
477                            strike_text.push(ch);
478                        }
479
480                        if found_end {
481                            rich.push(
482                                strike_text,
483                                TextSpanStyle::default().with_strikethrough(true),
484                            );
485                        } else {
486                            current.push_str("~~");
487                            current.push_str(&strike_text);
488                        }
489                    } else {
490                        current.push(ch);
491                    }
492                }
493                _ => {
494                    current.push(ch);
495                }
496            }
497        }
498
499        if !current.is_empty() {
500            rich.push_str(current);
501        }
502
503        rich
504    }
505}
506
507impl Default for RichText {
508    fn default() -> Self {
509        Self::new()
510    }
511}
512
513/// Builder for creating rich text with a fluent API.
514pub struct RichTextBuilder {
515    rich_text: RichText,
516}
517
518impl RichTextBuilder {
519    /// Create a new rich text builder.
520    pub fn new() -> Self {
521        Self {
522            rich_text: RichText::new(),
523        }
524    }
525
526    /// Add plain text.
527    pub fn text(mut self, text: impl Into<String>) -> Self {
528        self.rich_text.push_str(text);
529        self
530    }
531
532    /// Add bold text.
533    pub fn bold(mut self, text: impl Into<String>) -> Self {
534        self.rich_text.push_bold(text);
535        self
536    }
537
538    /// Add italic text.
539    pub fn italic(mut self, text: impl Into<String>) -> Self {
540        self.rich_text.push_italic(text);
541        self
542    }
543
544    /// Add colored text.
545    pub fn colored(mut self, text: impl Into<String>, color: Color) -> Self {
546        self.rich_text.push_colored(text, color);
547        self
548    }
549
550    /// Add a custom styled span.
551    pub fn span(mut self, text: impl Into<String>, style: TextSpanStyle) -> Self {
552        self.rich_text.push(text, style);
553        self
554    }
555
556    /// Set default font size.
557    pub fn default_size(mut self, size: f32) -> Self {
558        self.rich_text.set_default_font_size(size);
559        self
560    }
561
562    /// Set default color.
563    pub fn default_color(mut self, color: Color) -> Self {
564        self.rich_text.set_default_color(color);
565        self
566    }
567
568    /// Set text alignment.
569    pub fn align(mut self, align: TextAlign) -> Self {
570        self.rich_text.set_align(align);
571        self
572    }
573
574    /// Set vertical alignment.
575    pub fn vertical_align(mut self, align: VerticalAlign) -> Self {
576        self.rich_text.set_vertical_align(align);
577        self
578    }
579
580    /// Set text wrapping.
581    pub fn wrap(mut self, wrap: TextWrap) -> Self {
582        self.rich_text.set_wrap(wrap);
583        self
584    }
585
586    /// Set line breaking configuration.
587    ///
588    /// This provides more control than `.wrap()` alone, allowing configuration
589    /// of hyphen breaks and future UAX#14 options.
590    pub fn line_break(mut self, config: LineBreakConfig) -> Self {
591        self.rich_text.set_line_break(config);
592        self
593    }
594
595    /// Set maximum width.
596    pub fn max_width(mut self, width: f32) -> Self {
597        self.rich_text.set_max_width(Some(width));
598        self
599    }
600
601    /// Set maximum height.
602    pub fn max_height(mut self, height: f32) -> Self {
603        self.rich_text.set_max_height(Some(height));
604        self
605    }
606
607    /// Set line height multiplier.
608    pub fn line_height(mut self, height: f32) -> Self {
609        self.rich_text.set_line_height(height);
610        self
611    }
612
613    /// Build the rich text.
614    pub fn build(self) -> RichText {
615        self.rich_text
616    }
617}
618
619impl Default for RichTextBuilder {
620    fn default() -> Self {
621        Self::new()
622    }
623}
624
625#[cfg(test)]
626mod tests {
627    use super::*;
628
629    #[test]
630    fn test_rich_text_builder() {
631        let rich = RichTextBuilder::new()
632            .text("This is ")
633            .bold("bold")
634            .text(" and ")
635            .italic("italic")
636            .text(" text.")
637            .build();
638
639        assert_eq!(rich.spans().len(), 5);
640        assert_eq!(rich.full_text(), "This is bold and italic text.");
641    }
642
643    #[test]
644    fn test_markup_parsing_bold() {
645        let rich = RichText::from_markup("This is **bold** text.");
646        assert_eq!(rich.spans().len(), 3);
647        assert_eq!(rich.full_text(), "This is bold text.");
648
649        assert!(rich.spans()[1].style.weight == Some(FontWeight::Bold));
650    }
651
652    #[test]
653    fn test_markup_parsing_italic() {
654        let rich = RichText::from_markup("This is *italic* text.");
655        assert_eq!(rich.spans().len(), 3);
656        assert_eq!(rich.full_text(), "This is italic text.");
657
658        assert!(rich.spans()[1].style.style == Some(FontStyle::Italic));
659    }
660
661    #[test]
662    fn test_markup_parsing_underline() {
663        let rich = RichText::from_markup("This is __underlined__ text.");
664        assert_eq!(rich.spans().len(), 3);
665        assert_eq!(rich.full_text(), "This is underlined text.");
666
667        assert!(rich.spans()[1].style.underline);
668    }
669
670    #[test]
671    fn test_markup_parsing_strikethrough() {
672        let rich = RichText::from_markup("This is ~~strikethrough~~ text.");
673        assert_eq!(rich.spans().len(), 3);
674        assert_eq!(rich.full_text(), "This is strikethrough text.");
675
676        assert!(rich.spans()[1].style.strikethrough);
677    }
678
679    #[test]
680    fn test_markup_parsing_mixed() {
681        let rich = RichText::from_markup("This is **bold** and *italic* and __underlined__ text.");
682        assert_eq!(rich.spans().len(), 7);
683    }
684}