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