Skip to main content

cranpose_ui_graphics/
unit.rs

1//! Length units: [`Dp`], [`Sp`], [`Px`], and the [`Density`] that converts
2//! between them.
3//!
4//! This mirrors Jetpack Compose's `Dp`/`TextUnit`/pixel split: a layout
5//! author reasons in [`Dp`] (density-independent) and [`Sp`] (scale-
6//! independent, for text), never in a raw device pixel count, and the only
7//! way from either into [`Px`] is through an explicit [`Density`]. Neither
8//! [`Dp`] nor [`Sp`] implements `From<Px>` (nor the reverse for [`Dp`]/
9//! [`Sp`] into [`Px`] without a density), so a call site cannot hand a
10//! pixel count to a `Dp`-typed parameter and have it silently compile:
11//!
12//! ```compile_fail
13//! use cranpose_ui_graphics::{Dp, Px};
14//!
15//! fn takes_dp(_padding: impl Into<Dp>) {}
16//!
17//! let measured = Px(16.0);
18//! takes_dp(measured); // Px has no `Into<Dp>` impl -- this does not compile.
19//! ```
20//!
21//! Going the other way requires stating the grid:
22//!
23//! ```
24//! use cranpose_ui_graphics::{Density, Dp};
25//!
26//! let padding = Dp(16.0);
27//! let px = padding.to_px(Density::from_scale(2.0));
28//! assert_eq!(px.0, 32.0);
29//! ```
30
31use std::ops::{Add, Div, Mul, Neg, Sub};
32
33/// The device pixel grid a [`Dp`] or [`Sp`] value is measured against: how
34/// many device pixels one [`Dp`] is worth, and how far the platform's text-
35/// size setting scales an [`Sp`] beyond that.
36///
37/// This is the pure-data half of Compose's `Density`. The composition-aware
38/// wrapper that reads the ambient grid from a running app and reacts to
39/// [`CompositionLocalProvider`](https://docs.rs/cranpose-core)-style
40/// overrides lives above this crate (`cranpose-ui`'s `Density`, which cannot
41/// live here without an upward dependency on `cranpose-ui`) and converts
42/// into this type at the boundary.
43#[derive(Clone, Copy, Debug, PartialEq)]
44pub struct Density {
45    /// Device pixels per [`Dp`].
46    pub scale: f32,
47    /// The user's text-size setting, applied on top of `scale` for [`Sp`].
48    pub font_scale: f32,
49}
50
51impl Density {
52    /// One layout point per device pixel, no extra text scaling -- the grid
53    /// a test or a golden reaches for when the host's real grid does not
54    /// matter to what it is checking.
55    pub const IDENTITY: Self = Self {
56        scale: 1.0,
57        font_scale: 1.0,
58    };
59
60    pub fn new(scale: f32, font_scale: f32) -> Self {
61        Self { scale, font_scale }
62    }
63
64    /// A grid whose text does not follow a separate font-scale setting --
65    /// what a caller converting a length rather than a text size reaches
66    /// for, since a length must not move when the user's text size does.
67    pub fn from_scale(scale: f32) -> Self {
68        Self::new(scale, 1.0)
69    }
70}
71
72impl Default for Density {
73    fn default() -> Self {
74        Self::IDENTITY
75    }
76}
77
78macro_rules! scalar_unit {
79    ($name:ident, $doc:literal) => {
80        #[doc = $doc]
81        #[derive(Clone, Copy, Debug, Default, PartialEq, PartialOrd)]
82        pub struct $name(pub f32);
83
84        impl $name {
85            pub const ZERO: Self = Self(0.0);
86
87            /// The larger of two lengths in the same unit. Comparing across
88            /// units (a `Dp` against an `Sp`) is exactly the mistake this
89            /// module exists to make impossible, so this does not accept one.
90            pub fn max(self, other: Self) -> Self {
91                Self(self.0.max(other.0))
92            }
93
94            /// The smaller of two lengths in the same unit.
95            pub fn min(self, other: Self) -> Self {
96                Self(self.0.min(other.0))
97            }
98        }
99
100        impl From<f32> for $name {
101            fn from(value: f32) -> Self {
102                Self(value)
103            }
104        }
105
106        impl From<i32> for $name {
107            fn from(value: i32) -> Self {
108                Self(value as f32)
109            }
110        }
111
112        impl Add for $name {
113            type Output = Self;
114            fn add(self, rhs: Self) -> Self {
115                Self(self.0 + rhs.0)
116            }
117        }
118
119        impl Sub for $name {
120            type Output = Self;
121            fn sub(self, rhs: Self) -> Self {
122                Self(self.0 - rhs.0)
123            }
124        }
125
126        impl Neg for $name {
127            type Output = Self;
128            fn neg(self) -> Self {
129                Self(-self.0)
130            }
131        }
132
133        impl Mul<f32> for $name {
134            type Output = Self;
135            fn mul(self, rhs: f32) -> Self {
136                Self(self.0 * rhs)
137            }
138        }
139
140        impl Div<f32> for $name {
141            type Output = Self;
142            fn div(self, rhs: f32) -> Self {
143                Self(self.0 / rhs)
144            }
145        }
146    };
147}
148
149scalar_unit!(
150    Dp,
151    "Density-independent pixels: a layout length that reads the same on \
152     every device regardless of its pixel density."
153);
154scalar_unit!(
155    Sp,
156    "Scale-independent pixels: a text size that follows both the device's \
157     pixel density and the user's font-scale accessibility setting. \
158     Distinct from `Dp` because a length must not move when the user's \
159     text-size setting does, and a text size must."
160);
161scalar_unit!(
162    Px,
163    "A device pixel. Renderer-facing surfaces, hit-testing, and \
164     framebuffer coordinates read and write this; a layout author reaches \
165     for `Dp`/`Sp` instead, and can only get here through an explicit \
166     `Density`."
167);
168
169impl Dp {
170    /// Converts to the actual device pixel count on `density`'s grid.
171    pub fn to_px(self, density: Density) -> Px {
172        Px(self.0 * density.scale)
173    }
174
175    /// Recovers the `Dp` that produced `px` on `density`'s grid.
176    pub fn from_px(px: Px, density: Density) -> Self {
177        Self(px.0 / density.scale)
178    }
179}
180
181impl Sp {
182    /// Converts to the actual device pixel count on `density`'s grid,
183    /// scaled by the user's font-scale setting on top of pixel density.
184    pub fn to_px(self, density: Density) -> Px {
185        Px(self.0 * density.scale * density.font_scale)
186    }
187
188    /// Recovers the `Sp` that produced `px` on `density`'s grid.
189    pub fn from_px(px: Px, density: Density) -> Self {
190        Self(px.0 / (density.scale * density.font_scale))
191    }
192}
193
194impl Px {
195    /// Recovers the layout length that produced this pixel count on
196    /// `density`'s grid.
197    pub fn to_dp(self, density: Density) -> Dp {
198        Dp(self.0 / density.scale)
199    }
200}
201
202/// Reads `16.0.dp()` at a call site rather than `Dp(16.0)`, matching
203/// Kotlin's `Float.dp`/`Int.dp` extension properties.
204pub trait DpExt {
205    fn dp(self) -> Dp;
206}
207
208impl DpExt for f32 {
209    fn dp(self) -> Dp {
210        Dp(self)
211    }
212}
213
214impl DpExt for i32 {
215    fn dp(self) -> Dp {
216        Dp(self as f32)
217    }
218}
219
220/// Reads `16.0.sp()` at a call site rather than `Sp(16.0)`.
221pub trait SpExt {
222    fn sp(self) -> Sp;
223}
224
225impl SpExt for f32 {
226    fn sp(self) -> Sp {
227        Sp(self)
228    }
229}
230
231impl SpExt for i32 {
232    fn sp(self) -> Sp {
233        Sp(self as f32)
234    }
235}
236
237/// Reads `16.0.px()` at a call site rather than `Px(16.0)`.
238pub trait PxExt {
239    fn px(self) -> Px;
240}
241
242impl PxExt for f32 {
243    fn px(self) -> Px {
244        Px(self)
245    }
246}
247
248impl PxExt for i32 {
249    fn px(self) -> Px {
250        Px(self as f32)
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    /// The two directions have to be each other's inverse, or a value that
259    /// made a round trip through the platform -- a touch slop measured in
260    /// pixels, stored as `Dp`, applied back in pixels -- comes back a
261    /// different size on every screen but the one it was written on.
262    #[test]
263    fn density_independent_pixels_round_trip_through_a_density() {
264        for scale in [1.0f32, 1.5, 2.0, 3.0] {
265            let density = Density::from_scale(scale);
266            let dp = Dp(24.0);
267            assert_eq!(dp.to_px(density), Px(24.0 * scale));
268            assert_eq!(Dp::from_px(dp.to_px(density), density), dp);
269        }
270    }
271
272    /// Pinned at a non-1.0 density so a regression that drops the `scale`
273    /// factor (or silently treats `Dp` as already being in pixels) fails
274    /// this test rather than only showing up as a soft, mis-sized edge on a
275    /// real high-density screen.
276    #[test]
277    fn a_dp_length_is_pinned_at_a_non_identity_density() {
278        let density = Density::from_scale(2.5);
279        assert_eq!(Dp(16.0).to_px(density), Px(40.0));
280        assert_eq!(Px(40.0).to_dp(density), Dp(16.0));
281    }
282
283    /// Text carries the user's font-scale setting as well as the screen's
284    /// density, and both have to survive the trip.
285    #[test]
286    fn scale_independent_pixels_round_trip_through_density_and_font_scale() {
287        for scale in [1.0f32, 2.0, 3.0] {
288            for font_scale in [0.85f32, 1.0, 1.3] {
289                let density = Density::new(scale, font_scale);
290                let sp = Sp(16.0);
291                assert_eq!(sp.to_px(density), Px(16.0 * scale * font_scale));
292                assert_eq!(Sp::from_px(sp.to_px(density), density), sp);
293            }
294        }
295    }
296
297    /// Pinned at a non-1.0 density and a non-1.0 font scale together, since
298    /// either one alone can hide a regression that mixes up which factor
299    /// applies to which unit.
300    #[test]
301    fn an_sp_length_is_pinned_at_a_non_identity_density_and_font_scale() {
302        let density = Density::new(2.0, 1.25);
303        assert_eq!(Sp(16.0).to_px(density), Px(40.0));
304    }
305
306    #[test]
307    fn dp_arithmetic_matches_plain_float_arithmetic() {
308        assert_eq!(Dp(4.0) + Dp(2.0), Dp(6.0));
309        assert_eq!(Dp(4.0) - Dp(2.0), Dp(2.0));
310        assert_eq!(Dp(4.0) * 2.5, Dp(10.0));
311        assert_eq!(Dp(10.0) / 4.0, Dp(2.5));
312        assert_eq!(-Dp(4.0), Dp(-4.0));
313        assert_eq!(Dp(4.0).max(Dp(9.0)), Dp(9.0));
314        assert_eq!(Dp(4.0).min(Dp(9.0)), Dp(4.0));
315    }
316
317    #[test]
318    fn literal_and_extension_constructors_agree() {
319        assert_eq!(Dp::from(16.0f32), Dp(16.0));
320        assert_eq!(Dp::from(16), Dp(16.0));
321        assert_eq!(16.0.dp(), Dp(16.0));
322        assert_eq!(16.sp(), Sp(16.0));
323        assert_eq!(16.px(), Px(16.0));
324    }
325
326    /// `impl Into<Dp>` is what lets a widget/modifier parameter accept both
327    /// a bare literal and an explicit `.dp()` call; this exercises exactly
328    /// that generic boundary rather than the concrete type.
329    #[test]
330    fn into_dp_accepts_a_bare_literal_and_an_explicit_conversion() {
331        fn padding(value: impl Into<Dp>) -> Dp {
332            value.into()
333        }
334
335        assert_eq!(padding(16.0), Dp(16.0));
336        assert_eq!(padding(16), Dp(16.0));
337        assert_eq!(padding(16.0.dp()), Dp(16.0));
338    }
339}