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;