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