cranpose_ui/text/line_box.rs
1//! Where a line of text sits inside the height it was given.
2//!
3//! [`LineHeightStyle`] has been a declared-but-unread field on
4//! [`ParagraphStyle`](crate::text::ParagraphStyle) since it was added: nothing
5//! outside `merge` and the hash keys ever looked at it, and the rasterizer's
6//! line box was a fixed rule — the box is exactly the requested line height,
7//! and the leading is split evenly above and below. That rule is not what
8//! Android does, and the difference is visible.
9//!
10//! AOSP's `StaticLayout` differs in four ways that each move a glyph row:
11//!
12//! - the font's ascent and descent are **whole pixels**, rounded the way
13//! `Paint.getFontMetricsInt()` rounds them, and the line is built from that
14//! pair rather than from the float metrics;
15//! - the line advance is a **whole pixel**, `ceil`ed, not a float;
16//! - a requested line height **shorter than the font's own ascent + descent
17//! does not shrink the line** — the font wins, which is why a 16sp/18sp
18//! style lays out in 38px rather than 36px at density 2;
19//! - the leading is split with the **odd pixel below** the baseline, not above.
20//!
21//! [`line_box`] implements that, and it implements it **only when the caller
22//! asked for it**. A style whose `line_height_style` is `None` gets exactly the
23//! arithmetic it got before, bit for bit. That is deliberate: the rule changes
24//! where every glyph lands, and it is not a change to make silently on behalf
25//! of text that never asked. The Wear widgets ask for it through
26//! [`WearTextStyle`](crate::widgets::wear::WearTextStyle), and a
27//! [`DrawScope`](cranpose_ui_graphics::DrawScope) run asks for it through
28//! [`DrawTextStyle::with_line_height_style`](cranpose_ui_graphics::DrawTextStyle::with_line_height_style)
29//! — which is what lets a canvas and a `Text` on one screen agree.
30
31use crate::text::style::{
32 LineHeightAlignment, LineHeightMode, LineHeightStyle, LineHeightTrim, TextStyle,
33};
34
35/// A resolved line box: how tall the line is and where its baseline sits inside
36/// it, both measured down from the top of the box.
37#[derive(Clone, Copy, Debug, PartialEq)]
38pub struct LineBox {
39 /// Baseline-to-baseline advance, and the height of a single-line block.
40 pub height: f32,
41 /// Distance from the top of the box down to the baseline.
42 pub baseline: f32,
43}
44
45/// The font's own vertical extent, in the same unit as the line height.
46///
47/// `ascent` and `descent` are both **positive distances** from the baseline,
48/// which is the sign convention AOSP states its rule in and the opposite of the
49/// one `ab_glyph` reports `descent` in.
50#[derive(Clone, Copy, Debug, PartialEq)]
51pub struct FontExtent {
52 pub ascent: f32,
53 pub descent: f32,
54 /// `hhea.lineGap`. Only read when a style asks for font padding.
55 pub line_gap: f32,
56}
57
58impl FontExtent {
59 pub fn new(ascent: f32, descent: f32, line_gap: f32) -> Self {
60 Self {
61 ascent,
62 descent,
63 line_gap,
64 }
65 }
66
67 /// Ascent plus descent — the height the font needs with no leading at all.
68 pub fn natural(self) -> f32 {
69 self.ascent + self.descent
70 }
71}
72
73/// The line box a style asks for, given the font's extent and the line height
74/// already resolved from the style's own units.
75///
76/// `asked` is the line height in the same unit as the extent. `grid` is how
77/// many device pixels there are to one of those units, and it is what every
78/// rounding in the AOSP rule is done against — pass `1.0` when the values are
79/// already device pixels, or the density when they are layout points. Getting
80/// it wrong does not shift a baseline by a fraction; it quantises the whole
81/// line box to the wrong step.
82pub fn line_box(style: &TextStyle, extent: FontExtent, asked: f32, grid: f32) -> LineBox {
83 let grid = if grid.is_finite() && grid > 0.0 {
84 grid
85 } else {
86 1.0
87 };
88 match style.paragraph_style.line_height_style {
89 None => unstyled_line_box(extent, asked, grid),
90 Some(line_height_style) => {
91 let padding = font_padding(style, extent);
92 aosp_line_box(line_height_style, extent, asked, padding, grid)
93 }
94 }
95}
96
97fn unstyled_line_box(extent: FontExtent, asked: f32, grid: f32) -> LineBox {
98 let natural = (extent.natural() * grid).ceil() / grid;
99 LineBox {
100 height: asked,
101 baseline: extent.ascent + (asked - natural) * 0.5,
102 }
103}
104
105fn font_padding(style: &TextStyle, extent: FontExtent) -> f32 {
106 let asked = style
107 .paragraph_style
108 .platform_style
109 .and_then(|platform| platform.include_font_padding)
110 .unwrap_or(false);
111 if asked && extent.line_gap.is_finite() && extent.line_gap > 0.0 {
112 extent.line_gap
113 } else {
114 0.0
115 }
116}
117
118fn aosp_line_box(
119 style: LineHeightStyle,
120 extent: FontExtent,
121 asked: f32,
122 padding: f32,
123 grid: f32,
124) -> LineBox {
125 let up = |value: f32| (value * grid).ceil() / grid;
126 let down = |value: f32| (value * grid).floor() / grid;
127 let round = |value: f32| ((value * grid) + 0.5).floor() / grid;
128 let ascent = -round(-extent.ascent.max(0.0));
129 let descent = round(extent.descent.max(0.0));
130 let above_padding = down(padding * 0.5);
131 let below_padding = padding - above_padding;
132 let natural = up(ascent + descent + padding);
133 let asked = if asked.is_finite() {
134 up(asked)
135 } else {
136 natural
137 };
138
139 let height = match style.mode {
140 LineHeightMode::Fixed => asked.max(1.0),
141 LineHeightMode::Minimum => asked.max(natural).max(1.0),
142 LineHeightMode::Tight => natural.max(1.0),
143 };
144
145 let leading = height - (ascent + descent + padding);
146 let (mut above, mut below) = match style.alignment {
147 LineHeightAlignment::Top => (0.0, leading),
148 LineHeightAlignment::Bottom => (leading, 0.0),
149 LineHeightAlignment::Center => {
150 let below = up(leading * 0.5);
151 (leading - below, below)
152 }
153 LineHeightAlignment::Proportional => {
154 let total = ascent + descent;
155 if total > 0.0 {
156 let above = leading * (ascent / total);
157 (above, leading - above)
158 } else {
159 (leading * 0.5, leading * 0.5)
160 }
161 }
162 };
163 above += above_padding;
164 below += below_padding;
165
166 let (trim_above, trim_below) = match style.trim {
167 LineHeightTrim::None => (false, false),
168 LineHeightTrim::FirstLineTop => (true, false),
169 LineHeightTrim::LastLineBottom => (false, true),
170 LineHeightTrim::Both => (true, true),
171 };
172 let mut height = height;
173 if trim_above {
174 height -= above;
175 above = 0.0;
176 }
177 if trim_below {
178 height -= below;
179 }
180
181 LineBox {
182 height: height.max(1.0),
183 baseline: above + ascent,
184 }
185}
186
187#[cfg(test)]
188#[path = "tests/line_box_tests.rs"]
189mod tests;