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)]
255#[path = "tests/unit_tests.rs"]
256mod tests;