cranpose_ui/density.rs
1//! Integral layout, the way Compose does it.
2//!
3//! Cranpose's layout is continuous: [`Constraints`](cranpose_ui_layout::Constraints)
4//! is four `f32`, a child is placed at an `f32` offset, and nothing in
5//! `cranpose-ui-layout` rounds. Compose's is integral — `Dp.roundToPx()` turns
6//! every padding, minimum size and spacing into an `Int` before anything is
7//! measured, and `Placeable.placeAt` takes an `IntOffset`.
8//!
9//! That difference is not cosmetic. Half a pixel of drift moves a line of
10//! text's antialiasing onto the next row, and it puts a soft edge everywhere
11//! the Compose build draws a crisp one. A widget that must draw a crisp edge
12//! therefore rounds its own sizes and offsets through [`Density`] rather than
13//! trusting the ambient float arithmetic. The Wear widgets do this throughout;
14//! the rest of the library does not, which is where a soft edge still comes
15//! from.
16//!
17//! One length unit runs through all of it: a Cranpose layout point is a dp.
18//! `Density` converts to and from device pixels and does the rounding on
19//! the pixel side, which is the only side where rounding means anything.
20
21use cranpose_core::{compositionLocalOf, CompositionLocal, CompositionLocalProvider};
22
23use crate::{
24 font_scale::FontScaleCurve,
25 render_state::{current_density, current_font_scale_curve},
26 round_scaling_list::round_to_px,
27};
28
29/// The device pixel grid a layout measures against: how many device pixels
30/// a layout point is worth, and how the platform converts a text size.
31///
32/// This is Compose's `Density`, and [`local_density`] is its `LocalDensity`.
33///
34/// Read it in a composable through [`density`], which subscribes to the
35/// composition local rather than to process-wide state, so a subtree given its
36/// own density recomposes only what read it. Tests and goldens construct one
37/// directly so a measurement does not depend on the machine it runs on.
38#[derive(Clone, Copy, Debug, PartialEq)]
39pub struct Density {
40 density: f32,
41 font_scale: FontScaleCurve,
42}
43
44impl Density {
45 /// The grid the host installed on this shell.
46 ///
47 /// This is the composition local's default, not the way a composable should
48 /// read the density -- use [`density`] for that, so a subtree provided its
49 /// own grid is honoured.
50 pub fn from_host() -> Self {
51 // A composition can run without a shell -- a test, a golden, a headless
52 // measurement -- and asking the render state for a grid there panics.
53 // The unit grid is the honest answer when no host has stated one.
54 if crate::render_state::has_current_app_context() {
55 Self::with_curve(current_density(), current_font_scale_curve())
56 } else {
57 Self::default()
58 }
59 }
60
61 /// A grid stated outright, for a test or a golden. The setting is taken as
62 /// a plain multiplier, which is what it is wherever the platform reports no
63 /// conversion of its own.
64 pub fn new(density: f32, font_scale: f32) -> Self {
65 let font_scale = if font_scale.is_finite() && font_scale > 0.0 {
66 font_scale
67 } else {
68 1.0
69 };
70 Self::with_curve(density, FontScaleCurve::linear(font_scale))
71 }
72
73 /// A grid whose text size follows the platform's own `Sp` conversion rather
74 /// than a multiplier. See [`crate::font_scale`].
75 pub fn with_curve(density: f32, font_scale: FontScaleCurve) -> Self {
76 Self {
77 density: if density.is_finite() && density > 0.0 {
78 density
79 } else {
80 1.0
81 },
82 font_scale,
83 }
84 }
85
86 /// Device pixels per layout point.
87 pub fn density(self) -> f32 {
88 self.density
89 }
90
91 /// The user's text size setting.
92 pub fn font_scale(self) -> f32 {
93 self.font_scale.scale()
94 }
95
96 /// The conversion the platform performs for a size in `Sp`.
97 pub fn font_scale_curve(self) -> FontScaleCurve {
98 self.font_scale
99 }
100
101 /// `Dp.roundToPx()`, expressed back in points: a dp length snapped to the
102 /// whole device pixel Compose would give it.
103 pub fn dp(self, value: f32) -> f32 {
104 round_to_px(value, self.density)
105 }
106
107 /// A text size in scale-independent pixels, snapped the same way.
108 ///
109 /// Anything that must not move when the user changes their text size is
110 /// measured with [`Density::dp`] instead — that is the whole difference
111 /// between the two.
112 pub fn sp(self, value: f32) -> f32 {
113 self.dp(self.font_scale.sp_to_dp(value))
114 }
115
116 /// A length snapped to the nearest whole device pixel, exact halves up.
117 pub fn round(self, value: f32) -> f32 {
118 round_to_px(value, self.density)
119 }
120
121 /// A length snapped down to a whole device pixel.
122 pub fn floor(self, value: f32) -> f32 {
123 if value.is_finite() {
124 (value * self.density).floor() / self.density
125 } else {
126 value
127 }
128 }
129
130 /// A length snapped up to a whole device pixel.
131 ///
132 /// This is what a line box does with its own height, and what a container
133 /// does with a content size it must not clip.
134 pub fn ceil(self, value: f32) -> f32 {
135 if value.is_finite() {
136 (value * self.density).ceil() / self.density
137 } else {
138 value
139 }
140 }
141
142 /// Points to device pixels.
143 pub fn to_px(self, points: f32) -> f32 {
144 points * self.density
145 }
146
147 /// Centres `content` in `available` the way Compose does: on a whole pixel.
148 ///
149 /// `Arrangement.Center` measures every child in whole pixels and rounds the
150 /// leading gap before placing any of them, so a column whose content is an
151 /// odd number of pixels tall starts on a pixel boundary rather than across
152 /// one. Halving in floats instead is the single easiest way to soften every
153 /// edge in a row.
154 pub fn centre(self, available: f32, content: f32) -> f32 {
155 self.round((available - content) * 0.5)
156 }
157}
158
159impl Default for Density {
160 fn default() -> Self {
161 Self::new(1.0, 1.0)
162 }
163}
164
165/// The production [`cranpose_ui_layout::MeasureScope`]: a grid captured at
166/// composition time, carried through measurement.
167///
168/// `MeasurePolicy::measure` runs with this as its scope, the way Compose's
169/// `MeasurePolicy.measure` runs with `MeasureScope` as its receiver. The grid
170/// is the [`LayoutNode`](crate::widgets::nodes::LayoutNode)'s own
171/// [`Density`] -- read at composition time by the `Layout` composable from
172/// [`density`], not the host default -- so a subtree given a different grid
173/// through [`ProvideDensity`] is measured on that grid.
174#[derive(Clone, Copy, Debug, PartialEq)]
175pub struct DensityMeasureScope {
176 density: Density,
177}
178
179impl DensityMeasureScope {
180 /// Wraps a captured [`Density`] as a measurement scope.
181 pub fn new(density: Density) -> Self {
182 Self { density }
183 }
184}
185
186impl cranpose_ui_layout::MeasureScope for DensityMeasureScope {
187 fn density(&self) -> f32 {
188 self.density.density()
189 }
190
191 fn font_scale(&self) -> f32 {
192 self.density.font_scale()
193 }
194}
195
196/// The [`CompositionLocal`] carrying the device pixel grid.
197///
198/// Compose's `LocalDensity`. Its default is whatever grid the host installed on
199/// this shell, so an application that never provides one still measures against
200/// the real screen; providing one scopes a different grid to a subtree, which
201/// is what a preview, a scaled container or a density-specific golden needs.
202pub fn local_density() -> CompositionLocal<Density> {
203 thread_local! {
204 static LOCAL: std::cell::RefCell<Option<CompositionLocal<Density>>> =
205 const { std::cell::RefCell::new(None) };
206 }
207 LOCAL.with(|cell| {
208 cell.borrow_mut()
209 .get_or_insert_with(|| compositionLocalOf(Density::from_host))
210 .clone()
211 })
212}
213
214/// The device pixel grid in force here.
215pub fn density() -> Density {
216 local_density().current()
217}
218
219/// Runs `content` on `density`.
220///
221/// Compose's `CompositionLocalProvider(LocalDensity provides ...)`. A preview, a
222/// scaled container or a golden that must not depend on the machine it runs on
223/// states its grid here instead of moving the whole shell onto it.
224#[allow(non_snake_case)]
225pub fn ProvideDensity(density: Density, content: impl FnOnce()) {
226 CompositionLocalProvider(vec![local_density().provides(density)], content);
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232
233 /// Density was a value read from the shell, so a subtree could not be
234 /// measured on a different grid. Reading it through the local is what lets a
235 /// preview or a golden state its own grid without moving the whole shell.
236 #[test]
237 fn a_provided_density_reaches_the_content_and_ends_with_it() {
238 use std::{cell::Cell, rc::Rc};
239
240 use cranpose_core::{location_key, Composition, MemoryApplier};
241
242 let mut composition = Composition::new(MemoryApplier::new());
243 let outer = Rc::new(Cell::new(0.0_f32));
244 let inside = Rc::new(Cell::new(0.0_f32));
245 let after = Rc::new(Cell::new(0.0_f32));
246
247 let key = location_key(file!(), line!(), column!());
248 {
249 let (outer, inside, after) = (Rc::clone(&outer), Rc::clone(&inside), Rc::clone(&after));
250 let mut render = move || {
251 outer.set(density().density());
252 ProvideDensity(Density::new(3.0, 1.0), || inside.set(density().density()));
253 after.set(density().density());
254 };
255 composition.render(key, &mut render).expect("render");
256 }
257
258 assert_eq!(inside.get(), 3.0, "the subtree reads the grid it was given");
259 assert_ne!(
260 outer.get(),
261 3.0,
262 "the provision does not escape upwards out of its content"
263 );
264 assert_eq!(
265 after.get(),
266 outer.get(),
267 "nor does it leak into what follows it"
268 );
269 }
270
271 #[test]
272 fn a_dp_length_lands_on_a_whole_device_pixel() {
273 let density = Density::new(2.0, 1.0);
274 assert_eq!(density.dp(13.3), 13.5);
275 assert_eq!(density.to_px(density.dp(13.3)), 27.0);
276 // An exact half goes up, as Kotlin's `roundToInt` does.
277 assert_eq!(density.dp(0.25), 0.5);
278 }
279
280 #[test]
281 fn a_text_size_moves_with_the_font_scale_and_a_dp_length_does_not() {
282 let density = Density::new(2.0, 1.24);
283 assert_eq!(density.dp(15.0), 15.0);
284 // 15sp at 1.24 is 18.6dp = 37.2px, which is 37px.
285 assert_eq!(density.to_px(density.sp(15.0)), 37.0);
286 }
287
288 #[test]
289 fn centring_puts_the_leading_gap_on_a_pixel_boundary() {
290 let density = Density::new(2.0, 1.0);
291 // 104px of room, 93px of content: 5.5px above, which rounds up to a
292 // whole 6px rather than landing across a pixel boundary.
293 let leading = density.centre(52.0, 46.5);
294 assert_eq!(density.to_px(leading), 6.0);
295 }
296
297 #[test]
298 fn floor_and_ceil_move_whole_pixels_only() {
299 let density = Density::new(2.0, 1.0);
300 assert_eq!(density.floor(13.3), 13.0);
301 assert_eq!(density.ceil(13.3), 13.5);
302 assert_eq!(density.ceil(13.5), 13.5);
303 }
304
305 #[test]
306 fn a_nonsense_grid_falls_back_to_one_rather_than_dividing_by_zero() {
307 let density = Density::new(0.0, f32::NAN);
308 assert_eq!(density.density(), 1.0);
309 assert_eq!(density.font_scale(), 1.0);
310 assert_eq!(density.dp(3.7), 4.0);
311 }
312}