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