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