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::{
20    font::{FontFamily, FontStyle, FontWeight},
21    line_box::LineBox,
22    style::{SpanStyle, TextStyle},
23    unit::TextUnit,
24};
25
26/// Builds the [`TextStyle`] that describes a draw-scope text run.
27///
28/// Everything a `DrawTextStyle` can say is a span attribute except the line
29/// height and its policy, so the rest of the paragraph style stays at its
30/// defaults — in particular `text_align` is left unspecified, because a draw
31/// scope has already resolved alignment into the primitive's rect.
32///
33/// The policy has to come across. Without it every run drawn through a canvas
34/// takes [`line_box`](fn@super::line_box)'s plain branch while a `Text` composable
35/// of the same style takes the AOSP one, and a screen that does both puts its
36/// two sets of rows a device pixel apart.
37pub fn text_style_for_draw_style(style: &DrawTextStyle) -> TextStyle {
38    let mut span_style = SpanStyle {
39        font_size: TextUnit::Sp(style.resolved_font_size()),
40        font_weight: Some(FontWeight::new(style.font_weight.value())),
41        font_style: Some(match style.font_style {
42            DrawFontStyle::Normal => FontStyle::Normal,
43            // No font in the stack ships an oblique face; the renderer
44            // synthesizes both the same way.
45            DrawFontStyle::Italic | DrawFontStyle::Oblique => FontStyle::Italic,
46        }),
47        ..SpanStyle::default()
48    };
49    if let Some(family) = &style.font_family {
50        span_style.font_family = Some(FontFamily::from_name(family));
51    }
52    let letter_spacing = style.resolved_letter_spacing();
53    if letter_spacing != 0.0 {
54        span_style.letter_spacing = TextUnit::Sp(letter_spacing);
55    }
56
57    let mut text_style = TextStyle::from_span_style(span_style);
58    if let Some(line_height) = style.line_height {
59        if line_height.is_finite() && line_height > 0.0 {
60            text_style.paragraph_style.line_height = TextUnit::Sp(line_height);
61        }
62    }
63    text_style.paragraph_style.line_height_style = style.line_height_style;
64    text_style
65}
66
67/// The line box a draw-scope style resolves to against the app's fonts: how
68/// tall one line is and where its baseline sits inside it.
69///
70/// This is the vertical half of [`DrawScope::measure_text`](cranpose_ui_graphics::DrawScope::measure_text),
71/// answerable without a string to measure or a scope to measure in — a layout
72/// that stacks rows of a known style needs the row pitch before it has any text
73/// for them. `None` when no app context owns the fonts.
74///
75/// It resolves the style exactly as the measurer does, which means the sizes are
76/// taken as stated: a `DrawTextStyle` is already resolved, so the system font
77/// scale must not be folded in a second time here.
78pub fn draw_style_line_box(style: &DrawTextStyle) -> Option<LineBox> {
79    super::measure::resolved_line_box(&text_style_for_draw_style(style))
80}
81
82/// Measures draw-scope text against the app's fonts.
83///
84/// Every call lands in `super::measure::measure_resolved_text`, backed by the
85/// app context's metrics cache — so measuring an unchanged string every frame
86/// is a hash lookup, not a shaping pass.
87///
88/// "Resolved" is the whole point: a [`DrawTextStyle`] states final sizes, and a
89/// scene lowers a text primitive with `style.resolved_font_size()` untouched,
90/// so the system font scale must not be folded in here. It is applied where an
91/// unresolved size lives instead — the `Text` composable's `Sp` values — and
92/// that path carries the scaled style through to the renderer with it.
93#[derive(Clone, Copy, Debug, Default)]
94pub struct AppContextTextMeasurer;
95
96impl AppContextTextMeasurer {
97    /// A shared measurer to hand to
98    /// [`DrawScopeDefault::with_text_measurer`](cranpose_ui_graphics::DrawScopeDefault::with_text_measurer).
99    pub fn shared() -> Rc<dyn DrawTextMeasurer> {
100        thread_local! {
101            static SHARED: Rc<dyn DrawTextMeasurer> = Rc::new(AppContextTextMeasurer);
102        }
103        SHARED.with(Rc::clone)
104    }
105}
106
107impl DrawTextMeasurer for AppContextTextMeasurer {
108    fn measure_text(&self, text: &str, style: &DrawTextStyle) -> TextMeasurement {
109        // Draw closures normally run inside the app context that owns the
110        // fonts. Tooling that runs one standalone gets the font-free estimate
111        // rather than a panic.
112        if crate::render_state::current_app_context().is_none() {
113            return estimate_text_measurement(text, style);
114        }
115
116        let text_style = text_style_for_draw_style(style);
117        let annotated = super::shared_plain_annotated_string(text);
118        let metrics = super::measure::measure_resolved_text(&annotated, &text_style);
119        let line_height = if metrics.line_height.is_finite() && metrics.line_height > 0.0 {
120            metrics.line_height
121        } else {
122            estimate_text_measurement(text, style).line_height
123        };
124        let first_baseline = super::measure::resolved_first_baseline(&text_style)
125            .unwrap_or_else(|| estimate_text_measurement(text, style).first_baseline);
126
127        if text.is_empty() {
128            return TextMeasurement::empty(line_height, first_baseline);
129        }
130
131        let line_count = metrics.line_count.max(1);
132        TextMeasurement {
133            // Height comes from the line box, not from `metrics.height`: a
134            // measurer is free to report a taller box for `min_lines`, and the
135            // rasterizer only ever advances by `line_height` per line.
136            size: Size::new(metrics.width.max(0.0), line_count as f32 * line_height),
137            line_height,
138            first_baseline,
139            line_count,
140        }
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use cranpose_ui_graphics::{FontWeight as DrawFontWeight, TextAlign, TextVerticalAlign};
147
148    use super::*;
149
150    #[test]
151    fn draw_style_maps_onto_span_attributes() {
152        let style = DrawTextStyle::new(23.0)
153            .with_font_family("Fira Sans")
154            .with_weight(DrawFontWeight::BOLD)
155            .with_style(DrawFontStyle::Italic)
156            .with_letter_spacing(1.5)
157            .with_line_height(30.0);
158        let mapped = text_style_for_draw_style(&style);
159
160        assert_eq!(mapped.span_style.font_size, TextUnit::Sp(23.0));
161        assert_eq!(mapped.span_style.font_weight, Some(FontWeight::BOLD));
162        assert_eq!(mapped.span_style.font_style, Some(FontStyle::Italic));
163        assert_eq!(
164            mapped.span_style.font_family,
165            Some(FontFamily::Named("Fira Sans".to_string()))
166        );
167        assert_eq!(mapped.span_style.letter_spacing, TextUnit::Sp(1.5));
168        assert_eq!(mapped.paragraph_style.line_height, TextUnit::Sp(30.0));
169    }
170
171    #[test]
172    fn draw_style_leaves_unset_attributes_unspecified() {
173        let mapped = text_style_for_draw_style(&DrawTextStyle::new(14.0));
174        assert_eq!(mapped.span_style.font_family, None);
175        assert!(mapped.span_style.letter_spacing.is_unspecified());
176        assert!(mapped.paragraph_style.line_height.is_unspecified());
177        assert_eq!(mapped.paragraph_style.line_height_style, None);
178        assert_eq!(mapped.span_style.color, None);
179    }
180
181    #[test]
182    fn a_drawn_run_and_a_composed_text_resolve_the_same_line_box() {
183        // The defect this field exists for: a canvas and a `Text` on one screen
184        // took different line-box rules, so every drawn row landed a device
185        // pixel off the composed rows beside it. Roboto at 16sp on a density-2
186        // watch, in device pixels.
187        use crate::{
188            text::line_box::{line_box, FontExtent},
189            widgets::wear::wear_line_height_style,
190        };
191
192        let extent = FontExtent::new(32.0 * 1900.0 / 2048.0, 32.0 * 500.0 / 2048.0, 0.0);
193        let drawn = DrawTextStyle::new(32.0)
194            .with_line_height(36.0)
195            .with_line_height_style(wear_line_height_style());
196        let resolved = line_box(&text_style_for_draw_style(&drawn), extent, 36.0, 1.0);
197        let composed = line_box(
198            &crate::widgets::wear::WearTextStyle::TITLE_MEDIUM
199                .resolve(cranpose_ui_graphics::Color::WHITE),
200            extent,
201            36.0,
202            1.0,
203        );
204        assert_eq!(resolved, composed);
205        // And it is the platform's answer, not the plain split: the font's own
206        // extent is 38px, which a 36px line height does not shrink.
207        assert_eq!(resolved.height, 38.0);
208        assert_eq!(resolved.baseline, 30.0);
209
210        // Without the policy the same run takes the plain branch and sits half
211        // a device pixel higher, which is what put the two paths out of step.
212        let unstyled = line_box(
213            &text_style_for_draw_style(&DrawTextStyle::new(32.0).with_line_height(36.0)),
214            extent,
215            36.0,
216            1.0,
217        );
218        assert_ne!(unstyled, resolved);
219    }
220
221    #[test]
222    fn alignment_never_reaches_the_paragraph_style() {
223        // A draw scope resolves alignment into the primitive's rect; letting it
224        // through here would align the text twice.
225        let style = DrawTextStyle::new(14.0)
226            .with_align(TextAlign::Center)
227            .with_vertical_align(TextVerticalAlign::Bottom);
228        let mapped = text_style_for_draw_style(&style);
229        assert_eq!(
230            mapped.paragraph_style.text_align,
231            super::super::paragraph::TextAlign::Unspecified
232        );
233    }
234
235    #[test]
236    fn oblique_and_italic_request_the_same_face() {
237        let italic =
238            text_style_for_draw_style(&DrawTextStyle::new(14.0).with_style(DrawFontStyle::Italic));
239        let oblique =
240            text_style_for_draw_style(&DrawTextStyle::new(14.0).with_style(DrawFontStyle::Oblique));
241        assert_eq!(italic.span_style.font_style, oblique.span_style.font_style);
242    }
243
244    #[test]
245    fn degenerate_font_sizes_are_resolved_before_they_reach_the_measurer() {
246        for size in [0.0, -3.0, f32::NAN] {
247            let mapped = text_style_for_draw_style(&DrawTextStyle::new(size));
248            assert_eq!(
249                mapped.span_style.font_size,
250                TextUnit::Sp(DrawTextStyle::DEFAULT_FONT_SIZE)
251            );
252        }
253    }
254
255    #[test]
256    fn measuring_without_an_app_context_falls_back_to_the_estimate() {
257        let style = DrawTextStyle::new(16.0);
258        assert_eq!(
259            AppContextTextMeasurer.measure_text("HELLO", &style),
260            estimate_text_measurement("HELLO", &style)
261        );
262    }
263}