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