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