Skip to main content

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//! A size in `Sp` used to be `sp * font_scale` dp, and on Android 13 and below
5//! that is exactly what the platform does. Android 14 replaced it: above a
6//! threshold setting the platform runs the sp value through a piecewise-linear
7//! table, so small text grows by the 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. It is a hard cap because a
38/// curve is `Copy` and rides inside a type that is passed by value on the
39/// measurement path.
40pub const MAX_FONT_SCALE_KNOTS: usize = 12;
41
42/// How far off a straight line through its neighbours a knot has to sit to be
43/// worth keeping, in dp. Well under the half device pixel that could move a
44/// glyph, and well over the float noise a sampled curve carries.
45const COLLINEAR_EPSILON_DP: f32 = 1.0e-3;
46
47/// The sp → dp conversion the platform performs, sampled from the platform.
48///
49/// With no knots this is `sp * scale`. With knots it is the piecewise-linear
50/// function through them, extended past both ends by the ratio of the knot on
51/// that end — which is how the platform extends its own table, so a size below
52/// the first knot scales by the plain setting and one above the last keeps the
53/// last knot's ratio.
54#[derive(Clone, Copy, Debug, PartialEq)]
55pub struct FontScaleCurve {
56    scale: f32,
57    knots: [(f32, f32); MAX_FONT_SCALE_KNOTS],
58    len: usize,
59    fingerprint: u32,
60}
61
62impl Default for FontScaleCurve {
63    fn default() -> Self {
64        Self::linear(1.0)
65    }
66}
67
68impl FontScaleCurve {
69    /// The plain multiplier: what every platform without a conversion table
70    /// does, and what Android itself did before it had one.
71    pub const fn linear(scale: f32) -> Self {
72        Self {
73            scale,
74            knots: [(0.0, 0.0); MAX_FONT_SCALE_KNOTS],
75            len: 0,
76            fingerprint: 0,
77        }
78    }
79
80    /// A curve through `samples`, which are `(sp, dp)` pairs read from the
81    /// platform in ascending sp order.
82    ///
83    /// Samples that lie on the straight line between their neighbours are
84    /// dropped, so a densely sampled table comes back as the handful of points
85    /// that actually bend. Anything the platform could not have produced — an
86    /// empty or unsorted set, a non-finite or non-positive value, or more bends
87    /// than [`MAX_FONT_SCALE_KNOTS`] — is refused, and the caller gets the
88    /// multiplier rather than a curve nobody measured.
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        let mut curve = Self::linear(scale);
94        for (index, knot) in kept.iter().enumerate() {
95            curve.knots[index] = *knot;
96        }
97        curve.len = kept.len();
98        curve.fingerprint = fingerprint(scale, &kept);
99        curve
100    }
101
102    /// The setting itself — what the user chose, whatever the table then does
103    /// with an individual size. This is the number to report, not the number to
104    /// multiply by.
105    pub fn scale(self) -> f32 {
106        self.scale
107    }
108
109    /// Whether this is the plain multiplier, with no table behind it.
110    pub fn is_linear(self) -> bool {
111        self.len == 0
112    }
113
114    /// Whether resolving an `Sp` through this curve changes nothing at all.
115    ///
116    /// Asked of the conversion and not of the representation: a platform that
117    /// hands back a table while the setting sits at 1.0 hands back a table
118    /// whose every knot maps a size to itself, and that is still nothing to do.
119    pub fn is_identity(self) -> bool {
120        if self.len == 0 {
121            return (self.scale - 1.0).abs() <= f32::EPSILON;
122        }
123        self.knots[..self.len]
124            .iter()
125            .all(|(sp, dp)| (dp - sp).abs() <= COLLINEAR_EPSILON_DP)
126    }
127
128    /// The knots, ascending by sp. Empty for a linear curve.
129    pub fn knots(self) -> [(f32, f32); MAX_FONT_SCALE_KNOTS] {
130        self.knots
131    }
132
133    /// How many of [`FontScaleCurve::knots`] are used.
134    pub fn knot_count(self) -> usize {
135        self.len
136    }
137
138    /// A cheap identity for cache keys: two curves that convert identically
139    /// share it, and one that does not is overwhelmingly unlikely to.
140    pub fn fingerprint(self) -> u32 {
141        self.fingerprint ^ self.scale.to_bits()
142    }
143
144    /// A size in scale-independent pixels, in dp.
145    ///
146    /// This is `FontScaleConverterImpl.convertSpToDp`: the sign is carried
147    /// separately, an exact hit on a knot returns that knot, a size outside the
148    /// table scales by the ratio of the nearest end, and everything between two
149    /// knots is interpolated.
150    pub fn sp_to_dp(self, sp: f32) -> f32 {
151        if !sp.is_finite() {
152            return sp;
153        }
154        if self.len == 0 {
155            return sp * self.scale;
156        }
157        let magnitude = sp.abs();
158        let sign = if sp.is_sign_negative() { -1.0 } else { 1.0 };
159        let knots = &self.knots[..self.len];
160        let (first_sp, first_dp) = knots[0];
161        if magnitude <= first_sp {
162            return sign * magnitude * (first_dp / first_sp);
163        }
164        let (last_sp, last_dp) = knots[self.len - 1];
165        if magnitude >= last_sp {
166            return sign * magnitude * (last_dp / last_sp);
167        }
168        for window in knots.windows(2) {
169            let (low_sp, low_dp) = window[0];
170            let (high_sp, high_dp) = window[1];
171            if magnitude <= high_sp {
172                let t = (magnitude - low_sp) / (high_sp - low_sp);
173                return sign * (low_dp + (high_dp - low_dp) * t);
174            }
175        }
176        sign * magnitude * (last_dp / last_sp)
177    }
178}
179
180/// Drops the samples that sit on the line between their neighbours, and refuses
181/// a set the platform could not have produced.
182fn compress(samples: &[(f32, f32)]) -> Option<Vec<(f32, f32)>> {
183    if samples.len() < 2 {
184        return None;
185    }
186    let mut previous_sp = 0.0f32;
187    for (sp, dp) in samples {
188        if !sp.is_finite() || !dp.is_finite() || *sp <= previous_sp || *dp <= 0.0 {
189            return None;
190        }
191        previous_sp = *sp;
192    }
193    let mut kept: Vec<(f32, f32)> = Vec::with_capacity(samples.len());
194    kept.push(samples[0]);
195    for index in 1..samples.len() - 1 {
196        let (low_sp, low_dp) = *kept.last().expect("the first sample was pushed");
197        let (sp, dp) = samples[index];
198        let (high_sp, high_dp) = samples[index + 1];
199        let t = (sp - low_sp) / (high_sp - low_sp);
200        let straight = low_dp + (high_dp - low_dp) * t;
201        if (dp - straight).abs() > COLLINEAR_EPSILON_DP {
202            kept.push(samples[index]);
203        }
204    }
205    kept.push(samples[samples.len() - 1]);
206    if kept.len() > MAX_FONT_SCALE_KNOTS {
207        return None;
208    }
209    Some(kept)
210}
211
212fn fingerprint(scale: f32, knots: &[(f32, f32)]) -> u32 {
213    let mut hash = 2166136261u32;
214    let mut mix = |bits: u32| {
215        hash ^= bits;
216        hash = hash.wrapping_mul(16777619);
217    };
218    mix(scale.to_bits());
219    for (sp, dp) in knots {
220        mix(sp.to_bits());
221        mix(dp.to_bits());
222    }
223    hash
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    /// The curve Android 14 applies at font scale 1.24 on a density-2 display,
231    /// in dp, read off `TypedValue.applyDimension(COMPLEX_UNIT_SP, ..)` on a
232    /// Wear OS 5 emulator. `androidx.compose.ui.unit.Density(context).toPx()`
233    /// returns the same value for every one of these, so this is what the Kotlin
234    /// build measures with as well.
235    const PLATFORM_124: [(f32, f32); 10] = [
236        (8.0, 9.92),
237        (10.0, 12.4),
238        (12.0, 14.88),
239        (14.0, 17.84),
240        (16.0, 19.36),
241        (18.0, 20.88),
242        (20.0, 22.88),
243        (24.0, 25.92),
244        (30.0, 30.0),
245        (100.0, 100.0),
246    ];
247
248    fn platform_124() -> FontScaleCurve {
249        FontScaleCurve::from_samples(1.24, &PLATFORM_124)
250    }
251
252    #[test]
253    fn a_curve_with_no_knots_multiplies() {
254        let curve = FontScaleCurve::linear(1.24);
255        assert!(curve.is_linear());
256        assert_eq!(curve.sp_to_dp(13.0), 13.0 * 1.24);
257        assert_eq!(curve.sp_to_dp(0.4), 0.4 * 1.24);
258        assert_eq!(curve.scale(), 1.24);
259    }
260
261    #[test]
262    fn the_identity_curve_leaves_a_size_alone() {
263        assert!(FontScaleCurve::linear(1.0).is_identity());
264        assert!(!FontScaleCurve::linear(1.24).is_identity());
265        assert!(!platform_124().is_identity());
266        // A platform that answers with a table while the setting is 1.0 has
267        // still answered "nothing changes", and the fast path has to see that.
268        let flat: Vec<(f32, f32)> = (1..=40).map(|sp| (sp as f32, sp as f32)).collect();
269        assert!(FontScaleCurve::from_samples(1.0, &flat).is_identity());
270    }
271
272    #[test]
273    fn the_platform_curve_is_reproduced_at_every_size_the_platform_was_asked() {
274        // Not just at the knots: these are the sizes a Wear type scale actually
275        // names, and each is the number the emulator returned.
276        let curve = platform_124();
277        for (sp, expected_px) in [
278            (0.4f32, 0.992f32),
279            (12.0, 29.76),
280            (13.0, 32.72),
281            (14.0, 35.68),
282            (15.0, 37.2),
283            (16.0, 38.72),
284            (18.0, 41.76),
285            (19.0, 43.76),
286            (20.0, 45.76),
287            (24.0, 51.84),
288            (30.0, 60.0),
289            (40.0, 80.0),
290            (100.0, 200.0),
291        ] {
292            let px = curve.sp_to_dp(sp) * 2.0;
293            assert!(
294                (px - expected_px).abs() < 1.0e-3,
295                "{sp}sp resolved to {px}px where the platform answered {expected_px}px",
296            );
297        }
298    }
299
300    #[test]
301    fn the_thirteen_sp_secondary_label_is_where_multiplying_goes_wrong() {
302        // The size behind the defect this curve exists for: Wear Material 3's
303        // `labelSmall`, which a Settings chip puts its secondary line in. A
304        // multiplier makes it 32.24 px and the platform makes it 32.72, and the
305        // 1.5% is the difference between one line and two in a 326 px column.
306        let curve = platform_124();
307        assert!((curve.sp_to_dp(13.0) * 2.0 - 32.72).abs() < 1.0e-3);
308        assert!((FontScaleCurve::linear(1.24).sp_to_dp(13.0) * 2.0 - 32.24).abs() < 1.0e-3);
309    }
310
311    #[test]
312    fn collinear_samples_are_dropped_and_the_curve_still_answers_the_same() {
313        let curve = platform_124();
314        // 10 sits on the line from 8 to 12 and 16 on the line from 14 to 18, so
315        // eight of the platform's ten knots survive.
316        assert_eq!(curve.knot_count(), 8);
317        let dense: Vec<(f32, f32)> = (1..=120)
318            .map(|sp| {
319                let sp = sp as f32;
320                (sp, curve.sp_to_dp(sp))
321            })
322            .collect();
323        let resampled = FontScaleCurve::from_samples(1.24, &dense);
324        for step in 1..=1200 {
325            let sp = step as f32 * 0.1;
326            assert!(
327                (resampled.sp_to_dp(sp) - curve.sp_to_dp(sp)).abs() < 1.0e-3,
328                "{sp}sp: {} against {}",
329                resampled.sp_to_dp(sp),
330                curve.sp_to_dp(sp),
331            );
332        }
333    }
334
335    #[test]
336    fn samples_the_platform_could_not_have_produced_fall_back_to_multiplying() {
337        for samples in [
338            &[][..],
339            &[(12.0, 14.88)][..],
340            &[(12.0, 14.88), (12.0, 15.0)][..],
341            &[(14.0, 17.84), (12.0, 14.88)][..],
342            &[(12.0, f32::NAN), (14.0, 17.84)][..],
343            &[(12.0, 0.0), (14.0, 17.84)][..],
344        ] {
345            let curve = FontScaleCurve::from_samples(1.24, samples);
346            assert!(curve.is_linear(), "{samples:?} was accepted");
347            assert_eq!(curve.sp_to_dp(13.0), 13.0 * 1.24);
348        }
349    }
350
351    #[test]
352    fn more_bends_than_there_are_knots_falls_back_rather_than_truncating() {
353        // A truncated curve answers plausibly and wrongly, which is worse than
354        // answering the way a platform with no table would.
355        let zigzag: Vec<(f32, f32)> = (1..=40)
356            .map(|sp| {
357                let sp = sp as f32;
358                (sp, sp * if sp as i32 % 2 == 0 { 1.5 } else { 1.2 })
359            })
360            .collect();
361        assert!(FontScaleCurve::from_samples(1.24, &zigzag).is_linear());
362    }
363
364    #[test]
365    fn a_negative_size_keeps_its_sign() {
366        let curve = platform_124();
367        assert!((curve.sp_to_dp(-13.0) + curve.sp_to_dp(13.0)).abs() < 1.0e-6);
368        assert!(FontScaleCurve::linear(1.24).sp_to_dp(-13.0) < 0.0);
369    }
370
371    #[test]
372    fn a_size_that_is_not_a_number_comes_back_unchanged() {
373        let curve = platform_124();
374        assert!(curve.sp_to_dp(f32::NAN).is_nan());
375        assert_eq!(curve.sp_to_dp(f32::INFINITY), f32::INFINITY);
376    }
377
378    #[test]
379    fn curves_that_convert_differently_do_not_share_a_fingerprint() {
380        let platform = platform_124();
381        assert_ne!(
382            platform.fingerprint(),
383            FontScaleCurve::linear(1.24).fingerprint()
384        );
385        assert_ne!(
386            FontScaleCurve::linear(1.0).fingerprint(),
387            FontScaleCurve::linear(1.24).fingerprint()
388        );
389        assert_eq!(platform.fingerprint(), platform_124().fingerprint());
390    }
391}