Skip to main content

cranpose_ui/text/
style.rs

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