cranpose_ui/text/line_box.rs
1//! Where a line of text sits inside the height it was given, and what a
2//! paragraph gives back at its edges.
3//!
4//! Jetpack Compose lays text out with AOSP's `StaticLayout` and its
5//! `LineHeightStyle` span, and so does [`line_box`]:
6//!
7//! - the font's ascent and descent are **whole pixels**, rounded the way
8//! `Paint.getFontMetricsInt()` rounds them, and the line is built from that
9//! pair rather than from the float metrics;
10//! - the line advance is a **whole pixel**, `ceil`ed, not a float;
11//! - the leading, the line height past the font's own ascent + descent, is
12//! placed by the style's [`LineHeightAlignment`], with the odd pixel of a
13//! centred split below the baseline;
14//! - [`LineHeightTrim`] gives the leading back **only at the paragraph's edges**:
15//! above its first line and below its last. Every line keeps the full advance
16//! between baselines, so a paragraph of `n` lines is
17//! [`LineBox::block_height`] tall and its first baseline sits at
18//! [`LineBox::first_baseline`].
19//!
20//! A style that names no [`LineHeightStyle`] gets Compose's default, which is
21//! [`LineHeightStyle::default`]: proportional leading, both edges trimmed, the
22//! requested height fixed. A style that asks for font padding instead gets the
23//! rule Compose keeps for padded text: proportional leading, nothing trimmed. A
24//! style that asks for no line height gets the font's own ascent + descent, as
25//! Compose's does, so a single line of it is exactly as tall as its font.
26
27use crate::text::style::{
28 LineHeightAlignment, LineHeightMode, LineHeightStyle, LineHeightTrim, TextStyle,
29};
30
31/// A resolved line box: how far apart a paragraph's baselines are, where the
32/// baseline sits in each line, and what the paragraph's first and last lines
33/// give back at its edges. All measured down from the top of a line.
34#[derive(Clone, Copy, Debug, PartialEq)]
35pub struct LineBox {
36 /// Baseline-to-baseline advance: the height of every line before the
37 /// paragraph's edges are trimmed.
38 pub height: f32,
39 /// Distance from the top of a line down to its baseline.
40 pub baseline: f32,
41 /// What the first line gives back above its glyphs.
42 pub trim_top: f32,
43 /// What the last line gives back below its glyphs.
44 pub trim_bottom: f32,
45}
46
47impl LineBox {
48 /// A box with nothing trimmed: `height` apart, baseline `baseline` down.
49 pub fn untrimmed(height: f32, baseline: f32) -> Self {
50 Self {
51 height,
52 baseline,
53 trim_top: 0.0,
54 trim_bottom: 0.0,
55 }
56 }
57
58 /// The height of a paragraph of `lines` lines: every line's advance, less
59 /// what the first line's top and the last line's bottom give back.
60 pub fn block_height(self, lines: usize) -> f32 {
61 (self.height * lines.max(1) as f32 - self.trim_top - self.trim_bottom).max(1.0)
62 }
63
64 /// The first line's baseline, measured down from the paragraph's top.
65 pub fn first_baseline(self) -> f32 {
66 self.baseline - self.trim_top
67 }
68
69 /// Where line `index` starts, measured down from the paragraph's top.
70 pub fn line_top(self, index: usize) -> f32 {
71 index as f32 * self.height - self.trim_top
72 }
73}
74
75/// The font's own vertical extent, in the same unit as the line height.
76///
77/// `ascent` and `descent` are both **positive distances** from the baseline,
78/// which is the sign convention AOSP states its rule in and the opposite of the
79/// one `ab_glyph` reports `descent` in.
80#[derive(Clone, Copy, Debug, PartialEq)]
81pub struct FontExtent {
82 /// Distance from the baseline up to the top of the font, as a positive number.
83 pub ascent: f32,
84 /// Distance from the baseline down to the bottom of the font, as a positive number.
85 pub descent: f32,
86 /// `hhea.lineGap`. Only read when a style asks for font padding.
87 pub line_gap: f32,
88}
89
90impl FontExtent {
91 /// Creates an extent from `ascent`, `descent` and `line_gap`, all in the same unit.
92 pub fn new(ascent: f32, descent: f32, line_gap: f32) -> Self {
93 Self {
94 ascent,
95 descent,
96 line_gap,
97 }
98 }
99
100 /// Ascent plus descent — the height the font needs with no leading at all.
101 pub fn natural(self) -> f32 {
102 self.ascent + self.descent
103 }
104}
105
106/// The line box a style asks for, given the font's extent and the line height
107/// already resolved from the style's own units.
108///
109/// `asked` is the line height in the same unit as the extent; a style that
110/// asks for no line height is laid out at the font's own extent whatever
111/// `asked` says. `grid` is how many device pixels there are to one of those
112/// units, and it is what every rounding in the AOSP rule is done against —
113/// pass `1.0` when the values are already device pixels, or the density when
114/// they are layout points. Getting it wrong does not shift a baseline by a
115/// fraction; it quantises the whole line box to the wrong step.
116pub fn line_box(style: &TextStyle, extent: FontExtent, asked: f32, grid: f32) -> LineBox {
117 let grid = if grid.is_finite() && grid > 0.0 {
118 grid
119 } else {
120 1.0
121 };
122 let asked = if style.paragraph_style.line_height.is_unspecified() {
123 f32::NAN
124 } else {
125 asked
126 };
127 let padding = font_padding(style, extent);
128 let line_height_style = match style.paragraph_style.line_height_style {
129 Some(line_height_style) => line_height_style,
130 None if padding > 0.0 || font_padding_asked(style) => LineHeightStyle {
131 alignment: LineHeightAlignment::Proportional,
132 trim: LineHeightTrim::None,
133 mode: LineHeightMode::Fixed,
134 },
135 None => LineHeightStyle::default(),
136 };
137 aosp_line_box(line_height_style, extent, asked, padding, grid)
138}
139
140fn font_padding_asked(style: &TextStyle) -> bool {
141 style
142 .paragraph_style
143 .platform_style
144 .and_then(|platform| platform.include_font_padding)
145 .unwrap_or(false)
146}
147
148fn font_padding(style: &TextStyle, extent: FontExtent) -> f32 {
149 if font_padding_asked(style) && extent.line_gap.is_finite() && extent.line_gap > 0.0 {
150 extent.line_gap
151 } else {
152 0.0
153 }
154}
155
156fn aosp_line_box(
157 style: LineHeightStyle,
158 extent: FontExtent,
159 asked: f32,
160 padding: f32,
161 grid: f32,
162) -> LineBox {
163 let up = |value: f32| (value * grid).ceil() / grid;
164 let down = |value: f32| (value * grid).floor() / grid;
165 let round = |value: f32| ((value * grid) + 0.5).floor() / grid;
166 let ascent = -round(-extent.ascent.max(0.0));
167 let descent = round(extent.descent.max(0.0));
168 let above_padding = down(padding * 0.5);
169 let below_padding = padding - above_padding;
170 let natural = up(ascent + descent + padding);
171 let asked = if asked.is_finite() {
172 up(asked)
173 } else {
174 natural
175 };
176
177 let height = match style.mode {
178 LineHeightMode::Fixed => asked.max(1.0),
179 LineHeightMode::Minimum => asked.max(natural).max(1.0),
180 LineHeightMode::Tight => natural.max(1.0),
181 };
182
183 let leading = height - (ascent + descent + padding);
184 let (mut above, mut below) = match style.alignment {
185 LineHeightAlignment::Top => (0.0, leading),
186 LineHeightAlignment::Bottom => (leading, 0.0),
187 LineHeightAlignment::Center => {
188 let below = up(leading * 0.5);
189 (leading - below, below)
190 }
191 LineHeightAlignment::Proportional => {
192 let total = ascent + descent;
193 if total > 0.0 {
194 let above = leading * (ascent / total);
195 (above, leading - above)
196 } else {
197 (leading * 0.5, leading * 0.5)
198 }
199 }
200 };
201 above += above_padding;
202 below += below_padding;
203
204 let (trim_above, trim_below) = match style.trim {
205 LineHeightTrim::None => (false, false),
206 LineHeightTrim::FirstLineTop => (true, false),
207 LineHeightTrim::LastLineBottom => (false, true),
208 LineHeightTrim::Both => (true, true),
209 };
210
211 LineBox {
212 height: height.max(1.0),
213 baseline: above + ascent,
214 trim_top: if trim_above { above } else { 0.0 },
215 trim_bottom: if trim_below { below } else { 0.0 },
216 }
217}
218
219#[cfg(test)]
220#[path = "tests/line_box_tests.rs"]
221mod tests;