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    /// Creates a density from device pixels per `Dp` and the user's text-size multiplier.
61    pub fn new(scale: f32, font_scale: f32) -> Self {
62        Self { scale, font_scale }
63    }
64
65    /// A grid whose text does not follow a separate font-scale setting --
66    /// what a caller converting a length rather than a text size reaches
67    /// for, since a length must not move when the user's text size does.
68    pub fn from_scale(scale: f32) -> Self {
69        Self::new(scale, 1.0)
70    }
71}
72
73impl Default for Density {
74    fn default() -> Self {
75        Self::IDENTITY
76    }
77}
78
79macro_rules! scalar_unit {
80    ($name:ident, $doc:literal) => {
81        #[doc = $doc]
82        #[derive(Clone, Copy, Debug, Default, PartialEq, PartialOrd)]
83        pub struct $name(pub f32);
84
85        impl $name {
86            /// A length of zero.
87            pub const ZERO: Self = Self(0.0);
88
89            /// The larger of two lengths in the same unit. Comparing across
90            /// units (a `Dp` against an `Sp`) is exactly the mistake this
91            /// module exists to make impossible, so this does not accept one.
92            pub fn max(self, other: Self) -> Self {
93                Self(self.0.max(other.0))
94            }
95
96            /// The smaller of two lengths in the same unit.
97            pub fn min(self, other: Self) -> Self {
98                Self(self.0.min(other.0))
99            }
100        }
101
102        impl From<f32> for $name {
103            fn from(value: f32) -> Self {
104                Self(value)
105            }
106        }
107
108        impl From<i32> for $name {
109            fn from(value: i32) -> Self {
110                Self(value as f32)
111            }
112        }
113
114        impl Add for $name {
115            type Output = Self;
116            fn add(self, rhs: Self) -> Self {
117                Self(self.0 + rhs.0)
118            }
119        }
120
121        impl Sub for $name {
122            type Output = Self;
123            fn sub(self, rhs: Self) -> Self {
124                Self(self.0 - rhs.0)
125            }
126        }
127
128        impl Neg for $name {
129            type Output = Self;
130            fn neg(self) -> Self {
131                Self(-self.0)
132            }
133        }
134
135        impl Mul<f32> for $name {
136            type Output = Self;
137            fn mul(self, rhs: f32) -> Self {
138                Self(self.0 * rhs)
139            }
140        }
141
142        impl Div<f32> for $name {
143            type Output = Self;
144            fn div(self, rhs: f32) -> Self {
145                Self(self.0 / rhs)
146            }
147        }
148    };
149}
150
151scalar_unit!(
152    Dp,
153    "Density-independent pixels: a layout length that reads the same on \
154     every device regardless of its pixel density."
155);
156scalar_unit!(
157    Sp,
158    "Scale-independent pixels: a text size that follows both the device's \
159     pixel density and the user's font-scale accessibility setting. \
160     Distinct from `Dp` because a length must not move when the user's \
161     text-size setting does, and a text size must."
162);
163scalar_unit!(
164    Px,
165    "A device pixel. Renderer-facing surfaces, hit-testing, and \
166     framebuffer coordinates read and write this; a layout author reaches \
167     for `Dp`/`Sp` instead, and can only get here through an explicit \
168     `Density`."
169);
170
171impl Dp {
172    /// Converts to the actual device pixel count on `density`'s grid.
173    pub fn to_px(self, density: Density) -> Px {
174        Px(self.0 * density.scale)
175    }
176
177    /// Recovers the `Dp` that produced `px` on `density`'s grid.
178    pub fn from_px(px: Px, density: Density) -> Self {
179        Self(px.0 / density.scale)
180    }
181}
182
183impl Sp {
184    /// Converts to the actual device pixel count on `density`'s grid,
185    /// scaled by the user's font-scale setting on top of pixel density.
186    pub fn to_px(self, density: Density) -> Px {
187        Px(self.0 * density.scale * density.font_scale)
188    }
189
190    /// Recovers the `Sp` that produced `px` on `density`'s grid.
191    pub fn from_px(px: Px, density: Density) -> Self {
192        Self(px.0 / (density.scale * density.font_scale))
193    }
194}
195
196impl Px {
197    /// Recovers the layout length that produced this pixel count on
198    /// `density`'s grid.
199    pub fn to_dp(self, density: Density) -> Dp {
200        Dp(self.0 / density.scale)
201    }
202}
203
204/// Reads `16.0.dp()` at a call site rather than `Dp(16.0)`, matching
205/// Kotlin's `Float.dp`/`Int.dp` extension properties.
206pub trait DpExt {
207    /// Wraps this number as a [`Dp`] length.
208    fn dp(self) -> Dp;
209}
210
211impl DpExt for f32 {
212    fn dp(self) -> Dp {
213        Dp(self)
214    }
215}
216
217impl DpExt for i32 {
218    fn dp(self) -> Dp {
219        Dp(self as f32)
220    }
221}
222
223/// Reads `16.0.sp()` at a call site rather than `Sp(16.0)`.
224pub trait SpExt {
225    /// Wraps this number as an [`Sp`] length.
226    fn sp(self) -> Sp;
227}
228
229impl SpExt for f32 {
230    fn sp(self) -> Sp {
231        Sp(self)
232    }
233}
234
235impl SpExt for i32 {
236    fn sp(self) -> Sp {
237        Sp(self as f32)
238    }
239}
240
241/// Reads `16.0.px()` at a call site rather than `Px(16.0)`.
242pub trait PxExt {
243    /// Wraps this number as a [`Px`] length.
244    fn px(self) -> Px;
245}
246
247impl PxExt for f32 {
248    fn px(self) -> Px {
249        Px(self)
250    }
251}
252
253impl PxExt for i32 {
254    fn px(self) -> Px {
255        Px(self as f32)
256    }
257}
258
259#[cfg(test)]
260#[path = "tests/unit_tests.rs"]
261mod tests;