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    /// Hash covering everything that affects rasterized output: the
434    /// measurement inputs plus the visual-only properties
435    /// (color/brush/alpha, decorations, shadow, draw style).
436    pub fn raster_hash(&self) -> u64 {
437        let mut hasher = default::new();
438        self.measurement_hash().hash(&mut hasher);
439        let span = &self.span_style;
440        if let Some(color) = span.color {
441            color.0.to_bits().hash(&mut hasher);
442            color.1.to_bits().hash(&mut hasher);
443            color.2.to_bits().hash(&mut hasher);
444            color.3.to_bits().hash(&mut hasher);
445        }
446        if let Some(brush) = &span.brush {
447            format_brush_bits(brush, &mut hasher);
448        }
449        if let Some(alpha) = span.alpha {
450            alpha.to_bits().hash(&mut hasher);
451        }
452        span.text_decoration.hash(&mut hasher);
453        if let Some(shadow) = &span.shadow {
454            shadow.color.0.to_bits().hash(&mut hasher);
455            shadow.color.1.to_bits().hash(&mut hasher);
456            shadow.color.2.to_bits().hash(&mut hasher);
457            shadow.color.3.to_bits().hash(&mut hasher);
458            shadow.offset.x.to_bits().hash(&mut hasher);
459            shadow.offset.y.to_bits().hash(&mut hasher);
460            shadow.blur_radius.to_bits().hash(&mut hasher);
461        }
462        if let Some(draw_style) = &span.draw_style {
463            format!("{draw_style:?}").hash(&mut hasher);
464        }
465        hasher.finish()
466    }
467
468    pub fn render_hash(&self) -> u64 {
469        let mut hasher = default::new();
470        hash_span_style(&self.span_style, &mut hasher);
471        hash_paragraph_style(&self.paragraph_style, &mut hasher);
472        hasher.finish()
473    }
474}
475
476fn merge_foreground_style(
477    current: &SpanStyle,
478    incoming: &SpanStyle,
479) -> (Option<Color>, Option<Brush>) {
480    if let Some(brush) = incoming.brush.clone() {
481        return (None, Some(brush));
482    }
483    if let Some(color) = incoming.color {
484        return (Some(color), None);
485    }
486    (current.color, current.brush.clone())
487}
488
489fn solid_brush_color(brush: Option<&Brush>) -> Option<Color> {
490    match brush {
491        Some(Brush::Solid(color)) => Some(*color),
492        _ => None,
493    }
494}
495
496fn create_platform_text_style(
497    explicit: Option<PlatformTextStyle>,
498    span_style: Option<PlatformSpanStyle>,
499    paragraph_style: Option<PlatformParagraphStyle>,
500) -> Option<PlatformTextStyle> {
501    let explicit_span = explicit.and_then(|style| style.span_style);
502    let explicit_paragraph = explicit.and_then(|style| style.paragraph_style);
503    let span = span_style.or(explicit_span);
504    let paragraph = paragraph_style.or(explicit_paragraph);
505    if span.is_none() && paragraph.is_none() {
506        None
507    } else {
508        Some(PlatformTextStyle {
509            span_style: span,
510            paragraph_style: paragraph,
511        })
512    }
513}
514
515fn merge_text_unit(current: TextUnit, incoming: TextUnit) -> TextUnit {
516    if matches!(incoming, TextUnit::Unspecified) {
517        current
518    } else {
519        incoming
520    }
521}
522
523fn merge_text_align(current: TextAlign, incoming: TextAlign) -> TextAlign {
524    if matches!(incoming, TextAlign::Unspecified) {
525        current
526    } else {
527        incoming
528    }
529}
530
531fn merge_text_direction(current: TextDirection, incoming: TextDirection) -> TextDirection {
532    if matches!(incoming, TextDirection::Unspecified) {
533        current
534    } else {
535        incoming
536    }
537}
538
539fn merge_line_break(current: LineBreak, incoming: LineBreak) -> LineBreak {
540    if matches!(incoming, LineBreak::Unspecified) {
541        current
542    } else {
543        incoming
544    }
545}
546
547fn merge_hyphens(current: Hyphens, incoming: Hyphens) -> Hyphens {
548    if matches!(incoming, Hyphens::Unspecified) {
549        current
550    } else {
551        incoming
552    }
553}
554
555fn hash_f32_bits<H: Hasher>(value: f32, state: &mut H) {
556    value.to_bits().hash(state);
557}
558
559fn hash_option_color<H: Hasher>(color: &Option<Color>, state: &mut H) {
560    match color {
561        Some(color) => {
562            1u8.hash(state);
563            color.render_hash().hash(state);
564        }
565        None => 0u8.hash(state),
566    }
567}
568
569fn hash_option_brush<H: Hasher>(brush: &Option<Brush>, state: &mut H) {
570    match brush {
571        Some(brush) => {
572            1u8.hash(state);
573            brush.render_hash().hash(state);
574        }
575        None => 0u8.hash(state),
576    }
577}
578
579fn hash_option_alpha<H: Hasher>(alpha: &Option<f32>, state: &mut H) {
580    match alpha {
581        Some(alpha) => {
582            1u8.hash(state);
583            hash_f32_bits(*alpha, state);
584        }
585        None => 0u8.hash(state),
586    }
587}
588
589fn format_brush_bits<H: Hasher>(brush: &cranpose_ui_graphics::Brush, hasher: &mut H) {
590    // Structural hash over the brush's numeric content; discriminant first.
591    std::mem::discriminant(brush).hash(hasher);
592    let debug = format!("{brush:?}");
593    debug.hash(hasher);
594}
595
596fn hash_text_unit<H: Hasher>(unit: TextUnit, state: &mut H) {
597    match unit {
598        TextUnit::Unspecified => 0u8.hash(state),
599        TextUnit::Sp(value) => {
600            1u8.hash(state);
601            hash_f32_bits(value, state);
602        }
603        TextUnit::Em(value) => {
604            2u8.hash(state);
605            hash_f32_bits(value, state);
606        }
607    }
608}
609
610fn hash_option_baseline_shift<H: Hasher>(shift: &Option<BaselineShift>, state: &mut H) {
611    match shift {
612        Some(shift) => {
613            1u8.hash(state);
614            hash_f32_bits(shift.0, state);
615        }
616        None => 0u8.hash(state),
617    }
618}
619
620fn hash_option_geometric_transform<H: Hasher>(
621    transform: &Option<TextGeometricTransform>,
622    state: &mut H,
623) {
624    match transform {
625        Some(transform) => {
626            1u8.hash(state);
627            hash_f32_bits(transform.scale_x, state);
628            hash_f32_bits(transform.skew_x, state);
629        }
630        None => 0u8.hash(state),
631    }
632}
633
634fn hash_option_text_indent<H: Hasher>(indent: &Option<TextIndent>, state: &mut H) {
635    match indent {
636        Some(indent) => {
637            1u8.hash(state);
638            hash_text_unit(indent.first_line, state);
639            hash_text_unit(indent.rest_line, state);
640        }
641        None => 0u8.hash(state),
642    }
643}
644
645fn hash_option_shadow<H: Hasher>(shadow: &Option<Shadow>, state: &mut H) {
646    match shadow {
647        Some(shadow) => {
648            1u8.hash(state);
649            shadow.color.render_hash().hash(state);
650            hash_f32_bits(shadow.offset.x, state);
651            hash_f32_bits(shadow.offset.y, state);
652            hash_f32_bits(shadow.blur_radius, state);
653        }
654        None => 0u8.hash(state),
655    }
656}
657
658fn hash_option_text_draw_style<H: Hasher>(draw_style: &Option<TextDrawStyle>, state: &mut H) {
659    match draw_style {
660        Some(TextDrawStyle::Fill) => {
661            1u8.hash(state);
662            0u8.hash(state);
663        }
664        Some(TextDrawStyle::Stroke { width }) => {
665            1u8.hash(state);
666            1u8.hash(state);
667            hash_f32_bits(*width, state);
668        }
669        None => 0u8.hash(state),
670    }
671}
672
673fn hash_span_style<H: Hasher>(span: &SpanStyle, state: &mut H) {
674    hash_option_color(&span.color, state);
675    hash_option_brush(&span.brush, state);
676    hash_option_alpha(&span.alpha, state);
677    hash_text_unit(span.font_size, state);
678    span.font_weight.hash(state);
679    span.font_style.hash(state);
680    span.font_synthesis.hash(state);
681    span.font_family.hash(state);
682    span.font_feature_settings.hash(state);
683    hash_text_unit(span.letter_spacing, state);
684    hash_option_baseline_shift(&span.baseline_shift, state);
685    hash_option_geometric_transform(&span.text_geometric_transform, state);
686    span.locale_list.hash(state);
687    hash_option_color(&span.background, state);
688    span.text_decoration.hash(state);
689    hash_option_shadow(&span.shadow, state);
690    span.platform_style.hash(state);
691    hash_option_text_draw_style(&span.draw_style, state);
692}
693
694fn hash_paragraph_style<H: Hasher>(paragraph: &ParagraphStyle, state: &mut H) {
695    paragraph.text_align.hash(state);
696    paragraph.text_direction.hash(state);
697    hash_text_unit(paragraph.line_height, state);
698    hash_option_text_indent(&paragraph.text_indent, state);
699    paragraph.platform_style.hash(state);
700    paragraph.line_height_style.hash(state);
701    paragraph.line_break.hash(state);
702    paragraph.hyphens.hash(state);
703    paragraph.text_motion.hash(state);
704}
705
706#[cfg(test)]
707mod tests {
708    use super::*;
709    use crate::modifier::Brush;
710    use crate::text::{FontFamily, TextDirection};
711
712    #[test]
713    fn baseline_shift_reports_specified() {
714        assert!(BaselineShift::SUPERSCRIPT.is_specified());
715        assert!(!BaselineShift::UNSPECIFIED.is_specified());
716    }
717
718    #[test]
719    fn locale_list_parses_language_tags() {
720        let locale_list = LocaleList::from_language_tags("en-US, ar-EG, ja-JP");
721        assert_eq!(locale_list.locales(), &["en-US", "ar-EG", "ja-JP"]);
722    }
723
724    #[test]
725    fn span_style_merge_prefers_incoming_specified_values() {
726        let base = SpanStyle {
727            font_size: TextUnit::Sp(14.0),
728            font_family: Some(FontFamily::Serif),
729            ..Default::default()
730        };
731        let incoming = SpanStyle {
732            font_size: TextUnit::Unspecified,
733            letter_spacing: TextUnit::Em(0.1),
734            ..Default::default()
735        };
736
737        let merged = base.merge(&incoming);
738        assert_eq!(merged.font_size, TextUnit::Sp(14.0));
739        assert_eq!(merged.letter_spacing, TextUnit::Em(0.1));
740        assert_eq!(merged.font_family, Some(FontFamily::Serif));
741    }
742
743    #[test]
744    fn span_style_merge_switches_foreground_kind() {
745        let base = SpanStyle {
746            color: Some(Color(1.0, 0.0, 0.0, 1.0)),
747            ..Default::default()
748        };
749        let incoming = SpanStyle {
750            brush: Some(Brush::solid(Color(0.0, 1.0, 0.0, 1.0))),
751            ..Default::default()
752        };
753
754        let merged = base.merge(&incoming);
755        assert_eq!(merged.color, None);
756        assert_eq!(merged.brush, incoming.brush);
757    }
758
759    #[test]
760    fn span_style_plus_matches_merge() {
761        let base = SpanStyle {
762            font_size: TextUnit::Sp(12.0),
763            ..Default::default()
764        };
765        let incoming = SpanStyle {
766            letter_spacing: TextUnit::Em(0.2),
767            ..Default::default()
768        };
769        assert_eq!(base.plus(&incoming), base.merge(&incoming));
770    }
771
772    #[test]
773    fn paragraph_style_merge_prefers_specified_values() {
774        let base = ParagraphStyle {
775            text_direction: TextDirection::Ltr,
776            line_height: TextUnit::Sp(18.0),
777            ..Default::default()
778        };
779        let incoming = ParagraphStyle {
780            text_direction: TextDirection::Unspecified,
781            line_height: TextUnit::Em(1.4),
782            ..Default::default()
783        };
784
785        let merged = base.merge(&incoming);
786        assert_eq!(merged.text_direction, TextDirection::Ltr);
787        assert_eq!(merged.line_height, TextUnit::Em(1.4));
788    }
789
790    #[test]
791    fn paragraph_style_plus_matches_merge() {
792        let base = ParagraphStyle {
793            text_align: TextAlign::Start,
794            ..Default::default()
795        };
796        let incoming = ParagraphStyle {
797            text_direction: TextDirection::Rtl,
798            ..Default::default()
799        };
800        assert_eq!(base.plus(&incoming), base.merge(&incoming));
801    }
802
803    #[test]
804    fn resolve_font_size_uses_specified_value() {
805        let style = TextStyle::new(
806            SpanStyle {
807                font_size: TextUnit::Sp(18.0),
808                ..Default::default()
809            },
810            ParagraphStyle::default(),
811        );
812        assert_eq!(style.resolve_font_size(14.0), 18.0);
813    }
814
815    #[test]
816    fn resolve_font_size_handles_em_units() {
817        let style = TextStyle::new(
818            SpanStyle {
819                font_size: TextUnit::Em(1.5),
820                ..Default::default()
821            },
822            ParagraphStyle::default(),
823        );
824        assert_eq!(style.resolve_font_size(16.0), 24.0);
825    }
826
827    #[test]
828    fn resolve_line_height_uses_style_value() {
829        let style = TextStyle::new(
830            SpanStyle {
831                font_size: TextUnit::Sp(20.0),
832                ..Default::default()
833            },
834            ParagraphStyle {
835                line_height: TextUnit::Em(1.2),
836                ..Default::default()
837            },
838        );
839        assert_eq!(style.resolve_line_height(14.0, 18.0), 24.0);
840    }
841
842    #[test]
843    fn resolve_foreground_color_supports_solid_brush_with_alpha() {
844        let style = SpanStyle {
845            brush: Some(Brush::solid(Color(0.2, 0.4, 0.6, 1.0))),
846            alpha: Some(0.5),
847            ..Default::default()
848        };
849        assert_eq!(
850            style.resolve_foreground_color(Color(1.0, 1.0, 1.0, 1.0)),
851            Color(0.2, 0.4, 0.6, 0.5)
852        );
853    }
854
855    #[test]
856    fn resolve_foreground_color_keeps_default_color_for_gradient_brush() {
857        let style = SpanStyle {
858            brush: Some(Brush::linear_gradient(vec![
859                Color(0.1, 0.2, 0.3, 1.0),
860                Color(0.9, 0.8, 0.7, 1.0),
861            ])),
862            alpha: Some(0.25),
863            ..Default::default()
864        };
865
866        assert_eq!(
867            style.resolve_foreground_color(Color(1.0, 1.0, 1.0, 1.0)),
868            Color(1.0, 1.0, 1.0, 0.25)
869        );
870    }
871
872    #[test]
873    fn text_style_merge_combines_span_and_paragraph() {
874        let base = TextStyle::new(
875            SpanStyle {
876                font_family: Some(FontFamily::SansSerif),
877                ..Default::default()
878            },
879            ParagraphStyle {
880                text_direction: TextDirection::Ltr,
881                ..Default::default()
882            },
883        );
884        let incoming = TextStyle::new(
885            SpanStyle {
886                letter_spacing: TextUnit::Em(0.2),
887                ..Default::default()
888            },
889            ParagraphStyle {
890                line_height: TextUnit::Sp(22.0),
891                ..Default::default()
892            },
893        );
894
895        let merged = base.merge(&incoming);
896        assert_eq!(merged.span_style.font_family, Some(FontFamily::SansSerif));
897        assert_eq!(merged.span_style.letter_spacing, TextUnit::Em(0.2));
898        assert_eq!(merged.paragraph_style.text_direction, TextDirection::Ltr);
899        assert_eq!(merged.paragraph_style.line_height, TextUnit::Sp(22.0));
900    }
901
902    #[test]
903    fn text_style_from_and_to_style_helpers_work() {
904        let span_style = SpanStyle {
905            font_size: TextUnit::Sp(12.0),
906            ..Default::default()
907        };
908        let from_span = TextStyle::from_span_style(span_style.clone());
909        assert_eq!(from_span.to_span_style(), span_style);
910
911        let paragraph_style = ParagraphStyle {
912            text_direction: TextDirection::Rtl,
913            ..Default::default()
914        };
915        let from_paragraph = TextStyle::from_paragraph_style(paragraph_style.clone());
916        assert_eq!(from_paragraph.to_paragraph_style(), paragraph_style);
917    }
918
919    #[test]
920    fn text_style_plus_matches_merge() {
921        let base = TextStyle::from_span_style(SpanStyle {
922            font_size: TextUnit::Sp(10.0),
923            ..Default::default()
924        });
925        let incoming = TextStyle::from_paragraph_style(ParagraphStyle {
926            text_direction: TextDirection::Ltr,
927            ..Default::default()
928        });
929        assert_eq!(base.plus(&incoming), base.merge(&incoming));
930    }
931
932    #[test]
933    fn text_style_platform_style_helpers_roundtrip() {
934        let style = TextStyle::default().with_platform_style(Some(PlatformTextStyle {
935            span_style: Some(PlatformSpanStyle),
936            paragraph_style: Some(PlatformParagraphStyle {
937                include_font_padding: Some(false),
938                shaping: Some(TextShaping::Basic),
939            }),
940        }));
941        assert_eq!(
942            style.platform_style(),
943            Some(PlatformTextStyle {
944                span_style: Some(PlatformSpanStyle),
945                paragraph_style: Some(PlatformParagraphStyle {
946                    include_font_padding: Some(false),
947                    shaping: Some(TextShaping::Basic),
948                }),
949            })
950        );
951    }
952
953    #[test]
954    fn measurement_hash_changes_when_measurement_attributes_change() {
955        let style_a = TextStyle::default();
956        let style_b = TextStyle::new(
957            SpanStyle {
958                font_family: Some(FontFamily::SansSerif),
959                ..Default::default()
960            },
961            ParagraphStyle {
962                text_direction: TextDirection::Rtl,
963                ..Default::default()
964            },
965        );
966
967        assert_ne!(style_a.measurement_hash(), style_b.measurement_hash());
968    }
969
970    #[test]
971    fn measurement_hash_includes_platform_style() {
972        let style_a = TextStyle::default();
973        let style_b = TextStyle::new(
974            SpanStyle {
975                platform_style: Some(PlatformSpanStyle),
976                ..Default::default()
977            },
978            ParagraphStyle::default(),
979        );
980        assert_ne!(style_a.measurement_hash(), style_b.measurement_hash());
981    }
982
983    #[test]
984    fn measurement_hash_includes_platform_paragraph_shaping() {
985        let style_a = TextStyle::default();
986        let style_b = TextStyle::from_paragraph_style(ParagraphStyle {
987            platform_style: Some(PlatformParagraphStyle {
988                include_font_padding: None,
989                shaping: Some(TextShaping::Basic),
990            }),
991            ..Default::default()
992        });
993        assert_ne!(style_a.measurement_hash(), style_b.measurement_hash());
994    }
995
996    #[test]
997    fn span_style_render_hash_changes_for_visual_attributes() {
998        let plain = SpanStyle::default();
999        let decorated = SpanStyle {
1000            shadow: Some(Shadow {
1001                color: Color(1.0, 0.0, 0.0, 0.5),
1002                offset: crate::modifier::Point::new(2.0, 3.0),
1003                blur_radius: 4.0,
1004            }),
1005            draw_style: Some(TextDrawStyle::Stroke { width: 2.0 }),
1006            ..Default::default()
1007        };
1008
1009        assert_ne!(plain.render_hash(), decorated.render_hash());
1010    }
1011
1012    #[test]
1013    fn paragraph_style_render_hash_changes_for_paragraph_attributes() {
1014        let base = ParagraphStyle::default();
1015        let aligned = ParagraphStyle {
1016            text_align: TextAlign::Center,
1017            text_direction: TextDirection::Rtl,
1018            ..Default::default()
1019        };
1020
1021        assert_ne!(base.render_hash(), aligned.render_hash());
1022    }
1023
1024    #[test]
1025    fn text_style_render_hash_includes_visual_attributes() {
1026        let base = TextStyle::default();
1027        let tinted = TextStyle::from_span_style(SpanStyle {
1028            color: Some(Color(0.1, 0.2, 0.3, 1.0)),
1029            background: Some(Color(0.9, 0.8, 0.7, 1.0)),
1030            ..Default::default()
1031        });
1032
1033        assert_ne!(base.render_hash(), tinted.render_hash());
1034    }
1035}