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//! 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. 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
42const COLLINEAR_EPSILON_DP: f32 = 1.0e-3;
43
44/// The sp → dp conversion the platform performs, sampled from the platform.
45///
46/// With no knots this is `sp * scale`. With knots it is the piecewise-linear
47/// function through them, extended past both ends by the ratio of the knot on
48/// that end — which is how the platform extends its own table, so a size below
49/// the first knot scales by the plain setting and one above the last keeps the
50/// last knot's ratio.
51#[derive(Clone, Copy, Debug, PartialEq)]
52pub struct FontScaleCurve {
53    scale: f32,
54    knots: [(f32, f32); MAX_FONT_SCALE_KNOTS],
55    len: usize,
56    fingerprint: u32,
57}
58
59impl Default for FontScaleCurve {
60    fn default() -> Self {
61        Self::linear(1.0)
62    }
63}
64
65impl FontScaleCurve {
66    /// The plain multiplier: what every platform without a conversion table
67    /// does, and what Android itself did before it had one.
68    pub const fn linear(scale: f32) -> Self {
69        Self {
70            scale,
71            knots: [(0.0, 0.0); MAX_FONT_SCALE_KNOTS],
72            len: 0,
73            fingerprint: 0,
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.
86    pub fn from_samples(scale: f32, samples: &[(f32, f32)]) -> Self {
87        let Some(kept) = compress(samples) else {
88            return Self::linear(scale);
89        };
90        let mut curve = Self::linear(scale);
91        for (index, knot) in kept.iter().enumerate() {
92            curve.knots[index] = *knot;
93        }
94        curve.len = kept.len();
95        curve.fingerprint = fingerprint(scale, &kept);
96        curve
97    }
98
99    /// The setting itself — what the user chose, whatever the table then does
100    /// with an individual size. This is the number to report, not the number to
101    /// multiply by.
102    pub fn scale(self) -> f32 {
103        self.scale
104    }
105
106    /// Whether this is the plain multiplier, with no table behind it.
107    pub fn is_linear(self) -> bool {
108        self.len == 0
109    }
110
111    /// Whether resolving an `Sp` through this curve changes nothing at all.
112    ///
113    /// Asked of the conversion and not of the representation: a platform that
114    /// hands back a table while the setting sits at 1.0 hands back a table
115    /// whose every knot maps a size to itself, and that is still nothing to do.
116    pub fn is_identity(self) -> bool {
117        if self.len == 0 {
118            return (self.scale - 1.0).abs() <= f32::EPSILON;
119        }
120        self.knots[..self.len]
121            .iter()
122            .all(|(sp, dp)| (dp - sp).abs() <= COLLINEAR_EPSILON_DP)
123    }
124
125    /// The knots, ascending by sp. Empty for a linear curve.
126    pub fn knots(self) -> [(f32, f32); MAX_FONT_SCALE_KNOTS] {
127        self.knots
128    }
129
130    /// How many of [`FontScaleCurve::knots`] are used.
131    pub fn knot_count(self) -> usize {
132        self.len
133    }
134
135    /// A cheap identity for cache keys: two curves that convert identically
136    /// share it, and one that does not is overwhelmingly unlikely to.
137    pub fn fingerprint(self) -> u32 {
138        self.fingerprint ^ self.scale.to_bits()
139    }
140
141    /// A size in scale-independent pixels, in dp.
142    ///
143    /// This is `FontScaleConverterImpl.convertSpToDp`: the sign is carried
144    /// separately, an exact hit on a knot returns that knot, a size outside the
145    /// table scales by the ratio of the nearest end, and everything between two
146    /// knots is interpolated.
147    pub fn sp_to_dp(self, sp: f32) -> f32 {
148        if !sp.is_finite() {
149            return sp;
150        }
151        if self.len == 0 {
152            return sp * self.scale;
153        }
154        let magnitude = sp.abs();
155        let sign = if sp.is_sign_negative() { -1.0 } else { 1.0 };
156        let knots = &self.knots[..self.len];
157        let (first_sp, first_dp) = knots[0];
158        if magnitude <= first_sp {
159            return sign * magnitude * (first_dp / first_sp);
160        }
161        let (last_sp, last_dp) = knots[self.len - 1];
162        if magnitude >= last_sp {
163            return sign * magnitude * (last_dp / last_sp);
164        }
165        for window in knots.windows(2) {
166            let (low_sp, low_dp) = window[0];
167            let (high_sp, high_dp) = window[1];
168            if magnitude <= high_sp {
169                let t = (magnitude - low_sp) / (high_sp - low_sp);
170                return sign * (low_dp + (high_dp - low_dp) * t);
171            }
172        }
173        sign * magnitude * (last_dp / last_sp)
174    }
175}
176
177fn compress(samples: &[(f32, f32)]) -> Option<Vec<(f32, f32)>> {
178    if samples.len() < 2 {
179        return None;
180    }
181    let mut previous_sp = 0.0f32;
182    for (sp, dp) in samples {
183        if !sp.is_finite() || !dp.is_finite() || *sp <= previous_sp || *dp <= 0.0 {
184            return None;
185        }
186        previous_sp = *sp;
187    }
188    let mut kept: Vec<(f32, f32)> = Vec::with_capacity(samples.len());
189    kept.push(samples[0]);
190    for index in 1..samples.len() - 1 {
191        let (low_sp, low_dp) = *kept.last().expect("the first sample was pushed");
192        let (sp, dp) = samples[index];
193        let (high_sp, high_dp) = samples[index + 1];
194        let t = (sp - low_sp) / (high_sp - low_sp);
195        let straight = low_dp + (high_dp - low_dp) * t;
196        if (dp - straight).abs() > COLLINEAR_EPSILON_DP {
197            kept.push(samples[index]);
198        }
199    }
200    kept.push(samples[samples.len() - 1]);
201    if kept.len() > MAX_FONT_SCALE_KNOTS {
202        return None;
203    }
204    Some(kept)
205}
206
207fn fingerprint(scale: f32, knots: &[(f32, f32)]) -> u32 {
208    let mut hash = 2166136261u32;
209    let mut mix = |bits: u32| {
210        hash ^= bits;
211        hash = hash.wrapping_mul(16777619);
212    };
213    mix(scale.to_bits());
214    for (sp, dp) in knots {
215        mix(sp.to_bits());
216        mix(dp.to_bits());
217    }
218    hash
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    const PLATFORM_124: [(f32, f32); 10] = [
226        (8.0, 9.92),
227        (10.0, 12.4),
228        (12.0, 14.88),
229        (14.0, 17.84),
230        (16.0, 19.36),
231        (18.0, 20.88),
232        (20.0, 22.88),
233        (24.0, 25.92),
234        (30.0, 30.0),
235        (100.0, 100.0),
236    ];
237
238    fn platform_124() -> FontScaleCurve {
239        FontScaleCurve::from_samples(1.24, &PLATFORM_124)
240    }
241
242    #[test]
243    fn a_curve_with_no_knots_multiplies() {
244        let curve = FontScaleCurve::linear(1.24);
245        assert!(curve.is_linear());
246        assert_eq!(curve.sp_to_dp(13.0), 13.0 * 1.24);
247        assert_eq!(curve.sp_to_dp(0.4), 0.4 * 1.24);
248        assert_eq!(curve.scale(), 1.24);
249    }
250
251    #[test]
252    fn the_identity_curve_leaves_a_size_alone() {
253        assert!(FontScaleCurve::linear(1.0).is_identity());
254        assert!(!FontScaleCurve::linear(1.24).is_identity());
255        assert!(!platform_124().is_identity());
256        let flat: Vec<(f32, f32)> = (1..=40).map(|sp| (sp as f32, sp as f32)).collect();
257        assert!(FontScaleCurve::from_samples(1.0, &flat).is_identity());
258    }
259
260    #[test]
261    fn the_platform_curve_is_reproduced_at_every_size_the_platform_was_asked() {
262        let curve = platform_124();
263        for (sp, expected_px) in [
264            (0.4f32, 0.992f32),
265            (12.0, 29.76),
266            (13.0, 32.72),
267            (14.0, 35.68),
268            (15.0, 37.2),
269            (16.0, 38.72),
270            (18.0, 41.76),
271            (19.0, 43.76),
272            (20.0, 45.76),
273            (24.0, 51.84),
274            (30.0, 60.0),
275            (40.0, 80.0),
276            (100.0, 200.0),
277        ] {
278            let px = curve.sp_to_dp(sp) * 2.0;
279            assert!(
280                (px - expected_px).abs() < 1.0e-3,
281                "{sp}sp resolved to {px}px where the platform answered {expected_px}px",
282            );
283        }
284    }
285
286    #[test]
287    fn the_thirteen_sp_secondary_label_is_where_multiplying_goes_wrong() {
288        let curve = platform_124();
289        assert!((curve.sp_to_dp(13.0) * 2.0 - 32.72).abs() < 1.0e-3);
290        assert!((FontScaleCurve::linear(1.24).sp_to_dp(13.0) * 2.0 - 32.24).abs() < 1.0e-3);
291    }
292
293    #[test]
294    fn collinear_samples_are_dropped_and_the_curve_still_answers_the_same() {
295        let curve = platform_124();
296        assert_eq!(curve.knot_count(), 8);
297        let dense: Vec<(f32, f32)> = (1..=120)
298            .map(|sp| {
299                let sp = sp as f32;
300                (sp, curve.sp_to_dp(sp))
301            })
302            .collect();
303        let resampled = FontScaleCurve::from_samples(1.24, &dense);
304        for step in 1..=1200 {
305            let sp = step as f32 * 0.1;
306            assert!(
307                (resampled.sp_to_dp(sp) - curve.sp_to_dp(sp)).abs() < 1.0e-3,
308                "{sp}sp: {} against {}",
309                resampled.sp_to_dp(sp),
310                curve.sp_to_dp(sp),
311            );
312        }
313    }
314
315    #[test]
316    fn samples_the_platform_could_not_have_produced_fall_back_to_multiplying() {
317        for samples in [
318            &[][..],
319            &[(12.0, 14.88)][..],
320            &[(12.0, 14.88), (12.0, 15.0)][..],
321            &[(14.0, 17.84), (12.0, 14.88)][..],
322            &[(12.0, f32::NAN), (14.0, 17.84)][..],
323            &[(12.0, 0.0), (14.0, 17.84)][..],
324        ] {
325            let curve = FontScaleCurve::from_samples(1.24, samples);
326            assert!(curve.is_linear(), "{samples:?} was accepted");
327            assert_eq!(curve.sp_to_dp(13.0), 13.0 * 1.24);
328        }
329    }
330
331    #[test]
332    fn more_bends_than_there_are_knots_falls_back_rather_than_truncating() {
333        let zigzag: Vec<(f32, f32)> = (1..=40)
334            .map(|sp| {
335                let sp = sp as f32;
336                (sp, sp * if sp as i32 % 2 == 0 { 1.5 } else { 1.2 })
337            })
338            .collect();
339        assert!(FontScaleCurve::from_samples(1.24, &zigzag).is_linear());
340    }
341
342    #[test]
343    fn a_negative_size_keeps_its_sign() {
344        let curve = platform_124();
345        assert!((curve.sp_to_dp(-13.0) + curve.sp_to_dp(13.0)).abs() < 1.0e-6);
346        assert!(FontScaleCurve::linear(1.24).sp_to_dp(-13.0) < 0.0);
347    }
348
349    #[test]
350    fn a_size_that_is_not_a_number_comes_back_unchanged() {
351        let curve = platform_124();
352        assert!(curve.sp_to_dp(f32::NAN).is_nan());
353        assert_eq!(curve.sp_to_dp(f32::INFINITY), f32::INFINITY);
354    }
355
356    #[test]
357    fn curves_that_convert_differently_do_not_share_a_fingerprint() {
358        let platform = platform_124();
359        assert_ne!(
360            platform.fingerprint(),
361            FontScaleCurve::linear(1.24).fingerprint()
362        );
363        assert_ne!(
364            FontScaleCurve::linear(1.0).fingerprint(),
365            FontScaleCurve::linear(1.24).fingerprint()
366        );
367        assert_eq!(platform.fingerprint(), platform_124().fingerprint());
368    }
369}