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