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, which it reaches by a
53/// thin pointer: a curve is 16 bytes.
54#[derive(Clone, Copy, Debug, PartialEq)]
55pub struct FontScaleCurve {
56 scale: f32,
57 fingerprint: u32,
58 knots: &'static Vec<(f32, f32)>,
59}
60
61/// The knots of every linear curve.
62static NO_KNOTS: Vec<(f32, f32)> = Vec::new();
63
64impl Default for FontScaleCurve {
65 fn default() -> Self {
66 Self::linear(1.0)
67 }
68}
69
70impl FontScaleCurve {
71 /// The plain multiplier: what every platform without a conversion table
72 /// does, and what Android itself did before it had one.
73 pub const fn linear(scale: f32) -> Self {
74 Self {
75 scale,
76 fingerprint: 0,
77 knots: &NO_KNOTS,
78 }
79 }
80
81 /// A curve through `samples`, which are `(sp, dp)` pairs read from the
82 /// platform in ascending sp order.
83 ///
84 /// Samples that lie on the straight line between their neighbours are
85 /// dropped, so a densely sampled table comes back as the handful of points
86 /// that actually bend. Anything the platform could not have produced — an
87 /// empty or unsorted set, a non-finite or non-positive value, or more bends
88 /// than [`MAX_FONT_SCALE_KNOTS`] — is refused, and the caller gets the
89 /// multiplier rather than a curve nobody measured. Samples that all lie on
90 /// the multiplier's own line are the multiplier, and come back as it, so a
91 /// platform answering with the plain setting gives a curve equal to
92 /// [`FontScaleCurve::linear`].
93 pub fn from_samples(scale: f32, samples: &[(f32, f32)]) -> Self {
94 let Some(kept) = compress(samples) else {
95 return Self::linear(scale);
96 };
97 if kept
98 .iter()
99 .all(|(sp, dp)| (dp - sp * scale).abs() <= COLLINEAR_EPSILON_DP)
100 {
101 return Self::linear(scale);
102 }
103 Self {
104 scale,
105 fingerprint: fingerprint(scale, &kept),
106 knots: shared_knots(kept),
107 }
108 }
109
110 /// The setting itself — what the user chose, whatever the table then does
111 /// with an individual size. This is the number to report, not the number to
112 /// multiply by.
113 pub fn scale(self) -> f32 {
114 self.scale
115 }
116
117 /// Whether this is the plain multiplier, with no table behind it.
118 pub fn is_linear(self) -> bool {
119 self.knots.is_empty()
120 }
121
122 /// Whether resolving an `Sp` through this curve changes nothing at all.
123 ///
124 /// Asked of the conversion and not of the representation: a platform that
125 /// hands back a table while the setting sits at 1.0 hands back a table
126 /// whose every knot maps a size to itself, and that is still nothing to do.
127 pub fn is_identity(self) -> bool {
128 if self.knots.is_empty() {
129 return (self.scale - 1.0).abs() <= f32::EPSILON;
130 }
131 self.knots
132 .iter()
133 .all(|(sp, dp)| (dp - sp).abs() <= COLLINEAR_EPSILON_DP)
134 }
135
136 /// The knots, ascending by sp. Empty for a linear curve.
137 pub fn knots(self) -> &'static [(f32, f32)] {
138 self.knots.as_slice()
139 }
140
141 /// How many [`FontScaleCurve::knots`] there are.
142 pub fn knot_count(self) -> usize {
143 self.knots.len()
144 }
145
146 /// A cheap identity for cache keys: two curves that convert identically
147 /// share it, and one that does not is overwhelmingly unlikely to.
148 pub fn fingerprint(self) -> u32 {
149 self.fingerprint ^ self.scale.to_bits()
150 }
151
152 /// A size in scale-independent pixels, in dp.
153 ///
154 /// This is `FontScaleConverterImpl.convertSpToDp`: the sign is carried
155 /// separately, an exact hit on a knot returns that knot, a size outside the
156 /// table scales by the ratio of the nearest end, and everything between two
157 /// knots is interpolated.
158 pub fn sp_to_dp(self, sp: f32) -> f32 {
159 if !sp.is_finite() {
160 return sp;
161 }
162 let knots = self.knots;
163 let (Some(&(first_sp, first_dp)), Some(&(last_sp, last_dp))) =
164 (knots.first(), knots.last())
165 else {
166 return sp * self.scale;
167 };
168 let magnitude = sp.abs();
169 let sign = if sp.is_sign_negative() { -1.0 } else { 1.0 };
170 if magnitude <= first_sp {
171 return sign * magnitude * (first_dp / first_sp);
172 }
173 if magnitude >= last_sp {
174 return sign * magnitude * (last_dp / last_sp);
175 }
176 for window in knots.windows(2) {
177 let (low_sp, low_dp) = window[0];
178 let (high_sp, high_dp) = window[1];
179 if magnitude <= high_sp {
180 let t = (magnitude - low_sp) / (high_sp - low_sp);
181 return sign * (low_dp + (high_dp - low_dp) * t);
182 }
183 }
184 sign * magnitude * (last_dp / last_sp)
185 }
186}
187
188fn compress(samples: &[(f32, f32)]) -> Option<Vec<(f32, f32)>> {
189 if samples.len() < 2 {
190 return None;
191 }
192 let mut previous_sp = 0.0f32;
193 for (sp, dp) in samples {
194 if !sp.is_finite() || !dp.is_finite() || *sp <= previous_sp || *dp <= 0.0 {
195 return None;
196 }
197 previous_sp = *sp;
198 }
199 let mut kept: Vec<(f32, f32)> = Vec::with_capacity(samples.len());
200 kept.push(samples[0]);
201 for index in 1..samples.len() - 1 {
202 let (low_sp, low_dp) = *kept.last().expect("the first sample was pushed");
203 let (sp, dp) = samples[index];
204 let (high_sp, high_dp) = samples[index + 1];
205 let t = (sp - low_sp) / (high_sp - low_sp);
206 let straight = low_dp + (high_dp - low_dp) * t;
207 if (dp - straight).abs() > COLLINEAR_EPSILON_DP {
208 kept.push(samples[index]);
209 }
210 }
211 kept.push(samples[samples.len() - 1]);
212 if kept.len() > MAX_FONT_SCALE_KNOTS {
213 return None;
214 }
215 Some(kept)
216}
217
218/// The table holding `knots`, shared with every curve that has the same
219/// knots. A process meets one table per font scale setting the user picks, so
220/// the tables are kept for its life.
221fn shared_knots(mut knots: Vec<(f32, f32)>) -> &'static Vec<(f32, f32)> {
222 static TABLES: std::sync::Mutex<Vec<&'static Vec<(f32, f32)>>> =
223 std::sync::Mutex::new(Vec::new());
224 let mut tables = TABLES
225 .lock()
226 .unwrap_or_else(std::sync::PoisonError::into_inner);
227 if let Some(table) = tables.iter().find(|table| ***table == knots) {
228 return table;
229 }
230 knots.shrink_to_fit();
231 let table: &'static Vec<(f32, f32)> = Box::leak(Box::new(knots));
232 tables.push(table);
233 table
234}
235
236fn fingerprint(scale: f32, knots: &[(f32, f32)]) -> u32 {
237 let mut hash = 2166136261u32;
238 let mut mix = |bits: u32| {
239 hash ^= bits;
240 hash = hash.wrapping_mul(16777619);
241 };
242 mix(scale.to_bits());
243 for (sp, dp) in knots {
244 mix(sp.to_bits());
245 mix(dp.to_bits());
246 }
247 hash
248}
249
250#[cfg(test)]
251#[path = "tests/font_scale_tests.rs"]
252mod tests;