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