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.
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;