Skip to main content

astrelis_text/
text.rs

1use cosmic_text::Color as CosmicColor;
2
3use crate::decoration::{StrikethroughStyle, TextDecoration, UnderlineStyle};
4use crate::effects::{TextEffect, TextEffects};
5use crate::font::{FontAttributes, FontStretch, FontStyle, FontWeight};
6use crate::sdf::TextRenderMode;
7use astrelis_core::math::Vec2;
8use astrelis_render::Color;
9
10/// Text alignment (horizontal).
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum TextAlign {
13    Left,
14    Center,
15    Right,
16    Justified,
17}
18
19impl TextAlign {
20    pub(crate) fn to_cosmic(self) -> cosmic_text::Align {
21        match self {
22            TextAlign::Left => cosmic_text::Align::Left,
23            TextAlign::Center => cosmic_text::Align::Center,
24            TextAlign::Right => cosmic_text::Align::Right,
25            TextAlign::Justified => cosmic_text::Align::Justified,
26        }
27    }
28}
29
30/// Vertical alignment for text within a container.
31///
32/// Controls how text is positioned vertically within its allocated space.
33/// Position coordinates always represent the top-left corner of the text's bounding box,
34/// and vertical alignment adjusts the text within that space.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
36pub enum VerticalAlign {
37    /// Align text to the top of the container (default).
38    #[default]
39    Top,
40    /// Center text vertically within the container.
41    Center,
42    /// Align text to the bottom of the container.
43    Bottom,
44}
45
46/// Text wrapping mode controlling how text breaks across lines.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
48pub enum TextWrap {
49    /// No wrapping - text extends past boundaries on a single line.
50    None,
51
52    /// Word-based wrapping - breaks only at word boundaries.
53    /// This is the default and most common mode.
54    #[default]
55    Word,
56
57    /// Glyph/character-based wrapping - breaks at any character.
58    /// Useful for CJK text or very narrow containers.
59    Glyph,
60
61    /// Try word wrapping first, fall back to glyph if a word is too long.
62    /// Best for mixed content (URLs, code, CJK text mixed with Latin).
63    WordOrGlyph,
64}
65
66impl TextWrap {
67    pub(crate) fn to_cosmic(self) -> cosmic_text::Wrap {
68        match self {
69            TextWrap::None => cosmic_text::Wrap::None,
70            TextWrap::Word => cosmic_text::Wrap::Word,
71            TextWrap::Glyph => cosmic_text::Wrap::Glyph,
72            TextWrap::WordOrGlyph => cosmic_text::Wrap::WordOrGlyph,
73        }
74    }
75}
76
77/// Configuration for line breaking behavior.
78///
79/// Provides control over how text wraps and breaks across lines.
80/// Currently uses cosmic-text's built-in line breaking.
81/// Future versions will support UAX#14 customization.
82///
83/// # Example
84///
85/// ```ignore
86/// use astrelis_text::{LineBreakConfig, TextWrap};
87///
88/// let config = LineBreakConfig::new(TextWrap::WordOrGlyph)
89///     .with_hyphen_breaks(true);
90/// ```
91#[derive(Debug, Clone, Default)]
92#[non_exhaustive]
93pub struct LineBreakConfig {
94    /// The wrapping mode to use.
95    pub wrap: TextWrap,
96
97    /// Whether to allow breaks at hyphens (e.g., "self-aware" can break at "-").
98    /// Default: true
99    pub break_at_hyphens: bool,
100}
101
102impl LineBreakConfig {
103    /// Create a new line break configuration with the specified wrap mode.
104    ///
105    /// # Arguments
106    ///
107    /// * `wrap` - The text wrapping mode to use
108    ///
109    /// # Example
110    ///
111    /// ```ignore
112    /// use astrelis_text::{LineBreakConfig, TextWrap};
113    ///
114    /// let config = LineBreakConfig::new(TextWrap::Word);
115    /// ```
116    pub fn new(wrap: TextWrap) -> Self {
117        Self {
118            wrap,
119            break_at_hyphens: true,
120        }
121    }
122
123    /// Configure whether to allow breaks at hyphens.
124    ///
125    /// When enabled, hyphenated words like "self-aware" can break at the hyphen.
126    /// Default is `true`.
127    ///
128    /// # Arguments
129    ///
130    /// * `allow` - Whether to allow breaks at hyphens
131    ///
132    /// # Example
133    ///
134    /// ```ignore
135    /// use astrelis_text::{LineBreakConfig, TextWrap};
136    ///
137    /// // Disable hyphen breaks for technical terms
138    /// let config = LineBreakConfig::new(TextWrap::Word)
139    ///     .with_hyphen_breaks(false);
140    /// ```
141    pub fn with_hyphen_breaks(mut self, allow: bool) -> Self {
142        self.break_at_hyphens = allow;
143        self
144    }
145
146    /// Get the wrap mode.
147    pub fn wrap(&self) -> TextWrap {
148        self.wrap
149    }
150
151    /// Check if hyphen breaks are enabled.
152    pub fn breaks_at_hyphens(&self) -> bool {
153        self.break_at_hyphens
154    }
155}
156
157/// Convert Color to cosmic-text color.
158pub(crate) fn color_to_cosmic(color: Color) -> CosmicColor {
159    CosmicColor::rgba(
160        (color.r * 255.0) as u8,
161        (color.g * 255.0) as u8,
162        (color.b * 255.0) as u8,
163        (color.a * 255.0) as u8,
164    )
165}
166
167/// Font metrics for text layout and positioning.
168///
169/// These metrics describe the vertical characteristics of a font at a given size,
170/// useful for precise text layout and baseline alignment.
171#[derive(Debug, Clone, Copy, PartialEq)]
172pub struct TextMetrics {
173    /// The ascent: distance from baseline to the top of the tallest glyph.
174    pub ascent: f32,
175    /// The descent: distance from baseline to the bottom of the lowest glyph (positive value).
176    pub descent: f32,
177    /// The line height: total vertical space for a line of text.
178    pub line_height: f32,
179    /// The baseline offset from the top of the bounding box.
180    /// This is typically equal to ascent for top-left positioned text.
181    pub baseline_offset: f32,
182}
183
184/// Text builder for creating styled text.
185///
186/// Text positioning uses a top-left coordinate system where (0, 0) is the top-left corner
187/// and Y increases downward, consistent with UI layout systems like CSS and Flutter.
188///
189/// ## SDF Effects
190///
191/// Text supports effects like shadows, outlines, and glows via SDF (Signed Distance Field)
192/// rendering. When effects are present, the text automatically uses SDF mode:
193///
194/// ```ignore
195/// let text = Text::new("Hello")
196///     .size(24.0)
197///     .with_shadow(Vec2::new(2.0, 2.0), 2.0, Color::rgba(0.0, 0.0, 0.0, 0.5))
198///     .with_outline(1.5, Color::BLACK);
199/// ```
200pub struct Text {
201    content: String,
202    font_size: f32,
203    line_height: f32,
204    font_attrs: FontAttributes,
205    color: Color,
206    align: TextAlign,
207    vertical_align: VerticalAlign,
208    wrap: TextWrap,
209    max_width: Option<f32>,
210    max_height: Option<f32>,
211    letter_spacing: f32,
212    word_spacing: f32,
213    /// Whether to allow breaks at hyphens (stored for future use)
214    break_at_hyphens: bool,
215    /// Optional text effects (shadows, outlines, glows)
216    effects: Option<TextEffects>,
217    /// Render mode (Bitmap or SDF) - auto-selected when effects are present
218    render_mode: Option<TextRenderMode>,
219    /// Optional text decoration (underline, strikethrough, background)
220    decoration: Option<TextDecoration>,
221}
222
223impl Text {
224    /// Create a new text instance.
225    pub fn new(content: impl Into<String>) -> Self {
226        Self {
227            content: content.into(),
228            font_size: 16.0,
229            line_height: 1.2,
230            font_attrs: FontAttributes::default(),
231            color: Color::WHITE,
232            align: TextAlign::Left,
233            vertical_align: VerticalAlign::Top,
234            wrap: TextWrap::Word,
235            max_width: None,
236            max_height: None,
237            letter_spacing: 0.0,
238            word_spacing: 0.0,
239            break_at_hyphens: true,
240            effects: None,
241            render_mode: None,
242            decoration: None,
243        }
244    }
245
246    /// Set the font size in pixels.
247    pub fn size(mut self, size: f32) -> Self {
248        self.font_size = size;
249        self
250    }
251
252    /// Set the line height multiplier.
253    pub fn line_height(mut self, height: f32) -> Self {
254        self.line_height = height;
255        self
256    }
257
258    /// Set the font family.
259    pub fn font(mut self, family: impl Into<String>) -> Self {
260        self.font_attrs.family = family.into();
261        self
262    }
263
264    /// Set the font weight.
265    pub fn weight(mut self, weight: FontWeight) -> Self {
266        self.font_attrs.weight = weight;
267        self
268    }
269
270    /// Set the font style.
271    pub fn style(mut self, style: FontStyle) -> Self {
272        self.font_attrs.style = style;
273        self
274    }
275
276    /// Set the font stretch.
277    pub fn stretch(mut self, stretch: FontStretch) -> Self {
278        self.font_attrs.stretch = stretch;
279        self
280    }
281
282    /// Set font attributes.
283    pub fn font_attrs(mut self, attrs: FontAttributes) -> Self {
284        self.font_attrs = attrs;
285        self
286    }
287
288    /// Set the text color.
289    pub fn color(mut self, color: Color) -> Self {
290        self.color = color;
291        self
292    }
293
294    /// Set the text alignment (horizontal).
295    pub fn align(mut self, align: TextAlign) -> Self {
296        self.align = align;
297        self
298    }
299
300    /// Set the vertical alignment.
301    pub fn vertical_align(mut self, vertical_align: VerticalAlign) -> Self {
302        self.vertical_align = vertical_align;
303        self
304    }
305
306    /// Set the text wrapping mode.
307    pub fn wrap(mut self, wrap: TextWrap) -> Self {
308        self.wrap = wrap;
309        self
310    }
311
312    /// Set line breaking configuration for advanced control.
313    ///
314    /// This provides more control than `.wrap()` alone, allowing configuration
315    /// of hyphen breaks and future UAX#14 options.
316    ///
317    /// # Arguments
318    ///
319    /// * `config` - The line break configuration
320    ///
321    /// # Example
322    ///
323    /// ```ignore
324    /// use astrelis_text::{Text, LineBreakConfig, TextWrap};
325    ///
326    /// let text = Text::new("Long text with a-very-long-hyphenated-word")
327    ///     .line_break(
328    ///         LineBreakConfig::new(TextWrap::WordOrGlyph)
329    ///             .with_hyphen_breaks(true)
330    ///     )
331    ///     .max_width(200.0);
332    /// ```
333    pub fn line_break(mut self, config: LineBreakConfig) -> Self {
334        self.wrap = config.wrap;
335        self.break_at_hyphens = config.break_at_hyphens;
336        self
337    }
338
339    /// Set the maximum width for text wrapping.
340    pub fn max_width(mut self, width: f32) -> Self {
341        self.max_width = Some(width);
342        self
343    }
344
345    /// Set the maximum height for text.
346    pub fn max_height(mut self, height: f32) -> Self {
347        self.max_height = Some(height);
348        self
349    }
350
351    /// Set letter spacing in pixels.
352    pub fn letter_spacing(mut self, spacing: f32) -> Self {
353        self.letter_spacing = spacing;
354        self
355    }
356
357    /// Set word spacing in pixels.
358    pub fn word_spacing(mut self, spacing: f32) -> Self {
359        self.word_spacing = spacing;
360        self
361    }
362
363    /// Make the text bold.
364    pub fn bold(self) -> Self {
365        self.weight(FontWeight::Bold)
366    }
367
368    /// Make the text italic.
369    pub fn italic(self) -> Self {
370        self.style(FontStyle::Italic)
371    }
372
373    // Getters
374
375    pub fn get_content(&self) -> &str {
376        &self.content
377    }
378
379    pub fn get_font_size(&self) -> f32 {
380        self.font_size
381    }
382
383    pub fn get_line_height(&self) -> f32 {
384        self.line_height
385    }
386
387    pub fn get_font_attrs(&self) -> &FontAttributes {
388        &self.font_attrs
389    }
390
391    pub fn get_color(&self) -> Color {
392        self.color
393    }
394
395    pub fn get_align(&self) -> TextAlign {
396        self.align
397    }
398
399    pub fn get_vertical_align(&self) -> VerticalAlign {
400        self.vertical_align
401    }
402
403    pub fn get_wrap(&self) -> TextWrap {
404        self.wrap
405    }
406
407    pub fn get_max_width(&self) -> Option<f32> {
408        self.max_width
409    }
410
411    pub fn get_max_height(&self) -> Option<f32> {
412        self.max_height
413    }
414
415    pub fn get_letter_spacing(&self) -> f32 {
416        self.letter_spacing
417    }
418
419    pub fn get_word_spacing(&self) -> f32 {
420        self.word_spacing
421    }
422
423    /// Check if hyphen breaks are enabled.
424    pub fn get_break_at_hyphens(&self) -> bool {
425        self.break_at_hyphens
426    }
427
428    // ========== Effects Builder Methods ==========
429
430    /// Add a single text effect.
431    ///
432    /// Effects are rendered using SDF (Signed Distance Field) rendering, which
433    /// enables high-quality shadows, outlines, and glows at any scale.
434    ///
435    /// Multiple effects can be combined by chaining calls. Effects are rendered
436    /// in priority order: shadows first (background), then outlines (foreground).
437    ///
438    /// # Arguments
439    ///
440    /// * `effect` - The text effect to add
441    ///
442    /// # Example
443    ///
444    /// ```ignore
445    /// use astrelis_text::{Text, TextEffect, Color};
446    /// use astrelis_core::math::Vec2;
447    ///
448    /// // Single shadow effect
449    /// let text = Text::new("Hello")
450    ///     .size(32.0)
451    ///     .with_effect(TextEffect::shadow(
452    ///         Vec2::new(2.0, 2.0),
453    ///         Color::rgba(0.0, 0.0, 0.0, 0.5)
454    ///     ));
455    ///
456    /// // Combine shadow and outline
457    /// let text = Text::new("Bold")
458    ///     .size(48.0)
459    ///     .with_effect(TextEffect::shadow(
460    ///         Vec2::new(2.0, 2.0),
461    ///         Color::BLACK
462    ///     ))
463    ///     .with_effect(TextEffect::outline(
464    ///         2.0,
465    ///         Color::WHITE
466    ///     ));
467    /// ```
468    pub fn with_effect(mut self, effect: TextEffect) -> Self {
469        let effects = self.effects.get_or_insert_with(TextEffects::new);
470        effects.add(effect);
471        self
472    }
473
474    /// Add multiple text effects at once.
475    pub fn with_effects(mut self, effects: TextEffects) -> Self {
476        self.effects = Some(effects);
477        self
478    }
479
480    /// Add a shadow effect.
481    ///
482    /// Creates a drop shadow behind the text. This is the most commonly used effect
483    /// for improving text readability on varied backgrounds.
484    ///
485    /// # Arguments
486    ///
487    /// * `offset` - Shadow offset in pixels (x, y). Positive values offset down and right.
488    /// * `color` - Shadow color (typically semi-transparent black)
489    ///
490    /// # Example
491    ///
492    /// ```ignore
493    /// use astrelis_text::{Text, Color};
494    /// use astrelis_core::math::Vec2;
495    ///
496    /// // Standard drop shadow (2px right and down)
497    /// let text = Text::new("Readable")
498    ///     .size(24.0)
499    ///     .with_shadow(Vec2::new(2.0, 2.0), Color::rgba(0.0, 0.0, 0.0, 0.5));
500    /// ```
501    pub fn with_shadow(self, offset: Vec2, color: Color) -> Self {
502        self.with_effect(TextEffect::shadow(offset, color))
503    }
504
505    /// Add a blurred shadow effect for softer appearance.
506    ///
507    /// Creates a drop shadow with a blur radius, producing a softer, more natural
508    /// shadow that's useful for headings and titles.
509    ///
510    /// # Arguments
511    ///
512    /// * `offset` - Shadow offset in pixels (x, y)
513    /// * `blur_radius` - Blur radius in pixels (0 = hard edge, 2-5 = soft shadow)
514    /// * `color` - Shadow color (typically semi-transparent)
515    ///
516    /// # Example
517    ///
518    /// ```ignore
519    /// use astrelis_text::{Text, Color};
520    /// use astrelis_core::math::Vec2;
521    ///
522    /// // Soft shadow for a heading
523    /// let text = Text::new("Title")
524    ///     .size(48.0)
525    ///     .with_shadow_blurred(
526    ///         Vec2::new(3.0, 3.0),
527    ///         4.0,  // 4px blur radius
528    ///         Color::rgba(0.0, 0.0, 0.0, 0.6)
529    ///     );
530    /// ```
531    pub fn with_shadow_blurred(self, offset: Vec2, blur_radius: f32, color: Color) -> Self {
532        self.with_effect(TextEffect::shadow_blurred(offset, blur_radius, color))
533    }
534
535    /// Add an outline effect around the text.
536    ///
537    /// Creates a stroke around text characters, useful for making text stand out
538    /// against complex backgrounds or creating stylized text.
539    ///
540    /// # Arguments
541    ///
542    /// * `width` - Outline width in pixels (typically 1-3px)
543    /// * `color` - Outline color (often contrasting with text color)
544    ///
545    /// # Example
546    ///
547    /// ```ignore
548    /// use astrelis_text::{Text, Color};
549    ///
550    /// // White text with black outline (classic game text style)
551    /// let text = Text::new("Game Text")
552    ///     .size(32.0)
553    ///     .color(Color::WHITE)
554    ///     .with_outline(2.0, Color::BLACK);
555    ///
556    /// // Bold outline for emphasis
557    /// let text = Text::new("Important!")
558    ///     .size(40.0)
559    ///     .color(Color::YELLOW)
560    ///     .with_outline(3.0, Color::RED);
561    /// ```
562    pub fn with_outline(self, width: f32, color: Color) -> Self {
563        self.with_effect(TextEffect::outline(width, color))
564    }
565
566    /// Add a glow effect around the text.
567    ///
568    /// Creates a soft luminous halo around text, useful for magical, sci-fi,
569    /// or neon-style text effects.
570    ///
571    /// # Arguments
572    ///
573    /// * `radius` - Glow radius in pixels (typically 3-10px)
574    /// * `color` - Glow color (often bright, saturated colors)
575    /// * `intensity` - Glow intensity multiplier (0.5 to 1.0 for subtle, > 1.0 for intense)
576    ///
577    /// # Example
578    ///
579    /// ```ignore
580    /// use astrelis_text::{Text, Color};
581    ///
582    /// // Neon blue glow
583    /// let text = Text::new("Cyber")
584    ///     .size(36.0)
585    ///     .color(Color::CYAN)
586    ///     .with_glow(6.0, Color::BLUE, 0.8);
587    ///
588    /// // Intense magical glow
589    /// let text = Text::new("Magic")
590    ///     .size(40.0)
591    ///     .color(Color::WHITE)
592    ///     .with_glow(8.0, Color::rgba(1.0, 0.0, 1.0, 1.0), 1.2);
593    /// ```
594    pub fn with_glow(self, radius: f32, color: Color, intensity: f32) -> Self {
595        self.with_effect(TextEffect::glow(radius, color, intensity))
596    }
597
598    /// Set the render mode (Bitmap or SDF).
599    ///
600    /// By default, render mode is auto-selected based on font size and effects:
601    /// - Bitmap for small text (< 24px) without effects - sharper at small sizes
602    /// - SDF for large text (>= 24px) or text with effects - scalable and smooth
603    ///
604    /// Use this method to override the automatic selection.
605    ///
606    /// # Arguments
607    ///
608    /// * `mode` - The render mode to use:
609    ///   - `TextRenderMode::Bitmap` - Traditional rasterized glyphs
610    ///   - `TextRenderMode::SDF { spread }` - Distance field rendering
611    ///
612    /// # Example
613    ///
614    /// ```ignore
615    /// use astrelis_text::{Text, TextRenderMode};
616    ///
617    /// // Force bitmap even for large text
618    /// let text = Text::new("Large but Sharp")
619    ///     .size(48.0)
620    ///     .render_mode(TextRenderMode::Bitmap);
621    ///
622    /// // Force SDF with custom spread
623    /// let text = Text::new("Custom SDF")
624    ///     .size(20.0)
625    ///     .render_mode(TextRenderMode::SDF { spread: 6.0 });
626    /// ```
627    pub fn render_mode(mut self, mode: TextRenderMode) -> Self {
628        self.render_mode = Some(mode);
629        self
630    }
631
632    /// Force SDF rendering mode with default spread.
633    ///
634    /// Useful for text that needs to scale smoothly or maintain quality at various
635    /// sizes. Equivalent to `.render_mode(TextRenderMode::SDF { spread: 4.0 })`.
636    ///
637    /// # When to Use
638    ///
639    /// - Text that will be animated or scaled
640    /// - Text in UI elements that change size
641    /// - High-DPI displays where extra sharpness helps
642    /// - When preparing text for future effects
643    ///
644    /// # Example
645    ///
646    /// ```ignore
647    /// use astrelis_text::Text;
648    ///
649    /// // Small text that will be scaled up smoothly
650    /// let text = Text::new("UI Label")
651    ///     .size(14.0)
652    ///     .sdf();  // Force SDF for smooth scaling
653    /// ```
654    pub fn sdf(self) -> Self {
655        self.render_mode(TextRenderMode::SDF { spread: 4.0 })
656    }
657
658    /// Get the text effects, if any.
659    pub fn get_effects(&self) -> Option<&TextEffects> {
660        self.effects.as_ref()
661    }
662
663    /// Get the render mode, if explicitly set.
664    pub fn get_render_mode(&self) -> Option<TextRenderMode> {
665        self.render_mode
666    }
667
668    /// Check if this text has any effects configured.
669    pub fn has_effects(&self) -> bool {
670        self.effects
671            .as_ref()
672            .map(|e| e.has_enabled_effects())
673            .unwrap_or(false)
674    }
675
676    // ========== Decoration Builder Methods ==========
677
678    /// Set text decoration (underline, strikethrough, background).
679    ///
680    /// Text decorations are rendered separately from the text glyphs:
681    /// - Background: colored quad behind text (rendered first)
682    /// - Underline: line below the text baseline
683    /// - Strikethrough: line through the middle of text
684    ///
685    /// # Arguments
686    ///
687    /// * `decoration` - The decoration configuration
688    ///
689    /// # Example
690    ///
691    /// ```ignore
692    /// use astrelis_text::{Text, TextDecoration, UnderlineStyle, Color};
693    ///
694    /// let decoration = TextDecoration::new()
695    ///     .underline(UnderlineStyle::solid(Color::BLUE, 1.0))
696    ///     .background(Color::YELLOW);
697    ///
698    /// let text = Text::new("Important text")
699    ///     .with_decoration(decoration);
700    /// ```
701    pub fn with_decoration(mut self, decoration: TextDecoration) -> Self {
702        self.decoration = Some(decoration);
703        self
704    }
705
706    /// Add a solid underline with the specified color.
707    ///
708    /// Convenience method for adding a simple solid underline.
709    /// For more control (thickness, offset, style), use `with_decoration()`.
710    ///
711    /// # Arguments
712    ///
713    /// * `color` - The underline color
714    ///
715    /// # Example
716    ///
717    /// ```ignore
718    /// use astrelis_text::{Text, Color};
719    ///
720    /// let text = Text::new("Underlined")
721    ///     .underline(Color::BLUE);
722    /// ```
723    pub fn underline(self, color: Color) -> Self {
724        let decoration = self
725            .decoration
726            .clone()
727            .unwrap_or_default()
728            .underline(UnderlineStyle::solid(color, 1.0));
729        self.with_decoration(decoration)
730    }
731
732    /// Add a solid strikethrough with the specified color.
733    ///
734    /// Convenience method for adding a simple solid strikethrough.
735    /// For more control (thickness, offset, style), use `with_decoration()`.
736    ///
737    /// # Arguments
738    ///
739    /// * `color` - The strikethrough color
740    ///
741    /// # Example
742    ///
743    /// ```ignore
744    /// use astrelis_text::{Text, Color};
745    ///
746    /// let text = Text::new("Deleted")
747    ///     .strikethrough(Color::RED);
748    /// ```
749    pub fn strikethrough(self, color: Color) -> Self {
750        let decoration = self
751            .decoration
752            .clone()
753            .unwrap_or_default()
754            .strikethrough(StrikethroughStyle::solid(color, 1.0));
755        self.with_decoration(decoration)
756    }
757
758    /// Add a background highlight color.
759    ///
760    /// Convenience method for adding a simple background highlight.
761    /// For more control (padding), use `with_decoration()`.
762    ///
763    /// # Arguments
764    ///
765    /// * `color` - The background highlight color
766    ///
767    /// # Example
768    ///
769    /// ```ignore
770    /// use astrelis_text::{Text, Color};
771    ///
772    /// let text = Text::new("Highlighted")
773    ///     .background_color(Color::YELLOW);
774    /// ```
775    pub fn background_color(self, color: Color) -> Self {
776        let decoration = self
777            .decoration
778            .clone()
779            .unwrap_or_default()
780            .background(color);
781        self.with_decoration(decoration)
782    }
783
784    /// Get the text decoration, if any.
785    pub fn get_decoration(&self) -> Option<&TextDecoration> {
786        self.decoration.as_ref()
787    }
788
789    /// Check if this text has any decoration configured.
790    pub fn has_decoration(&self) -> bool {
791        self.decoration
792            .as_ref()
793            .map(|d| d.has_decoration())
794            .unwrap_or(false)
795    }
796
797    /// Determine the appropriate render mode for this text.
798    ///
799    /// Returns the explicitly set mode via `.render_mode()` or `.sdf()`, or auto-selects
800    /// based on font size and effects using the hybrid rendering strategy.
801    ///
802    /// # Auto-Selection Logic
803    ///
804    /// If no explicit mode is set:
805    /// - Font size >= 24px → SDF (better scaling for large text)
806    /// - Has effects → SDF (required for shadows, outlines, glows)
807    /// - Otherwise → Bitmap (sharper for small UI text)
808    ///
809    /// # Returns
810    ///
811    /// The render mode that will be used when this text is rendered
812    ///
813    /// # Example
814    ///
815    /// ```ignore
816    /// use astrelis_text::{Text, TextRenderMode};
817    ///
818    /// let text = Text::new("Hello").size(32.0);
819    /// assert!(text.effective_render_mode().is_sdf());  // Auto-selected SDF for 32px
820    ///
821    /// let text = Text::new("Small").size(14.0);
822    /// assert!(!text.effective_render_mode().is_sdf());  // Auto-selected Bitmap for 14px
823    ///
824    /// let text = Text::new("Effects").size(16.0).with_shadow(...);
825    /// assert!(text.effective_render_mode().is_sdf());  // Auto-selected SDF for effects
826    /// ```
827    pub fn effective_render_mode(&self) -> TextRenderMode {
828        // If explicitly set, use that
829        if let Some(mode) = self.render_mode {
830            return mode;
831        }
832
833        // Auto-select: SDF for effects or large text, bitmap otherwise
834        if self.has_effects() || self.font_size >= 24.0 {
835            TextRenderMode::SDF { spread: 4.0 }
836        } else {
837            TextRenderMode::Bitmap
838        }
839    }
840}
841
842impl Default for Text {
843    fn default() -> Self {
844        Self::new("")
845    }
846}
847
848#[cfg(test)]
849mod tests {
850    use super::*;
851
852    #[test]
853    fn test_text_with_effect() {
854        use crate::effects::TextEffect;
855
856        let text =
857            Text::new("Hello").with_effect(TextEffect::shadow(Vec2::new(1.0, 1.0), Color::BLACK));
858
859        assert!(text.has_effects());
860        assert_eq!(text.get_effects().unwrap().effects().len(), 1);
861    }
862
863    #[test]
864    fn test_text_with_shadow() {
865        let text = Text::new("Hello").with_shadow(Vec2::new(2.0, 2.0), Color::BLACK);
866
867        assert!(text.has_effects());
868        let effects = text.get_effects().unwrap();
869        assert_eq!(effects.effects().len(), 1);
870    }
871
872    #[test]
873    fn test_text_with_shadow_blurred() {
874        let text = Text::new("Hello").with_shadow_blurred(Vec2::new(2.0, 2.0), 1.5, Color::BLACK);
875
876        assert!(text.has_effects());
877    }
878
879    #[test]
880    fn test_text_with_outline() {
881        let text = Text::new("Hello").with_outline(1.0, Color::WHITE);
882
883        assert!(text.has_effects());
884    }
885
886    #[test]
887    fn test_text_with_glow() {
888        let text = Text::new("Hello").with_glow(5.0, Color::BLUE, 0.8);
889
890        assert!(text.has_effects());
891    }
892
893    #[test]
894    fn test_text_with_multiple_effects() {
895        let text = Text::new("Hello")
896            .with_shadow(Vec2::new(1.0, 1.0), Color::BLACK)
897            .with_outline(1.0, Color::WHITE)
898            .with_glow(3.0, Color::BLUE, 0.5);
899
900        assert!(text.has_effects());
901        let effects = text.get_effects().unwrap();
902        assert_eq!(effects.effects().len(), 3);
903    }
904
905    #[test]
906    fn test_text_render_mode_explicit() {
907        let text = Text::new("Hello").render_mode(TextRenderMode::SDF { spread: 6.0 });
908
909        assert_eq!(
910            text.get_render_mode(),
911            Some(TextRenderMode::SDF { spread: 6.0 })
912        );
913    }
914
915    #[test]
916    fn test_text_sdf() {
917        let text = Text::new("Hello").sdf();
918
919        assert!(text.get_render_mode().is_some());
920        assert!(text.get_render_mode().unwrap().is_sdf());
921    }
922
923    #[test]
924    fn test_text_effective_render_mode_small_no_effects() {
925        let text = Text::new("Hello").size(12.0);
926
927        let mode = text.effective_render_mode();
928        assert_eq!(mode, TextRenderMode::Bitmap);
929    }
930
931    #[test]
932    fn test_text_effective_render_mode_large_no_effects() {
933        let text = Text::new("Hello").size(32.0);
934
935        let mode = text.effective_render_mode();
936        assert!(mode.is_sdf());
937    }
938
939    #[test]
940    fn test_text_effective_render_mode_small_with_effects() {
941        let text = Text::new("Hello")
942            .size(12.0)
943            .with_shadow(Vec2::new(1.0, 1.0), Color::BLACK);
944
945        let mode = text.effective_render_mode();
946        assert!(mode.is_sdf());
947    }
948
949    #[test]
950    fn test_text_effective_render_mode_explicit_overrides() {
951        // Explicit mode should override auto-selection
952        let text = Text::new("Hello")
953            .size(12.0)
954            .with_shadow(Vec2::new(1.0, 1.0), Color::BLACK)
955            .render_mode(TextRenderMode::Bitmap);
956
957        let mode = text.effective_render_mode();
958        assert_eq!(mode, TextRenderMode::Bitmap);
959    }
960
961    #[test]
962    fn test_text_has_effects_false() {
963        let text = Text::new("Hello");
964
965        assert!(!text.has_effects());
966    }
967
968    #[test]
969    fn test_text_has_effects_true() {
970        let text = Text::new("Hello").with_shadow(Vec2::new(1.0, 1.0), Color::BLACK);
971
972        assert!(text.has_effects());
973    }
974
975    #[test]
976    fn test_text_has_effects_disabled() {
977        use crate::effects::{TextEffect, TextEffects};
978
979        let mut effects = TextEffects::new();
980        let mut effect = TextEffect::shadow(Vec2::new(1.0, 1.0), Color::BLACK);
981        effect.set_enabled(false);
982        effects.add(effect);
983
984        let text = Text::new("Hello").with_effects(effects);
985
986        assert!(!text.has_effects());
987    }
988
989    #[test]
990    fn test_text_builder_chaining() {
991        let text = Text::new("Hello World")
992            .size(24.0)
993            .color(Color::RED)
994            .bold()
995            .with_shadow(Vec2::new(2.0, 2.0), Color::BLACK)
996            .with_outline(1.0, Color::WHITE)
997            .sdf();
998
999        assert_eq!(text.get_font_size(), 24.0);
1000        assert_eq!(text.get_color(), Color::RED);
1001        assert!(text.has_effects());
1002        assert!(text.get_render_mode().unwrap().is_sdf());
1003    }
1004
1005    #[test]
1006    fn test_text_effective_render_mode_boundary() {
1007        // At 24px boundary
1008        let text_at_boundary = Text::new("Hello").size(24.0);
1009        assert!(text_at_boundary.effective_render_mode().is_sdf());
1010
1011        // Just below boundary
1012        let text_below = Text::new("Hello").size(23.9);
1013        assert!(!text_below.effective_render_mode().is_sdf());
1014
1015        // Just above boundary
1016        let text_above = Text::new("Hello").size(24.1);
1017        assert!(text_above.effective_render_mode().is_sdf());
1018    }
1019}