Skip to main content

cranpose_ui/text/
draw_scope_text.rs

1//! Bridge between [`DrawScope`](cranpose_ui_graphics::DrawScope) text and the
2//! framework text stack.
3//!
4//! `cranpose-ui-graphics` sits below fonts, so a draw scope describes text with
5//! the flat [`DrawTextStyle`] value and delegates measurement back up here. This
6//! module owns the single translation from that value into the full
7//! [`TextStyle`] the measurer and the rasterizer both consume — so a string
8//! measured through [`DrawScope::measure_text`](cranpose_ui_graphics::DrawScope::measure_text)
9//! and the same string rasterized by the renderer are described identically,
10//! down to the cache key.
11
12use std::rc::Rc;
13
14use cranpose_ui_graphics::{
15    estimate_text_measurement, DrawTextMeasurer, FontStyle as DrawFontStyle, Size, TextMeasurement,
16    TextStyle as DrawTextStyle,
17};
18
19use super::font::{FontFamily, FontStyle, FontWeight};
20use super::style::{SpanStyle, TextStyle};
21use super::unit::TextUnit;
22
23/// Builds the [`TextStyle`] that describes a draw-scope text run.
24///
25/// Everything a `DrawTextStyle` can say is a span attribute except the line
26/// height, so the paragraph style stays at its defaults — in particular
27/// `text_align` is left unspecified, because a draw scope has already resolved
28/// alignment into the primitive's rect.
29pub fn text_style_for_draw_style(style: &DrawTextStyle) -> TextStyle {
30    let mut span_style = SpanStyle {
31        font_size: TextUnit::Sp(style.resolved_font_size()),
32        font_weight: Some(FontWeight::new(style.font_weight.value())),
33        font_style: Some(match style.font_style {
34            DrawFontStyle::Normal => FontStyle::Normal,
35            // No font in the stack ships an oblique face; the renderer
36            // synthesizes both the same way.
37            DrawFontStyle::Italic | DrawFontStyle::Oblique => FontStyle::Italic,
38        }),
39        ..SpanStyle::default()
40    };
41    if let Some(family) = &style.font_family {
42        span_style.font_family = Some(FontFamily::from_name(family));
43    }
44    let letter_spacing = style.resolved_letter_spacing();
45    if letter_spacing != 0.0 {
46        span_style.letter_spacing = TextUnit::Sp(letter_spacing);
47    }
48
49    let mut text_style = TextStyle::from_span_style(span_style);
50    if let Some(line_height) = style.line_height {
51        if line_height.is_finite() && line_height > 0.0 {
52            text_style.paragraph_style.line_height = TextUnit::Sp(line_height);
53        }
54    }
55    text_style
56}
57
58/// Measures draw-scope text against the app's fonts.
59///
60/// Every call lands in [`super::measure::measure_resolved_text`], backed by the
61/// app context's metrics cache — so measuring an unchanged string every frame
62/// is a hash lookup, not a shaping pass.
63///
64/// "Resolved" is the whole point: a [`DrawTextStyle`] states final sizes, and a
65/// scene lowers a text primitive with `style.resolved_font_size()` untouched,
66/// so the system font scale must not be folded in here. It is applied where an
67/// unresolved size lives instead — the `Text` composable's `Sp` values — and
68/// that path carries the scaled style through to the renderer with it.
69#[derive(Clone, Copy, Debug, Default)]
70pub struct AppContextTextMeasurer;
71
72impl AppContextTextMeasurer {
73    /// A shared measurer to hand to
74    /// [`DrawScopeDefault::with_text_measurer`](cranpose_ui_graphics::DrawScopeDefault::with_text_measurer).
75    pub fn shared() -> Rc<dyn DrawTextMeasurer> {
76        thread_local! {
77            static SHARED: Rc<dyn DrawTextMeasurer> = Rc::new(AppContextTextMeasurer);
78        }
79        SHARED.with(Rc::clone)
80    }
81}
82
83impl DrawTextMeasurer for AppContextTextMeasurer {
84    fn measure_text(&self, text: &str, style: &DrawTextStyle) -> TextMeasurement {
85        // Draw closures normally run inside the app context that owns the
86        // fonts. Tooling that runs one standalone gets the font-free estimate
87        // rather than a panic.
88        if crate::render_state::current_app_context().is_none() {
89            return estimate_text_measurement(text, style);
90        }
91
92        let text_style = text_style_for_draw_style(style);
93        let annotated = super::shared_plain_annotated_string(text);
94        let metrics = super::measure::measure_resolved_text(&annotated, &text_style);
95        let line_height = if metrics.line_height.is_finite() && metrics.line_height > 0.0 {
96            metrics.line_height
97        } else {
98            estimate_text_measurement(text, style).line_height
99        };
100        let first_baseline = super::measure::resolved_first_baseline(&text_style)
101            .unwrap_or_else(|| estimate_text_measurement(text, style).first_baseline);
102
103        if text.is_empty() {
104            return TextMeasurement::empty(line_height, first_baseline);
105        }
106
107        let line_count = metrics.line_count.max(1);
108        TextMeasurement {
109            // Height comes from the line box, not from `metrics.height`: a
110            // measurer is free to report a taller box for `min_lines`, and the
111            // rasterizer only ever advances by `line_height` per line.
112            size: Size::new(metrics.width.max(0.0), line_count as f32 * line_height),
113            line_height,
114            first_baseline,
115            line_count,
116        }
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use cranpose_ui_graphics::{FontWeight as DrawFontWeight, TextAlign, TextVerticalAlign};
124
125    #[test]
126    fn draw_style_maps_onto_span_attributes() {
127        let style = DrawTextStyle::new(23.0)
128            .with_font_family("Fira Sans")
129            .with_weight(DrawFontWeight::BOLD)
130            .with_style(DrawFontStyle::Italic)
131            .with_letter_spacing(1.5)
132            .with_line_height(30.0);
133        let mapped = text_style_for_draw_style(&style);
134
135        assert_eq!(mapped.span_style.font_size, TextUnit::Sp(23.0));
136        assert_eq!(mapped.span_style.font_weight, Some(FontWeight::BOLD));
137        assert_eq!(mapped.span_style.font_style, Some(FontStyle::Italic));
138        assert_eq!(
139            mapped.span_style.font_family,
140            Some(FontFamily::Named("Fira Sans".to_string()))
141        );
142        assert_eq!(mapped.span_style.letter_spacing, TextUnit::Sp(1.5));
143        assert_eq!(mapped.paragraph_style.line_height, TextUnit::Sp(30.0));
144    }
145
146    #[test]
147    fn draw_style_leaves_unset_attributes_unspecified() {
148        let mapped = text_style_for_draw_style(&DrawTextStyle::new(14.0));
149        assert_eq!(mapped.span_style.font_family, None);
150        assert!(mapped.span_style.letter_spacing.is_unspecified());
151        assert!(mapped.paragraph_style.line_height.is_unspecified());
152        assert_eq!(mapped.span_style.color, None);
153    }
154
155    #[test]
156    fn alignment_never_reaches_the_paragraph_style() {
157        // A draw scope resolves alignment into the primitive's rect; letting it
158        // through here would align the text twice.
159        let style = DrawTextStyle::new(14.0)
160            .with_align(TextAlign::Center)
161            .with_vertical_align(TextVerticalAlign::Bottom);
162        let mapped = text_style_for_draw_style(&style);
163        assert_eq!(
164            mapped.paragraph_style.text_align,
165            super::super::paragraph::TextAlign::Unspecified
166        );
167    }
168
169    #[test]
170    fn oblique_and_italic_request_the_same_face() {
171        let italic =
172            text_style_for_draw_style(&DrawTextStyle::new(14.0).with_style(DrawFontStyle::Italic));
173        let oblique =
174            text_style_for_draw_style(&DrawTextStyle::new(14.0).with_style(DrawFontStyle::Oblique));
175        assert_eq!(italic.span_style.font_style, oblique.span_style.font_style);
176    }
177
178    #[test]
179    fn degenerate_font_sizes_are_resolved_before_they_reach_the_measurer() {
180        for size in [0.0, -3.0, f32::NAN] {
181            let mapped = text_style_for_draw_style(&DrawTextStyle::new(size));
182            assert_eq!(
183                mapped.span_style.font_size,
184                TextUnit::Sp(DrawTextStyle::DEFAULT_FONT_SIZE)
185            );
186        }
187    }
188
189    #[test]
190    fn measuring_without_an_app_context_falls_back_to_the_estimate() {
191        let style = DrawTextStyle::new(16.0);
192        assert_eq!(
193            AppContextTextMeasurer.measure_text("HELLO", &style),
194            estimate_text_measurement("HELLO", &style)
195        );
196    }
197}