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