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