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