Skip to main content

cranpose_ui/text/
style.rs

1use std::hash::{Hash, Hasher};
2
3use cranpose_ui_graphics::{FxHasher, RenderHash};
4/// The line-height policy lives with the drawing-side text types, because a
5/// [`DrawScope`](cranpose_ui_graphics::DrawScope) has to be able to name it too:
6/// a run drawn through a canvas and a `Text` composable on the same screen must
7/// resolve their line boxes by the same rule, and the only style a draw scope
8/// can carry is the flat one from `cranpose-ui-graphics`.
9pub use cranpose_ui_graphics::{
10    LineHeightAlignment, LineHeightMode, LineHeightStyle, LineHeightTrim,
11};
12
13use super::{
14    decoration::{Shadow, TextDecoration},
15    font::{FontFamily, FontStyle, FontSynthesis, FontWeight},
16    paragraph::{Hyphens, LineBreak, TextAlign, TextDirection, TextIndent},
17    unit::TextUnit,
18};
19use crate::modifier::{Brush, Color};
20
21#[derive(Clone, Copy, Debug, PartialEq)]
22pub struct BaselineShift(pub f32);
23
24impl BaselineShift {
25    pub const SUPERSCRIPT: Self = Self(0.5);
26    pub const SUBSCRIPT: Self = Self(-0.5);
27    pub const NONE: Self = Self(0.0);
28    pub const UNSPECIFIED: Self = Self(f32::NAN);
29
30    pub fn is_specified(self) -> bool {
31        !self.0.is_nan()
32    }
33}
34
35#[derive(Clone, Copy, Debug, PartialEq)]
36pub struct TextGeometricTransform {
37    pub scale_x: f32,
38    pub skew_x: f32,
39}
40
41impl Default for TextGeometricTransform {
42    fn default() -> Self {
43        Self {
44            scale_x: 1.0,
45            skew_x: 0.0,
46        }
47    }
48}
49
50#[derive(Clone, Debug, PartialEq, Eq, Hash, Default)]
51pub struct LocaleList {
52    locales: Vec<String>,
53}
54
55impl LocaleList {
56    pub fn new(locales: Vec<String>) -> Self {
57        Self { locales }
58    }
59
60    pub fn from_language_tags(tags: &str) -> Self {
61        let locales = tags
62            .split(',')
63            .map(str::trim)
64            .filter(|tag| !tag.is_empty())
65            .map(ToString::to_string)
66            .collect();
67        Self { locales }
68    }
69
70    pub fn locales(&self) -> &[String] {
71        &self.locales
72    }
73
74    pub fn is_empty(&self) -> bool {
75        self.locales.is_empty()
76    }
77}
78
79#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
80pub enum TextMotion {
81    #[default]
82    Static,
83    Animated,
84}
85
86#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
87pub struct PlatformSpanStyle;
88
89#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
90pub enum TextShaping {
91    Basic,
92    Advanced,
93}
94
95#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
96pub struct PlatformParagraphStyle {
97    pub include_font_padding: Option<bool>,
98    pub shaping: Option<TextShaping>,
99}
100
101#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
102pub struct PlatformTextStyle {
103    pub span_style: Option<PlatformSpanStyle>,
104    pub paragraph_style: Option<PlatformParagraphStyle>,
105}
106
107#[derive(Clone, Copy, Debug, PartialEq, Default)]
108pub enum TextDrawStyle {
109    #[default]
110    Fill,
111    Stroke {
112        width: f32,
113    },
114}
115
116#[derive(Clone, Debug, PartialEq)]
117pub struct SpanStyle {
118    pub color: Option<Color>,
119    pub brush: Option<Brush>,
120    pub alpha: Option<f32>,
121    pub font_size: TextUnit,
122    pub font_weight: Option<FontWeight>,
123    pub font_style: Option<FontStyle>,
124    pub font_synthesis: Option<FontSynthesis>,
125    pub font_family: Option<FontFamily>,
126    pub font_feature_settings: Option<String>,
127    pub letter_spacing: TextUnit,
128    pub baseline_shift: Option<BaselineShift>,
129    pub text_geometric_transform: Option<TextGeometricTransform>,
130    pub locale_list: Option<LocaleList>,
131    pub background: Option<Color>,
132    pub text_decoration: Option<TextDecoration>,
133    pub shadow: Option<Shadow>,
134    pub platform_style: Option<PlatformSpanStyle>,
135    pub draw_style: Option<TextDrawStyle>,
136}
137
138impl Default for SpanStyle {
139    fn default() -> Self {
140        Self {
141            color: None,
142            brush: None,
143            alpha: None,
144            font_size: TextUnit::Unspecified,
145            font_weight: None,
146            font_style: None,
147            font_synthesis: None,
148            font_family: None,
149            font_feature_settings: None,
150            letter_spacing: TextUnit::Unspecified,
151            baseline_shift: None,
152            text_geometric_transform: None,
153            locale_list: None,
154            background: None,
155            text_decoration: None,
156            shadow: None,
157            platform_style: None,
158            draw_style: None,
159        }
160    }
161}
162
163impl SpanStyle {
164    pub fn merge(&self, other: &SpanStyle) -> SpanStyle {
165        let (merged_color, merged_brush) = merge_foreground_style(self, other);
166        SpanStyle {
167            color: merged_color,
168            brush: merged_brush,
169            alpha: other.alpha.or(self.alpha),
170            font_size: merge_text_unit(self.font_size, other.font_size),
171            font_weight: other.font_weight.or(self.font_weight),
172            font_style: other.font_style.or(self.font_style),
173            font_synthesis: other.font_synthesis.or(self.font_synthesis),
174            font_family: other
175                .font_family
176                .clone()
177                .or_else(|| self.font_family.clone()),
178            font_feature_settings: other
179                .font_feature_settings
180                .clone()
181                .or_else(|| self.font_feature_settings.clone()),
182            letter_spacing: merge_text_unit(self.letter_spacing, other.letter_spacing),
183            baseline_shift: other.baseline_shift.or(self.baseline_shift),
184            text_geometric_transform: other
185                .text_geometric_transform
186                .or(self.text_geometric_transform),
187            locale_list: other
188                .locale_list
189                .clone()
190                .or_else(|| self.locale_list.clone()),
191            background: other.background.or(self.background),
192            text_decoration: other.text_decoration.or(self.text_decoration),
193            shadow: other.shadow.or(self.shadow),
194            platform_style: other.platform_style.or(self.platform_style),
195            draw_style: other.draw_style.or(self.draw_style),
196        }
197    }
198
199    pub fn plus(&self, other: &SpanStyle) -> SpanStyle {
200        self.merge(other)
201    }
202
203    pub fn resolve_font_size(&self, default_size: f32) -> f32 {
204        let fallback = if default_size.is_finite() && default_size > 0.0 {
205            default_size
206        } else {
207            14.0
208        };
209        match self.font_size {
210            TextUnit::Sp(value) if value.is_finite() && value > 0.0 => value,
211            TextUnit::Em(value) if value.is_finite() && value > 0.0 => value * fallback,
212            _ => fallback,
213        }
214    }
215
216    pub fn resolve_foreground_color(&self, default_color: Color) -> Color {
217        let mut color = self
218            .color
219            .or_else(|| solid_brush_color(self.brush.as_ref()))
220            .unwrap_or(default_color);
221        if let Some(alpha) = self.alpha {
222            color.3 *= alpha.clamp(0.0, 1.0);
223        }
224        color
225    }
226
227    pub fn render_hash(&self) -> u64 {
228        let mut hasher = FxHasher::default();
229        hash_span_style(self, &mut hasher);
230        hasher.finish()
231    }
232}
233
234#[derive(Clone, Debug, PartialEq)]
235pub struct ParagraphStyle {
236    pub text_align: TextAlign,
237    pub text_direction: TextDirection,
238    pub line_height: TextUnit,
239    pub text_indent: Option<TextIndent>,
240    pub platform_style: Option<PlatformParagraphStyle>,
241    pub line_height_style: Option<LineHeightStyle>,
242    pub line_break: LineBreak,
243    pub hyphens: Hyphens,
244    pub text_motion: Option<TextMotion>,
245}
246
247impl Default for ParagraphStyle {
248    fn default() -> Self {
249        Self {
250            text_align: TextAlign::Unspecified,
251            text_direction: TextDirection::Unspecified,
252            line_height: TextUnit::Unspecified,
253            text_indent: None,
254            platform_style: None,
255            line_height_style: None,
256            line_break: LineBreak::Unspecified,
257            hyphens: Hyphens::Unspecified,
258            text_motion: None,
259        }
260    }
261}
262
263impl ParagraphStyle {
264    pub fn merge(&self, other: &ParagraphStyle) -> ParagraphStyle {
265        ParagraphStyle {
266            text_align: merge_text_align(self.text_align, other.text_align),
267            text_direction: merge_text_direction(self.text_direction, other.text_direction),
268            line_height: merge_text_unit(self.line_height, other.line_height),
269            text_indent: other.text_indent.or(self.text_indent),
270            platform_style: other.platform_style.or(self.platform_style),
271            line_height_style: other.line_height_style.or(self.line_height_style),
272            line_break: merge_line_break(self.line_break, other.line_break),
273            hyphens: merge_hyphens(self.hyphens, other.hyphens),
274            text_motion: other.text_motion.or(self.text_motion),
275        }
276    }
277
278    pub fn plus(&self, other: &ParagraphStyle) -> ParagraphStyle {
279        self.merge(other)
280    }
281
282    pub fn render_hash(&self) -> u64 {
283        let mut hasher = FxHasher::default();
284        hash_paragraph_style(self, &mut hasher);
285        hasher.finish()
286    }
287}
288
289#[derive(Clone, Debug, PartialEq, Default)]
290pub struct TextStyle {
291    pub span_style: SpanStyle,
292    pub paragraph_style: ParagraphStyle,
293}
294
295impl TextStyle {
296    pub fn new(span_style: SpanStyle, paragraph_style: ParagraphStyle) -> Self {
297        Self {
298            span_style,
299            paragraph_style,
300        }
301    }
302
303    pub fn from_span_style(span_style: SpanStyle) -> Self {
304        Self::new(span_style, ParagraphStyle::default())
305    }
306
307    pub fn from_paragraph_style(paragraph_style: ParagraphStyle) -> Self {
308        Self::new(SpanStyle::default(), paragraph_style)
309    }
310
311    pub fn merge(&self, other: &TextStyle) -> TextStyle {
312        TextStyle {
313            span_style: self.span_style.merge(&other.span_style),
314            paragraph_style: self.paragraph_style.merge(&other.paragraph_style),
315        }
316    }
317
318    pub fn plus(&self, other: &TextStyle) -> TextStyle {
319        self.merge(other)
320    }
321
322    pub fn to_span_style(&self) -> SpanStyle {
323        self.span_style.clone()
324    }
325
326    pub fn to_paragraph_style(&self) -> ParagraphStyle {
327        self.paragraph_style.clone()
328    }
329
330    pub fn platform_style(&self) -> Option<PlatformTextStyle> {
331        create_platform_text_style(
332            None,
333            self.span_style.platform_style,
334            self.paragraph_style.platform_style,
335        )
336    }
337
338    pub fn with_platform_style(mut self, platform_style: Option<PlatformTextStyle>) -> Self {
339        self.span_style.platform_style = platform_style.and_then(|style| style.span_style);
340        self.paragraph_style.platform_style =
341            platform_style.and_then(|style| style.paragraph_style);
342        self
343    }
344
345    pub fn resolve_font_size(&self, default_size: f32) -> f32 {
346        self.span_style.resolve_font_size(default_size)
347    }
348
349    pub fn resolve_line_height(&self, default_size: f32, natural_line_height: f32) -> f32 {
350        let fallback = if natural_line_height.is_finite() && natural_line_height > 0.0 {
351            natural_line_height
352        } else {
353            self.resolve_font_size(default_size)
354        };
355        match self.paragraph_style.line_height {
356            TextUnit::Sp(value) if value.is_finite() && value > 0.0 => value,
357            TextUnit::Em(value) if value.is_finite() && value > 0.0 => {
358                value * self.resolve_font_size(default_size)
359            }
360            _ => fallback,
361        }
362    }
363
364    pub fn resolve_letter_spacing(&self, default_size: f32) -> f32 {
365        let font_size = self.resolve_font_size(default_size);
366        match self.span_style.letter_spacing {
367            TextUnit::Sp(value) if value.is_finite() => value,
368            TextUnit::Em(value) if value.is_finite() => value * font_size,
369            _ => 0.0,
370        }
371    }
372
373    pub fn resolve_text_color(&self, default_color: Color) -> Color {
374        self.span_style.resolve_foreground_color(default_color)
375    }
376
377    pub fn measurement_hash(&self) -> u64 {
378        let mut hasher = FxHasher::default();
379        let span = &self.span_style;
380        let paragraph = &self.paragraph_style;
381
382        hash_text_unit(span.font_size, &mut hasher);
383        span.font_weight.hash(&mut hasher);
384        span.font_style.hash(&mut hasher);
385        span.font_synthesis.hash(&mut hasher);
386        span.font_family.hash(&mut hasher);
387        span.font_feature_settings.hash(&mut hasher);
388        hash_text_unit(span.letter_spacing, &mut hasher);
389        hash_option_baseline_shift(&span.baseline_shift, &mut hasher);
390        hash_option_geometric_transform(&span.text_geometric_transform, &mut hasher);
391        span.locale_list.hash(&mut hasher);
392        span.platform_style.hash(&mut hasher);
393
394        paragraph.text_align.hash(&mut hasher);
395        paragraph.text_direction.hash(&mut hasher);
396        hash_text_unit(paragraph.line_height, &mut hasher);
397        hash_option_text_indent(&paragraph.text_indent, &mut hasher);
398        paragraph.platform_style.hash(&mut hasher);
399        paragraph.line_height_style.hash(&mut hasher);
400        paragraph.line_break.hash(&mut hasher);
401        paragraph.hyphens.hash(&mut hasher);
402        paragraph.text_motion.hash(&mut hasher);
403
404        hasher.finish()
405    }
406
407    pub fn render_hash(&self) -> u64 {
408        let mut hasher = FxHasher::default();
409        hash_span_style(&self.span_style, &mut hasher);
410        hash_paragraph_style(&self.paragraph_style, &mut hasher);
411        hasher.finish()
412    }
413}
414
415fn merge_foreground_style(
416    current: &SpanStyle,
417    incoming: &SpanStyle,
418) -> (Option<Color>, Option<Brush>) {
419    if let Some(brush) = incoming.brush.clone() {
420        return (None, Some(brush));
421    }
422    if let Some(color) = incoming.color {
423        return (Some(color), None);
424    }
425    (current.color, current.brush.clone())
426}
427
428fn solid_brush_color(brush: Option<&Brush>) -> Option<Color> {
429    match brush {
430        Some(Brush::Solid(color)) => Some(*color),
431        _ => None,
432    }
433}
434
435fn create_platform_text_style(
436    explicit: Option<PlatformTextStyle>,
437    span_style: Option<PlatformSpanStyle>,
438    paragraph_style: Option<PlatformParagraphStyle>,
439) -> Option<PlatformTextStyle> {
440    let explicit_span = explicit.and_then(|style| style.span_style);
441    let explicit_paragraph = explicit.and_then(|style| style.paragraph_style);
442    let span = span_style.or(explicit_span);
443    let paragraph = paragraph_style.or(explicit_paragraph);
444    if span.is_none() && paragraph.is_none() {
445        None
446    } else {
447        Some(PlatformTextStyle {
448            span_style: span,
449            paragraph_style: paragraph,
450        })
451    }
452}
453
454fn merge_text_unit(current: TextUnit, incoming: TextUnit) -> TextUnit {
455    if matches!(incoming, TextUnit::Unspecified) {
456        current
457    } else {
458        incoming
459    }
460}
461
462fn merge_text_align(current: TextAlign, incoming: TextAlign) -> TextAlign {
463    if matches!(incoming, TextAlign::Unspecified) {
464        current
465    } else {
466        incoming
467    }
468}
469
470fn merge_text_direction(current: TextDirection, incoming: TextDirection) -> TextDirection {
471    if matches!(incoming, TextDirection::Unspecified) {
472        current
473    } else {
474        incoming
475    }
476}
477
478fn merge_line_break(current: LineBreak, incoming: LineBreak) -> LineBreak {
479    if matches!(incoming, LineBreak::Unspecified) {
480        current
481    } else {
482        incoming
483    }
484}
485
486fn merge_hyphens(current: Hyphens, incoming: Hyphens) -> Hyphens {
487    if matches!(incoming, Hyphens::Unspecified) {
488        current
489    } else {
490        incoming
491    }
492}
493
494fn hash_f32_bits<H: Hasher>(value: f32, state: &mut H) {
495    value.to_bits().hash(state);
496}
497
498fn hash_option_color<H: Hasher>(color: &Option<Color>, state: &mut H) {
499    match color {
500        Some(color) => {
501            1u8.hash(state);
502            color.render_hash().hash(state);
503        }
504        None => 0u8.hash(state),
505    }
506}
507
508fn hash_option_brush<H: Hasher>(brush: &Option<Brush>, state: &mut H) {
509    match brush {
510        Some(brush) => {
511            1u8.hash(state);
512            brush.render_hash().hash(state);
513        }
514        None => 0u8.hash(state),
515    }
516}
517
518fn hash_option_alpha<H: Hasher>(alpha: &Option<f32>, state: &mut H) {
519    match alpha {
520        Some(alpha) => {
521            1u8.hash(state);
522            hash_f32_bits(*alpha, state);
523        }
524        None => 0u8.hash(state),
525    }
526}
527
528fn hash_text_unit<H: Hasher>(unit: TextUnit, state: &mut H) {
529    match unit {
530        TextUnit::Unspecified => 0u8.hash(state),
531        TextUnit::Sp(value) => {
532            1u8.hash(state);
533            hash_f32_bits(value, state);
534        }
535        TextUnit::Em(value) => {
536            2u8.hash(state);
537            hash_f32_bits(value, state);
538        }
539    }
540}
541
542fn hash_option_baseline_shift<H: Hasher>(shift: &Option<BaselineShift>, state: &mut H) {
543    match shift {
544        Some(shift) => {
545            1u8.hash(state);
546            hash_f32_bits(shift.0, state);
547        }
548        None => 0u8.hash(state),
549    }
550}
551
552fn hash_option_geometric_transform<H: Hasher>(
553    transform: &Option<TextGeometricTransform>,
554    state: &mut H,
555) {
556    match transform {
557        Some(transform) => {
558            1u8.hash(state);
559            hash_f32_bits(transform.scale_x, state);
560            hash_f32_bits(transform.skew_x, state);
561        }
562        None => 0u8.hash(state),
563    }
564}
565
566fn hash_option_text_indent<H: Hasher>(indent: &Option<TextIndent>, state: &mut H) {
567    match indent {
568        Some(indent) => {
569            1u8.hash(state);
570            hash_text_unit(indent.first_line, state);
571            hash_text_unit(indent.rest_line, state);
572        }
573        None => 0u8.hash(state),
574    }
575}
576
577fn hash_option_shadow<H: Hasher>(shadow: &Option<Shadow>, state: &mut H) {
578    match shadow {
579        Some(shadow) => {
580            1u8.hash(state);
581            shadow.color.render_hash().hash(state);
582            hash_f32_bits(shadow.offset.x, state);
583            hash_f32_bits(shadow.offset.y, state);
584            hash_f32_bits(shadow.blur_radius, state);
585        }
586        None => 0u8.hash(state),
587    }
588}
589
590fn hash_option_text_draw_style<H: Hasher>(draw_style: &Option<TextDrawStyle>, state: &mut H) {
591    match draw_style {
592        Some(TextDrawStyle::Fill) => {
593            1u8.hash(state);
594            0u8.hash(state);
595        }
596        Some(TextDrawStyle::Stroke { width }) => {
597            1u8.hash(state);
598            1u8.hash(state);
599            hash_f32_bits(*width, state);
600        }
601        None => 0u8.hash(state),
602    }
603}
604
605fn hash_span_style<H: Hasher>(span: &SpanStyle, state: &mut H) {
606    hash_option_color(&span.color, state);
607    hash_option_brush(&span.brush, state);
608    hash_option_alpha(&span.alpha, state);
609    hash_text_unit(span.font_size, state);
610    span.font_weight.hash(state);
611    span.font_style.hash(state);
612    span.font_synthesis.hash(state);
613    span.font_family.hash(state);
614    span.font_feature_settings.hash(state);
615    hash_text_unit(span.letter_spacing, state);
616    hash_option_baseline_shift(&span.baseline_shift, state);
617    hash_option_geometric_transform(&span.text_geometric_transform, state);
618    span.locale_list.hash(state);
619    hash_option_color(&span.background, state);
620    span.text_decoration.hash(state);
621    hash_option_shadow(&span.shadow, state);
622    span.platform_style.hash(state);
623    hash_option_text_draw_style(&span.draw_style, state);
624}
625
626fn hash_paragraph_style<H: Hasher>(paragraph: &ParagraphStyle, state: &mut H) {
627    paragraph.text_align.hash(state);
628    paragraph.text_direction.hash(state);
629    hash_text_unit(paragraph.line_height, state);
630    hash_option_text_indent(&paragraph.text_indent, state);
631    paragraph.platform_style.hash(state);
632    paragraph.line_height_style.hash(state);
633    paragraph.line_break.hash(state);
634    paragraph.hyphens.hash(state);
635    paragraph.text_motion.hash(state);
636}
637
638#[cfg(test)]
639#[path = "tests/style_tests.rs"]
640mod tests;