cranpose_ui/font_scale.rs
1//! The system font-size setting, as the curve it is and not the multiplier it
2//! looks like.
3//!
4//! On Android 13 and below the platform resolves a size in `Sp` as
5//! `sp * font_scale` dp. Android 14 does not: above a threshold setting it runs
6//! the sp value through a piecewise-linear table, so small text grows by the
7//! full setting and large text grows by less.
8//! `TypedValue.applyDimension(COMPLEX_UNIT_SP, ..)` answers from that table, and
9//! Jetpack Compose carries its own copy so its `Density` answers the same.
10//!
11//! Measured on a Wear OS 5 emulator at density 2.0 with the setting at 1.24
12//! (`TypedValue.applyDimension` and `androidx.compose.ui.unit.Density(context)`
13//! agree to the last bit on every sample):
14//!
15//! | sp | multiplied | platform |
16//! |---|---|---|
17//! | 12 | 29.76 px | 29.76 px |
18//! | 13 | 32.24 px | **32.72 px** |
19//! | 14 | 34.72 px | **35.68 px** |
20//! | 16 | 39.68 px | **38.72 px** |
21//! | 19 | 47.12 px | **43.76 px** |
22//!
23//! So multiplying is wrong in both directions at once, and by enough to change
24//! where a line wraps. A [`FontScaleCurve`] carries the platform's answer
25//! instead: the host samples the real conversion when the configuration says it
26//! changed, and everything that resolves an `Sp` goes through
27//! [`FontScaleCurve::sp_to_dp`].
28//!
29//! Off Android, and on an Android below the version that has a table, the curve
30//! holds no knots and `sp_to_dp` is the multiplication again — which is what
31//! those platforms actually do.
32
33/// How many knots a curve keeps.
34///
35/// The platform's own table has ten, and collapsing the points that sit on a
36/// straight line through their neighbours leaves fewer, so this is headroom
37/// rather than a limit anyone is expected to reach.
38pub const MAX_FONT_SCALE_KNOTS: usize = 12;
39
40const COLLINEAR_EPSILON_DP: f32 = 1.0e-3;
41
42/// The sp → dp conversion the platform performs, sampled from the platform.
43///
44/// With no knots this is `sp * scale`. With knots it is the piecewise-linear
45/// function through them, extended past both ends by the ratio of the knot on
46/// that end — which is how the platform extends its own table, so a size below
47/// the first knot scales by the plain setting and one above the last keeps the
48/// last knot's ratio.
49///
50/// A curve is `Copy` and rides in every [`crate::Density`], which each layout
51/// node keeps and the measurement path passes by value, so its knots live in
52/// a table shared by every curve with the same knots: a curve is 24 bytes.
53#[derive(Clone, Copy, Debug, PartialEq)]
54pub struct FontScaleCurve {
55 scale: f32,
56 fingerprint: u32,
57 knots: &'static [(f32, f32)],
58}
59
60impl Default for FontScaleCurve {
61 fn default() -> Self {
62 Self::linear(1.0)
63 }
64}
65
66impl FontScaleCurve {
67 /// The plain multiplier: what every platform without a conversion table
68 /// does, and what Android itself did before it had one.
69 pub const fn linear(scale: f32) -> Self {
70 Self {
71 scale,
72 fingerprint: 0,
73 knots: &[],
74 }
75 }
76
77 /// A curve through `samples`, which are `(sp, dp)` pairs read from the
78 /// platform in ascending sp order.
79 ///
80 /// Samples that lie on the straight line between their neighbours are
81 /// dropped, so a densely sampled table comes back as the handful of points
82 /// that actually bend. Anything the platform could not have produced — an
83 /// empty or unsorted set, a non-finite or non-positive value, or more bends
84 /// than [`MAX_FONT_SCALE_KNOTS`] — is refused, and the caller gets the
85 /// multiplier rather than a curve nobody measured. Samples that all lie on
86 /// the multiplier's own line are the multiplier, and come back as it, so a
87 /// platform answering with the plain setting gives a curve equal to
88 /// [`FontScaleCurve::linear`].
89 pub fn from_samples(scale: f32, samples: &[(f32, f32)]) -> Self {
90 let Some(kept) = compress(samples) else {
91 return Self::linear(scale);
92 };
93 if kept
94 .iter()
95 .all(|(sp, dp)| (dp - sp * scale).abs() <= COLLINEAR_EPSILON_DP)
96 {
97 return Self::linear(scale);
98 }
99 Self {
100 scale,
101 fingerprint: fingerprint(scale, &kept),
102 knots: shared_knots(kept),
103 }
104 }
105
106 /// The setting itself — what the user chose, whatever the table then does
107 /// with an individual size. This is the number to report, not the number to
108 /// multiply by.
109 pub fn scale(self) -> f32 {
110 self.scale
111 }
112
113 /// Whether this is the plain multiplier, with no table behind it.
114 pub fn is_linear(self) -> bool {
115 self.knots.is_empty()
116 }
117
118 /// Whether resolving an `Sp` through this curve changes nothing at all.
119 ///
120 /// Asked of the conversion and not of the representation: a platform that
121 /// hands back a table while the setting sits at 1.0 hands back a table
122 /// whose every knot maps a size to itself, and that is still nothing to do.
123 pub fn is_identity(self) -> bool {
124 if self.knots.is_empty() {
125 return (self.scale - 1.0).abs() <= f32::EPSILON;
126 }
127 self.knots
128 .iter()
129 .all(|(sp, dp)| (dp - sp).abs() <= COLLINEAR_EPSILON_DP)
130 }
131
132 /// The knots, ascending by sp. Empty for a linear curve.
133 pub fn knots(self) -> &'static [(f32, f32)] {
134 self.knots
135 }
136
137 /// How many [`FontScaleCurve::knots`] there are.
138 pub fn knot_count(self) -> usize {
139 self.knots.len()
140 }
141
142 /// A cheap identity for cache keys: two curves that convert identically
143 /// share it, and one that does not is overwhelmingly unlikely to.
144 pub fn fingerprint(self) -> u32 {
145 self.fingerprint ^ self.scale.to_bits()
146 }
147
148 /// A size in scale-independent pixels, in dp.
149 ///
150 /// This is `FontScaleConverterImpl.convertSpToDp`: the sign is carried
151 /// separately, an exact hit on a knot returns that knot, a size outside the
152 /// table scales by the ratio of the nearest end, and everything between two
153 /// knots is interpolated.
154 pub fn sp_to_dp(self, sp: f32) -> f32 {
155 if !sp.is_finite() {
156 return sp;
157 }
158 let knots = self.knots;
159 let (Some(&(first_sp, first_dp)), Some(&(last_sp, last_dp))) =
160 (knots.first(), knots.last())
161 else {
162 return sp * self.scale;
163 };
164 let magnitude = sp.abs();
165 let sign = if sp.is_sign_negative() { -1.0 } else { 1.0 };
166 if magnitude <= first_sp {
167 return sign * magnitude * (first_dp / first_sp);
168 }
169 if magnitude >= last_sp {
170 return sign * magnitude * (last_dp / last_sp);
171 }
172 for window in knots.windows(2) {
173 let (low_sp, low_dp) = window[0];
174 let (high_sp, high_dp) = window[1];
175 if magnitude <= high_sp {
176 let t = (magnitude - low_sp) / (high_sp - low_sp);
177 return sign * (low_dp + (high_dp - low_dp) * t);
178 }
179 }
180 sign * magnitude * (last_dp / last_sp)
181 }
182}
183
184fn compress(samples: &[(f32, f32)]) -> Option<Vec<(f32, f32)>> {
185 if samples.len() < 2 {
186 return None;
187 }
188 let mut previous_sp = 0.0f32;
189 for (sp, dp) in samples {
190 if !sp.is_finite() || !dp.is_finite() || *sp <= previous_sp || *dp <= 0.0 {
191 return None;
192 }
193 previous_sp = *sp;
194 }
195 let mut kept: Vec<(f32, f32)> = Vec::with_capacity(samples.len());
196 kept.push(samples[0]);
197 for index in 1..samples.len() - 1 {
198 let (low_sp, low_dp) = *kept.last().expect("the first sample was pushed");
199 let (sp, dp) = samples[index];
200 let (high_sp, high_dp) = samples[index + 1];
201 let t = (sp - low_sp) / (high_sp - low_sp);
202 let straight = low_dp + (high_dp - low_dp) * t;
203 if (dp - straight).abs() > COLLINEAR_EPSILON_DP {
204 kept.push(samples[index]);
205 }
206 }
207 kept.push(samples[samples.len() - 1]);
208 if kept.len() > MAX_FONT_SCALE_KNOTS {
209 return None;
210 }
211 Some(kept)
212}
213
214/// The table holding `knots`, shared with every curve that has the same
215/// knots. A process meets one table per font scale setting the user picks, so
216/// the tables are kept for its life.
217fn shared_knots(knots: Vec<(f32, f32)>) -> &'static [(f32, f32)] {
218 static TABLES: std::sync::Mutex<Vec<&'static [(f32, f32)]>> = std::sync::Mutex::new(Vec::new());
219 let mut tables = TABLES
220 .lock()
221 .unwrap_or_else(std::sync::PoisonError::into_inner);
222 if let Some(table) = tables.iter().find(|table| ***table == knots[..]) {
223 return table;
224 }
225 let table: &'static [(f32, f32)] = Box::leak(knots.into_boxed_slice());
226 tables.push(table);
227 table
228}
229
230fn fingerprint(scale: f32, knots: &[(f32, f32)]) -> u32 {
231 let mut hash = 2166136261u32;
232 let mut mix = |bits: u32| {
233 hash ^= bits;
234 hash = hash.wrapping_mul(16777619);
235 };
236 mix(scale.to_bits());
237 for (sp, dp) in knots {
238 mix(sp.to_bits());
239 mix(dp.to_bits());
240 }
241 hash
242}
243
244#[cfg(test)]
245#[path = "tests/font_scale_tests.rs"]
246mod tests;