Skip to main content

cranpose_ui/text/
style.rs

1use super::decoration::{Shadow, TextDecoration};
2use super::font::{FontFamily, FontStyle, FontSynthesis, FontWeight};
3use super::paragraph::{Hyphens, LineBreak, TextAlign, TextDirection, TextIndent};
4use super::unit::TextUnit;
5use crate::modifier::{Brush, Color};
6use cranpose_ui_graphics::{FxHasher, RenderHash};
7use std::hash::{Hash, Hasher};
8
9/// The line-height policy lives with the drawing-side text types, because a
10/// [`DrawScope`](cranpose_ui_graphics::DrawScope) has to be able to name it too:
11/// a run drawn through a canvas and a `Text` composable on the same screen must
12/// resolve their line boxes by the same rule, and the only style a draw scope
13/// can carry is the flat one from `cranpose-ui-graphics`.
14pub use cranpose_ui_graphics::{
15    LineHeightAlignment, LineHeightMode, LineHeightStyle, LineHeightTrim,
16};
17
18#[derive(Clone, Copy, Debug, PartialEq)]
19pub struct BaselineShift(pub f32);
20
21impl BaselineShift {
22    pub const SUPERSCRIPT: Self = Self(0.5);
23    pub const SUBSCRIPT: Self = Self(-0.5);
24    pub const NONE: Self = Self(0.0);
25    pub const UNSPECIFIED: Self = Self(f32::NAN);
26
27    pub fn is_specified(self) -> bool {
28        !self.0.is_nan()
29    }
30}
31
32#[derive(Clone, Copy, Debug, PartialEq)]
33pub struct TextGeometricTransform {
34    pub scale_x: f32,
35    pub skew_x: f32,
36}
37
38impl Default for TextGeometricTransform {
39    fn default() -> Self {
40        Self {
41            scale_x: 1.0,
42            skew_x: 0.0,
43        }
44    }
45}
46
47#[derive(Clone, Debug, PartialEq, Eq, Hash, Default)]
48pub struct LocaleList {
49    locales: Vec<String>,
50}
51
52impl LocaleList {
53    pub fn new(locales: Vec<String>) -> Self {
54        Self { locales }
55    }
56
57    pub fn from_language_tags(tags: &str) -> Self {
58        let locales = tags
59            .split(',')
60            .map(str::trim)
61            .filter(|tag| !tag.is_empty())
62            .map(ToString::to_string)
63            .collect();
64        Self { locales }
65    }
66
67    pub fn locales(&self) -> &[String] {
68        &self.locales
69    }
70
71    pub fn is_empty(&self) -> bool {
72        self.locales.is_empty()
73    }
74}
75
76#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
77pub enum TextMotion {
78    #[default]
79    Static,
80    Animated,
81}
82
83#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
84pub struct PlatformSpanStyle;
85
86#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
87pub enum TextShaping {
88    Basic,
89    Advanced,
90}
91
92#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
93pub struct PlatformParagraphStyle {
94    pub include_font_padding: Option<bool>,
95    pub shaping: Option<TextShaping>,
96}
97
98#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
99pub struct PlatformTextStyle {
100    pub span_style: Option<PlatformSpanStyle>,
101    pub paragraph_style: Option<PlatformParagraphStyle>,
102}
103
104#[derive(Clone, Copy, Debug, PartialEq, Default)]
105pub enum TextDrawStyle {
106    #[default]
107    Fill,
108    Stroke {
109        width: f32,
110    },
111}
112
113#[derive(Clone, Debug, PartialEq)]
114pub struct SpanStyle {
115    pub color: Option<Color>,
116    pub brush: Option<Brush>,
117    pub alpha: Option<f32>,
118    pub font_size: TextUnit,
119    pub font_weight: Option<FontWeight>,
120    pub font_style: Option<FontStyle>,
121    pub font_synthesis: Option<FontSynthesis>,
122    pub font_family: Option<FontFamily>,
123    pub font_feature_settings: Option<String>,
124    pub letter_spacing: TextUnit,
125    pub baseline_shift: Option<BaselineShift>,
126    pub text_geometric_transform: Option<TextGeometricTransform>,
127    pub locale_list: Option<LocaleList>,
128    pub background: Option<Color>,
129    pub text_decoration: Option<TextDecoration>,
130    pub shadow: Option<Shadow>,
131    pub platform_style: Option<PlatformSpanStyle>,
132    pub draw_style: Option<TextDrawStyle>,
133}
134
135impl Default for SpanStyle {
136    fn default() -> Self {
137        Self {
138            color: None,
139            brush: None,
140            alpha: None,
141            font_size: TextUnit::Unspecified,
142            font_weight: None,
143            font_style: None,
144            font_synthesis: None,
145            font_family: None,
146            font_feature_settings: None,
147            letter_spacing: TextUnit::Unspecified,
148            baseline_shift: None,
149            text_geometric_transform: None,
150            locale_list: None,
151            background: None,
152            text_decoration: None,
153            shadow: None,
154            platform_style: None,
155            draw_style: None,
156        }
157    }
158}
159
160impl SpanStyle {
161    pub fn merge(&self, other: &SpanStyle) -> SpanStyle {
162        let (merged_color, merged_brush) = merge_foreground_style(self, other);
163        SpanStyle {
164            color: merged_color,
165            brush: merged_brush,
166            alpha: other.alpha.or(self.alpha),
167            font_size: merge_text_unit(self.font_size, other.font_size),
168            font_weight: other.font_weight.or(self.font_weight),
169            font_style: other.font_style.or(self.font_style),
170            font_synthesis: other.font_synthesis.or(self.font_synthesis),
171            font_family: other.font_family.clone().or(self.font_family.clone()),
172            font_feature_settings: other
173                .font_feature_settings
174                .clone()
175                .or(self.font_feature_settings.clone()),
176            letter_spacing: merge_text_unit(self.letter_spacing, other.letter_spacing),
177            baseline_shift: other.baseline_shift.or(self.baseline_shift),
178            text_geometric_transform: other
179                .text_geometric_transform
180                .or(self.text_geometric_transform),
181            locale_list: other.locale_list.clone().or(self.locale_list.clone()),
182            background: other.background.or(self.background),
183            text_decoration: other.text_decoration.or(self.text_decoration),
184            shadow: other.shadow.or(self.shadow),
185            platform_style: other.platform_style.or(self.platform_style),
186            draw_style: other.draw_style.or(self.draw_style),
187        }
188    }
189
190    pub fn plus(&self, other: &SpanStyle) -> SpanStyle {
191        self.merge(other)
192    }
193
194    pub fn resolve_font_size(&self, default_size: f32) -> f32 {
195        let fallback = if default_size.is_finite() && default_size > 0.0 {
196            default_size
197        } else {
198            14.0
199        };
200        match self.font_size {
201            TextUnit::Sp(value) if value.is_finite() && value > 0.0 => value,
202            TextUnit::Em(value) if value.is_finite() && value > 0.0 => value * fallback,
203            _ => fallback,
204        }
205    }
206
207    pub fn resolve_foreground_color(&self, default_color: Color) -> Color {
208        let mut color = self
209            .color
210            .or_else(|| solid_brush_color(self.brush.as_ref()))
211            .unwrap_or(default_color);
212        if let Some(alpha) = self.alpha {
213            color.3 *= alpha.clamp(0.0, 1.0);
214        }
215        color
216    }
217
218    pub fn render_hash(&self) -> u64 {
219        let mut hasher = FxHasher::default();
220        hash_span_style(self, &mut hasher);
221        hasher.finish()
222    }
223}
224
225#[derive(Clone, Debug, PartialEq)]
226pub struct ParagraphStyle {
227    pub text_align: TextAlign,
228    pub text_direction: TextDirection,
229    pub line_height: TextUnit,
230    pub text_indent: Option<TextIndent>,
231    pub platform_style: Option<PlatformParagraphStyle>,
232    pub line_height_style: Option<LineHeightStyle>,
233    pub line_break: LineBreak,
234    pub hyphens: Hyphens,
235    pub text_motion: Option<TextMotion>,
236}
237
238impl Default for ParagraphStyle {
239    fn default() -> Self {
240        Self {
241            text_align: TextAlign::Unspecified,
242            text_direction: TextDirection::Unspecified,
243            line_height: TextUnit::Unspecified,
244            text_indent: None,
245            platform_style: None,
246            line_height_style: None,
247            line_break: LineBreak::Unspecified,
248            hyphens: Hyphens::Unspecified,
249            text_motion: None,
250        }
251    }
252}
253
254impl ParagraphStyle {
255    pub fn merge(&self, other: &ParagraphStyle) -> ParagraphStyle {
256        ParagraphStyle {
257            text_align: merge_text_align(self.text_align, other.text_align),
258            text_direction: merge_text_direction(self.text_direction, other.text_direction),
259            line_height: merge_text_unit(self.line_height, other.line_height),
260            text_indent: other.text_indent.or(self.text_indent),
261            platform_style: other.platform_style.or(self.platform_style),
262            line_height_style: other.line_height_style.or(self.line_height_style),
263            line_break: merge_line_break(self.line_break, other.line_break),
264            hyphens: merge_hyphens(self.hyphens, other.hyphens),
265            text_motion: other.text_motion.or(self.text_motion),
266        }
267    }
268
269    pub fn plus(&self, other: &ParagraphStyle) -> ParagraphStyle {
270        self.merge(other)
271    }
272
273    pub fn render_hash(&self) -> u64 {
274        let mut hasher = FxHasher::default();
275        hash_paragraph_style(self, &mut hasher);
276        hasher.finish()
277    }
278}
279
280#[derive(Clone, Debug, PartialEq, Default)]
281pub struct TextStyle {
282    pub span_style: SpanStyle,
283    pub paragraph_style: ParagraphStyle,
284}
285
286impl TextStyle {
287    pub fn new(span_style: SpanStyle, paragraph_style: ParagraphStyle) -> Self {
288        Self {
289            span_style,
290            paragraph_style,
291        }
292    }
293
294    pub fn from_span_style(span_style: SpanStyle) -> Self {
295        Self::new(span_style, ParagraphStyle::default())
296    }
297
298    pub fn from_paragraph_style(paragraph_style: ParagraphStyle) -> Self {
299        Self::new(SpanStyle::default(), paragraph_style)
300    }
301
302    pub fn merge(&self, other: &TextStyle) -> TextStyle {
303        TextStyle {
304            span_style: self.span_style.merge(&other.span_style),
305            paragraph_style: self.paragraph_style.merge(&other.paragraph_style),
306        }
307    }
308
309    pub fn plus(&self, other: &TextStyle) -> TextStyle {
310        self.merge(other)
311    }
312
313    pub fn to_span_style(&self) -> SpanStyle {
314        self.span_style.clone()
315    }
316
317    pub fn to_paragraph_style(&self) -> ParagraphStyle {
318        self.paragraph_style.clone()
319    }
320
321    pub fn platform_style(&self) -> Option<PlatformTextStyle> {
322        create_platform_text_style(
323            None,
324            self.span_style.platform_style,
325            self.paragraph_style.platform_style,
326        )
327    }
328
329    pub fn with_platform_style(mut self, platform_style: Option<PlatformTextStyle>) -> Self {
330        self.span_style.platform_style = platform_style.and_then(|style| style.span_style);
331        self.paragraph_style.platform_style =
332            platform_style.and_then(|style| style.paragraph_style);
333        self
334    }
335
336    pub fn resolve_font_size(&self, default_size: f32) -> f32 {
337        self.span_style.resolve_font_size(default_size)
338    }
339
340    pub fn resolve_line_height(&self, default_size: f32, natural_line_height: f32) -> f32 {
341        let fallback = if natural_line_height.is_finite() && natural_line_height > 0.0 {
342            natural_line_height
343        } else {
344            self.resolve_font_size(default_size)
345        };
346        match self.paragraph_style.line_height {
347            TextUnit::Sp(value) if value.is_finite() && value > 0.0 => value,
348            TextUnit::Em(value) if value.is_finite() && value > 0.0 => {
349                value * self.resolve_font_size(default_size)
350            }
351            _ => fallback,
352        }
353    }
354
355    pub fn resolve_letter_spacing(&self, default_size: f32) -> f32 {
356        let font_size = self.resolve_font_size(default_size);
357        match self.span_style.letter_spacing {
358            TextUnit::Sp(value) if value.is_finite() => value,
359            TextUnit::Em(value) if value.is_finite() => value * font_size,
360            _ => 0.0,
361        }
362    }
363
364    pub fn resolve_text_color(&self, default_color: Color) -> Color {
365        self.span_style.resolve_foreground_color(default_color)
366    }
367
368    pub fn measurement_hash(&self) -> u64 {
369        let mut hasher = FxHasher::default();
370        let span = &self.span_style;
371        let paragraph = &self.paragraph_style;
372
373        hash_text_unit(span.font_size, &mut hasher);
374        span.font_weight.hash(&mut hasher);
375        span.font_style.hash(&mut hasher);
376        span.font_synthesis.hash(&mut hasher);
377        span.font_family.hash(&mut hasher);
378        span.font_feature_settings.hash(&mut hasher);
379        hash_text_unit(span.letter_spacing, &mut hasher);
380        hash_option_baseline_shift(&span.baseline_shift, &mut hasher);
381        hash_option_geometric_transform(&span.text_geometric_transform, &mut hasher);
382        span.locale_list.hash(&mut hasher);
383        span.platform_style.hash(&mut hasher);
384
385        paragraph.text_align.hash(&mut hasher);
386        paragraph.text_direction.hash(&mut hasher);
387        hash_text_unit(paragraph.line_height, &mut hasher);
388        hash_option_text_indent(&paragraph.text_indent, &mut hasher);
389        paragraph.platform_style.hash(&mut hasher);
390        paragraph.line_height_style.hash(&mut hasher);
391        paragraph.line_break.hash(&mut hasher);
392        paragraph.hyphens.hash(&mut hasher);
393        paragraph.text_motion.hash(&mut hasher);
394
395        hasher.finish()
396    }
397
398    pub fn render_hash(&self) -> u64 {
399        let mut hasher = FxHasher::default();
400        hash_span_style(&self.span_style, &mut hasher);
401        hash_paragraph_style(&self.paragraph_style, &mut hasher);
402        hasher.finish()
403    }
404}
405
406fn merge_foreground_style(
407    current: &SpanStyle,
408    incoming: &SpanStyle,
409) -> (Option<Color>, Option<Brush>) {
410    if let Some(brush) = incoming.brush.clone() {
411        return (None, Some(brush));
412    }
413    if let Some(color) = incoming.color {
414        return (Some(color), None);
415    }
416    (current.color, current.brush.clone())
417}
418
419fn solid_brush_color(brush: Option<&Brush>) -> Option<Color> {
420    match brush {
421        Some(Brush::Solid(color)) => Some(*color),
422        _ => None,
423    }
424}
425
426fn create_platform_text_style(
427    explicit: Option<PlatformTextStyle>,
428    span_style: Option<PlatformSpanStyle>,
429    paragraph_style: Option<PlatformParagraphStyle>,
430) -> Option<PlatformTextStyle> {
431    let explicit_span = explicit.and_then(|style| style.span_style);
432    let explicit_paragraph = explicit.and_then(|style| style.paragraph_style);
433    let span = span_style.or(explicit_span);
434    let paragraph = paragraph_style.or(explicit_paragraph);
435    if span.is_none() && paragraph.is_none() {
436        None
437    } else {
438        Some(PlatformTextStyle {
439            span_style: span,
440            paragraph_style: paragraph,
441        })
442    }
443}
444
445fn merge_text_unit(current: TextUnit, incoming: TextUnit) -> TextUnit {
446    if matches!(incoming, TextUnit::Unspecified) {
447        current
448    } else {
449        incoming
450    }
451}
452
453fn merge_text_align(current: TextAlign, incoming: TextAlign) -> TextAlign {
454    if matches!(incoming, TextAlign::Unspecified) {
455        current
456    } else {
457        incoming
458    }
459}
460
461fn merge_text_direction(current: TextDirection, incoming: TextDirection) -> TextDirection {
462    if matches!(incoming, TextDirection::Unspecified) {
463        current
464    } else {
465        incoming
466    }
467}
468
469fn merge_line_break(current: LineBreak, incoming: LineBreak) -> LineBreak {
470    if matches!(incoming, LineBreak::Unspecified) {
471        current
472    } else {
473        incoming
474    }
475}
476
477fn merge_hyphens(current: Hyphens, incoming: Hyphens) -> Hyphens {
478    if matches!(incoming, Hyphens::Unspecified) {
479        current
480    } else {
481        incoming
482    }
483}
484
485fn hash_f32_bits<H: Hasher>(value: f32, state: &mut H) {
486    value.to_bits().hash(state);
487}
488
489fn hash_option_color<H: Hasher>(color: &Option<Color>, state: &mut H) {
490    match color {
491        Some(color) => {
492            1u8.hash(state);
493            color.render_hash().hash(state);
494        }
495        None => 0u8.hash(state),
496    }
497}
498
499fn hash_option_brush<H: Hasher>(brush: &Option<Brush>, state: &mut H) {
500    match brush {
501        Some(brush) => {
502            1u8.hash(state);
503            brush.render_hash().hash(state);
504        }
505        None => 0u8.hash(state),
506    }
507}
508
509fn hash_option_alpha<H: Hasher>(alpha: &Option<f32>, state: &mut H) {
510    match alpha {
511        Some(alpha) => {
512            1u8.hash(state);
513            hash_f32_bits(*alpha, state);
514        }
515        None => 0u8.hash(state),
516    }
517}
518
519fn hash_text_unit<H: Hasher>(unit: TextUnit, state: &mut H) {
520    match unit {
521        TextUnit::Unspecified => 0u8.hash(state),
522        TextUnit::Sp(value) => {
523            1u8.hash(state);
524            hash_f32_bits(value, state);
525        }
526        TextUnit::Em(value) => {
527            2u8.hash(state);
528            hash_f32_bits(value, state);
529        }
530    }
531}
532
533fn hash_option_baseline_shift<H: Hasher>(shift: &Option<BaselineShift>, state: &mut H) {
534    match shift {
535        Some(shift) => {
536            1u8.hash(state);
537            hash_f32_bits(shift.0, state);
538        }
539        None => 0u8.hash(state),
540    }
541}
542
543fn hash_option_geometric_transform<H: Hasher>(
544    transform: &Option<TextGeometricTransform>,
545    state: &mut H,
546) {
547    match transform {
548        Some(transform) => {
549            1u8.hash(state);
550            hash_f32_bits(transform.scale_x, state);
551            hash_f32_bits(transform.skew_x, state);
552        }
553        None => 0u8.hash(state),
554    }
555}
556
557fn hash_option_text_indent<H: Hasher>(indent: &Option<TextIndent>, state: &mut H) {
558    match indent {
559        Some(indent) => {
560            1u8.hash(state);
561            hash_text_unit(indent.first_line, state);
562            hash_text_unit(indent.rest_line, state);
563        }
564        None => 0u8.hash(state),
565    }
566}
567
568fn hash_option_shadow<H: Hasher>(shadow: &Option<Shadow>, state: &mut H) {
569    match shadow {
570        Some(shadow) => {
571            1u8.hash(state);
572            shadow.color.render_hash().hash(state);
573            hash_f32_bits(shadow.offset.x, state);
574            hash_f32_bits(shadow.offset.y, state);
575            hash_f32_bits(shadow.blur_radius, state);
576        }
577        None => 0u8.hash(state),
578    }
579}
580
581fn hash_option_text_draw_style<H: Hasher>(draw_style: &Option<TextDrawStyle>, state: &mut H) {
582    match draw_style {
583        Some(TextDrawStyle::Fill) => {
584            1u8.hash(state);
585            0u8.hash(state);
586        }
587        Some(TextDrawStyle::Stroke { width }) => {
588            1u8.hash(state);
589            1u8.hash(state);
590            hash_f32_bits(*width, state);
591        }
592        None => 0u8.hash(state),
593    }
594}
595
596fn hash_span_style<H: Hasher>(span: &SpanStyle, state: &mut H) {
597    hash_option_color(&span.color, state);
598    hash_option_brush(&span.brush, state);
599    hash_option_alpha(&span.alpha, state);
600    hash_text_unit(span.font_size, state);
601    span.font_weight.hash(state);
602    span.font_style.hash(state);
603    span.font_synthesis.hash(state);
604    span.font_family.hash(state);
605    span.font_feature_settings.hash(state);
606    hash_text_unit(span.letter_spacing, state);
607    hash_option_baseline_shift(&span.baseline_shift, state);
608    hash_option_geometric_transform(&span.text_geometric_transform, state);
609    span.locale_list.hash(state);
610    hash_option_color(&span.background, state);
611    span.text_decoration.hash(state);
612    hash_option_shadow(&span.shadow, state);
613    span.platform_style.hash(state);
614    hash_option_text_draw_style(&span.draw_style, state);
615}
616
617fn hash_paragraph_style<H: Hasher>(paragraph: &ParagraphStyle, state: &mut H) {
618    paragraph.text_align.hash(state);
619    paragraph.text_direction.hash(state);
620    hash_text_unit(paragraph.line_height, state);
621    hash_option_text_indent(&paragraph.text_indent, state);
622    paragraph.platform_style.hash(state);
623    paragraph.line_height_style.hash(state);
624    paragraph.line_break.hash(state);
625    paragraph.hyphens.hash(state);
626    paragraph.text_motion.hash(state);
627}
628
629#[cfg(test)]
630mod tests {
631    use super::*;
632    use crate::modifier::Brush;
633    use crate::text::{FontFamily, TextDirection};
634
635    #[test]
636    fn baseline_shift_reports_specified() {
637        assert!(BaselineShift::SUPERSCRIPT.is_specified());
638        assert!(!BaselineShift::UNSPECIFIED.is_specified());
639    }
640
641    #[test]
642    fn locale_list_parses_language_tags() {
643        let locale_list = LocaleList::from_language_tags("en-US, ar-EG, ja-JP");
644        assert_eq!(locale_list.locales(), &["en-US", "ar-EG", "ja-JP"]);
645    }
646
647    #[test]
648    fn span_style_merge_prefers_incoming_specified_values() {
649        let base = SpanStyle {
650            font_size: TextUnit::Sp(14.0),
651            font_family: Some(FontFamily::Serif),
652            ..Default::default()
653        };
654        let incoming = SpanStyle {
655            font_size: TextUnit::Unspecified,
656            letter_spacing: TextUnit::Em(0.1),
657            ..Default::default()
658        };
659
660        let merged = base.merge(&incoming);
661        assert_eq!(merged.font_size, TextUnit::Sp(14.0));
662        assert_eq!(merged.letter_spacing, TextUnit::Em(0.1));
663        assert_eq!(merged.font_family, Some(FontFamily::Serif));
664    }
665
666    #[test]
667    fn span_style_merge_switches_foreground_kind() {
668        let base = SpanStyle {
669            color: Some(Color(1.0, 0.0, 0.0, 1.0)),
670            ..Default::default()
671        };
672        let incoming = SpanStyle {
673            brush: Some(Brush::solid(Color(0.0, 1.0, 0.0, 1.0))),
674            ..Default::default()
675        };
676
677        let merged = base.merge(&incoming);
678        assert_eq!(merged.color, None);
679        assert_eq!(merged.brush, incoming.brush);
680    }
681
682    #[test]
683    fn span_style_plus_matches_merge() {
684        let base = SpanStyle {
685            font_size: TextUnit::Sp(12.0),
686            ..Default::default()
687        };
688        let incoming = SpanStyle {
689            letter_spacing: TextUnit::Em(0.2),
690            ..Default::default()
691        };
692        assert_eq!(base.plus(&incoming), base.merge(&incoming));
693    }
694
695    #[test]
696    fn paragraph_style_merge_prefers_specified_values() {
697        let base = ParagraphStyle {
698            text_direction: TextDirection::Ltr,
699            line_height: TextUnit::Sp(18.0),
700            ..Default::default()
701        };
702        let incoming = ParagraphStyle {
703            text_direction: TextDirection::Unspecified,
704            line_height: TextUnit::Em(1.4),
705            ..Default::default()
706        };
707
708        let merged = base.merge(&incoming);
709        assert_eq!(merged.text_direction, TextDirection::Ltr);
710        assert_eq!(merged.line_height, TextUnit::Em(1.4));
711    }
712
713    #[test]
714    fn paragraph_style_plus_matches_merge() {
715        let base = ParagraphStyle {
716            text_align: TextAlign::Start,
717            ..Default::default()
718        };
719        let incoming = ParagraphStyle {
720            text_direction: TextDirection::Rtl,
721            ..Default::default()
722        };
723        assert_eq!(base.plus(&incoming), base.merge(&incoming));
724    }
725
726    #[test]
727    fn resolve_font_size_uses_specified_value() {
728        let style = TextStyle::new(
729            SpanStyle {
730                font_size: TextUnit::Sp(18.0),
731                ..Default::default()
732            },
733            ParagraphStyle::default(),
734        );
735        assert_eq!(style.resolve_font_size(14.0), 18.0);
736    }
737
738    #[test]
739    fn resolve_font_size_handles_em_units() {
740        let style = TextStyle::new(
741            SpanStyle {
742                font_size: TextUnit::Em(1.5),
743                ..Default::default()
744            },
745            ParagraphStyle::default(),
746        );
747        assert_eq!(style.resolve_font_size(16.0), 24.0);
748    }
749
750    #[test]
751    fn resolve_line_height_uses_style_value() {
752        let style = TextStyle::new(
753            SpanStyle {
754                font_size: TextUnit::Sp(20.0),
755                ..Default::default()
756            },
757            ParagraphStyle {
758                line_height: TextUnit::Em(1.2),
759                ..Default::default()
760            },
761        );
762        assert_eq!(style.resolve_line_height(14.0, 18.0), 24.0);
763    }
764
765    #[test]
766    fn resolve_foreground_color_supports_solid_brush_with_alpha() {
767        let style = SpanStyle {
768            brush: Some(Brush::solid(Color(0.2, 0.4, 0.6, 1.0))),
769            alpha: Some(0.5),
770            ..Default::default()
771        };
772        assert_eq!(
773            style.resolve_foreground_color(Color(1.0, 1.0, 1.0, 1.0)),
774            Color(0.2, 0.4, 0.6, 0.5)
775        );
776    }
777
778    #[test]
779    fn resolve_foreground_color_keeps_default_color_for_gradient_brush() {
780        let style = SpanStyle {
781            brush: Some(Brush::linear_gradient(vec![
782                Color(0.1, 0.2, 0.3, 1.0),
783                Color(0.9, 0.8, 0.7, 1.0),
784            ])),
785            alpha: Some(0.25),
786            ..Default::default()
787        };
788
789        assert_eq!(
790            style.resolve_foreground_color(Color(1.0, 1.0, 1.0, 1.0)),
791            Color(1.0, 1.0, 1.0, 0.25)
792        );
793    }
794
795    #[test]
796    fn text_style_merge_combines_span_and_paragraph() {
797        let base = TextStyle::new(
798            SpanStyle {
799                font_family: Some(FontFamily::SansSerif),
800                ..Default::default()
801            },
802            ParagraphStyle {
803                text_direction: TextDirection::Ltr,
804                ..Default::default()
805            },
806        );
807        let incoming = TextStyle::new(
808            SpanStyle {
809                letter_spacing: TextUnit::Em(0.2),
810                ..Default::default()
811            },
812            ParagraphStyle {
813                line_height: TextUnit::Sp(22.0),
814                ..Default::default()
815            },
816        );
817
818        let merged = base.merge(&incoming);
819        assert_eq!(merged.span_style.font_family, Some(FontFamily::SansSerif));
820        assert_eq!(merged.span_style.letter_spacing, TextUnit::Em(0.2));
821        assert_eq!(merged.paragraph_style.text_direction, TextDirection::Ltr);
822        assert_eq!(merged.paragraph_style.line_height, TextUnit::Sp(22.0));
823    }
824
825    #[test]
826    fn text_style_from_and_to_style_helpers_work() {
827        let span_style = SpanStyle {
828            font_size: TextUnit::Sp(12.0),
829            ..Default::default()
830        };
831        let from_span = TextStyle::from_span_style(span_style.clone());
832        assert_eq!(from_span.to_span_style(), span_style);
833
834        let paragraph_style = ParagraphStyle {
835            text_direction: TextDirection::Rtl,
836            ..Default::default()
837        };
838        let from_paragraph = TextStyle::from_paragraph_style(paragraph_style.clone());
839        assert_eq!(from_paragraph.to_paragraph_style(), paragraph_style);
840    }
841
842    #[test]
843    fn text_style_plus_matches_merge() {
844        let base = TextStyle::from_span_style(SpanStyle {
845            font_size: TextUnit::Sp(10.0),
846            ..Default::default()
847        });
848        let incoming = TextStyle::from_paragraph_style(ParagraphStyle {
849            text_direction: TextDirection::Ltr,
850            ..Default::default()
851        });
852        assert_eq!(base.plus(&incoming), base.merge(&incoming));
853    }
854
855    #[test]
856    fn text_style_platform_style_helpers_roundtrip() {
857        let style = TextStyle::default().with_platform_style(Some(PlatformTextStyle {
858            span_style: Some(PlatformSpanStyle),
859            paragraph_style: Some(PlatformParagraphStyle {
860                include_font_padding: Some(false),
861                shaping: Some(TextShaping::Basic),
862            }),
863        }));
864        assert_eq!(
865            style.platform_style(),
866            Some(PlatformTextStyle {
867                span_style: Some(PlatformSpanStyle),
868                paragraph_style: Some(PlatformParagraphStyle {
869                    include_font_padding: Some(false),
870                    shaping: Some(TextShaping::Basic),
871                }),
872            })
873        );
874    }
875
876    #[test]
877    fn measurement_hash_changes_when_measurement_attributes_change() {
878        let style_a = TextStyle::default();
879        let style_b = TextStyle::new(
880            SpanStyle {
881                font_family: Some(FontFamily::SansSerif),
882                ..Default::default()
883            },
884            ParagraphStyle {
885                text_direction: TextDirection::Rtl,
886                ..Default::default()
887            },
888        );
889
890        assert_ne!(style_a.measurement_hash(), style_b.measurement_hash());
891    }
892
893    #[test]
894    fn measurement_hash_includes_platform_style() {
895        let style_a = TextStyle::default();
896        let style_b = TextStyle::new(
897            SpanStyle {
898                platform_style: Some(PlatformSpanStyle),
899                ..Default::default()
900            },
901            ParagraphStyle::default(),
902        );
903        assert_ne!(style_a.measurement_hash(), style_b.measurement_hash());
904    }
905
906    #[test]
907    fn measurement_hash_includes_platform_paragraph_shaping() {
908        let style_a = TextStyle::default();
909        let style_b = TextStyle::from_paragraph_style(ParagraphStyle {
910            platform_style: Some(PlatformParagraphStyle {
911                include_font_padding: None,
912                shaping: Some(TextShaping::Basic),
913            }),
914            ..Default::default()
915        });
916        assert_ne!(style_a.measurement_hash(), style_b.measurement_hash());
917    }
918
919    #[test]
920    fn span_style_render_hash_changes_for_visual_attributes() {
921        let plain = SpanStyle::default();
922        let decorated = SpanStyle {
923            shadow: Some(Shadow {
924                color: Color(1.0, 0.0, 0.0, 0.5),
925                offset: crate::modifier::Point::new(2.0, 3.0),
926                blur_radius: 4.0,
927            }),
928            draw_style: Some(TextDrawStyle::Stroke { width: 2.0 }),
929            ..Default::default()
930        };
931
932        assert_ne!(plain.render_hash(), decorated.render_hash());
933    }
934
935    #[test]
936    fn paragraph_style_render_hash_changes_for_paragraph_attributes() {
937        let base = ParagraphStyle::default();
938        let aligned = ParagraphStyle {
939            text_align: TextAlign::Center,
940            text_direction: TextDirection::Rtl,
941            ..Default::default()
942        };
943
944        assert_ne!(base.render_hash(), aligned.render_hash());
945    }
946
947    #[test]
948    fn text_style_render_hash_includes_visual_attributes() {
949        let base = TextStyle::default();
950        let tinted = TextStyle::from_span_style(SpanStyle {
951            color: Some(Color(0.1, 0.2, 0.3, 1.0)),
952            background: Some(Color(0.9, 0.8, 0.7, 1.0)),
953            ..Default::default()
954        });
955
956        assert_ne!(base.render_hash(), tinted.render_hash());
957    }
958}