Skip to main content

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::{CompositionLocal, CompositionLocalProvider, compositionLocalOf};
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        if crate::render_state::has_current_app_context() {
52            Self::with_curve(current_density(), current_font_scale_curve())
53        } else {
54            Self::default()
55        }
56    }
57
58    /// A grid stated outright, for a test or a golden. The setting is taken as
59    /// a plain multiplier, which is what it is wherever the platform reports no
60    /// conversion of its own.
61    pub fn new(density: f32, font_scale: f32) -> Self {
62        let font_scale = if font_scale.is_finite() && font_scale > 0.0 {
63            font_scale
64        } else {
65            1.0
66        };
67        Self::with_curve(density, FontScaleCurve::linear(font_scale))
68    }
69
70    /// A grid whose text size follows the platform's own `Sp` conversion rather
71    /// than a multiplier. See [`crate::font_scale`].
72    pub fn with_curve(density: f32, font_scale: FontScaleCurve) -> Self {
73        Self {
74            density: if density.is_finite() && density > 0.0 {
75                density
76            } else {
77                1.0
78            },
79            font_scale,
80        }
81    }
82
83    /// Device pixels per layout point.
84    pub fn density(self) -> f32 {
85        self.density
86    }
87
88    /// The user's text size setting.
89    pub fn font_scale(self) -> f32 {
90        self.font_scale.scale()
91    }
92
93    /// The conversion the platform performs for a size in `Sp`.
94    pub fn font_scale_curve(self) -> FontScaleCurve {
95        self.font_scale
96    }
97
98    /// `Dp.roundToPx()`, expressed back in points: a dp length snapped to the
99    /// whole device pixel Compose would give it.
100    pub fn dp(self, value: f32) -> f32 {
101        round_to_px(value, self.density)
102    }
103
104    /// A text size in scale-independent pixels, snapped the same way.
105    ///
106    /// Anything that must not move when the user changes their text size is
107    /// measured with [`Density::dp`] instead — that is the whole difference
108    /// between the two.
109    pub fn sp(self, value: f32) -> f32 {
110        self.dp(self.font_scale.sp_to_dp(value))
111    }
112
113    /// A length snapped to the nearest whole device pixel, exact halves up.
114    pub fn round(self, value: f32) -> f32 {
115        round_to_px(value, self.density)
116    }
117
118    /// A length snapped down to a whole device pixel.
119    pub fn floor(self, value: f32) -> f32 {
120        if value.is_finite() {
121            (value * self.density).floor() / self.density
122        } else {
123            value
124        }
125    }
126
127    /// A length snapped up to a whole device pixel.
128    ///
129    /// This is what a line box does with its own height, and what a container
130    /// does with a content size it must not clip.
131    pub fn ceil(self, value: f32) -> f32 {
132        if value.is_finite() {
133            (value * self.density).ceil() / self.density
134        } else {
135            value
136        }
137    }
138
139    /// Points to device pixels.
140    pub fn to_px(self, points: f32) -> f32 {
141        points * self.density
142    }
143
144    /// Centres `content` in `available` the way Compose does: on a whole pixel.
145    ///
146    /// `Arrangement.Center` measures every child in whole pixels and rounds the
147    /// leading gap before placing any of them, so a column whose content is an
148    /// odd number of pixels tall starts on a pixel boundary rather than across
149    /// one. Halving in floats instead is the single easiest way to soften every
150    /// edge in a row.
151    pub fn centre(self, available: f32, content: f32) -> f32 {
152        self.round((available - content) * 0.5)
153    }
154}
155
156impl Default for Density {
157    fn default() -> Self {
158        Self::new(1.0, 1.0)
159    }
160}
161
162/// The production [`cranpose_ui_layout::MeasureScope`]: a grid captured at
163/// composition time, carried through measurement.
164///
165/// `MeasurePolicy::measure` runs with this as its scope, the way Compose's
166/// `MeasurePolicy.measure` runs with `MeasureScope` as its receiver. The grid
167/// is the [`LayoutNode`](crate::widgets::nodes::LayoutNode)'s own
168/// [`Density`] -- read at composition time by the `Layout` composable from
169/// [`density`], not the host default -- so a subtree given a different grid
170/// through [`ProvideDensity`] is measured on that grid.
171#[derive(Clone, Copy, Debug, PartialEq)]
172pub struct DensityMeasureScope {
173    density: Density,
174}
175
176impl DensityMeasureScope {
177    /// Wraps a captured [`Density`] as a measurement scope.
178    pub fn new(density: Density) -> Self {
179        Self { density }
180    }
181}
182
183impl cranpose_ui_layout::MeasureScope for DensityMeasureScope {
184    fn density(&self) -> f32 {
185        self.density.density()
186    }
187
188    fn font_scale(&self) -> f32 {
189        self.density.font_scale()
190    }
191}
192
193/// The [`CompositionLocal`] carrying the device pixel grid.
194///
195/// Compose's `LocalDensity`. Its default is whatever grid the host installed on
196/// this shell, so an application that never provides one still measures against
197/// the real screen; providing one scopes a different grid to a subtree, which
198/// is what a preview, a scaled container or a density-specific golden needs.
199pub fn local_density() -> CompositionLocal<Density> {
200    thread_local! {
201        static LOCAL: std::cell::RefCell<Option<CompositionLocal<Density>>> =
202            const { std::cell::RefCell::new(None) };
203    }
204    LOCAL.with(|cell| {
205        cell.borrow_mut()
206            .get_or_insert_with(|| compositionLocalOf(Density::from_host))
207            .clone()
208    })
209}
210
211/// The device pixel grid in force here.
212pub fn density() -> Density {
213    local_density().current()
214}
215
216/// Runs `content` on `density`.
217///
218/// Compose's `CompositionLocalProvider(LocalDensity provides ...)`. A preview, a
219/// scaled container or a golden that must not depend on the machine it runs on
220/// states its grid here instead of moving the whole shell onto it.
221#[allow(non_snake_case)]
222#[track_caller]
223pub fn ProvideDensity(density: Density, content: impl FnOnce()) {
224    CompositionLocalProvider(vec![local_density().provides(density)], content);
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[test]
232    fn a_surviving_density_scope_keeps_its_entry_when_a_sibling_leaves() {
233        use std::{cell::Cell, rc::Rc};
234
235        use cranpose_core::{Composition, MemoryApplier, MutableState, location_key};
236
237        let mut composition = Composition::new(MemoryApplier::new());
238        let runtime = composition.runtime_handle();
239        let with_leading = MutableState::with_runtime(true, runtime.clone());
240        let survivor = MutableState::with_runtime(3.0_f32, runtime);
241        let seen = Rc::new(Cell::new(0.0_f32));
242
243        #[cranpose_macros::composable]
244        #[allow(non_snake_case)]
245        fn Reader(seen: Rc<Cell<f32>>) {
246            seen.set(density().density());
247        }
248
249        fn tree(with_leading: bool, survivor: f32, seen: Rc<Cell<f32>>) {
250            if with_leading {
251                ProvideDensity(Density::new(2.0, 1.0), || {});
252            }
253            ProvideDensity(Density::new(survivor, 1.0), move || Reader(seen));
254        }
255
256        #[cranpose_macros::composable]
257        #[allow(non_snake_case)]
258        fn Panel(
259            with_leading: MutableState<bool>,
260            survivor: MutableState<f32>,
261            seen: Rc<Cell<f32>>,
262        ) {
263            tree(with_leading.value(), survivor.value(), seen);
264        }
265
266        let key = location_key(file!(), line!(), column!());
267        {
268            let seen = Rc::clone(&seen);
269            composition
270                .render(key, move || Panel(with_leading, survivor, Rc::clone(&seen)))
271                .expect("initial composition");
272        }
273        assert_eq!(seen.get(), 3.0);
274
275        with_leading.set_value(false);
276        while composition
277            .process_invalid_scopes()
278            .expect("drop the leading density scope")
279        {}
280        survivor.set_value(4.0);
281        while composition
282            .process_invalid_scopes()
283            .expect("change the surviving density")
284        {}
285        assert_eq!(
286            seen.get(),
287            4.0,
288            "the surviving density scope must keep its entry and its reader's \
289             subscription when the sibling leaves"
290        );
291    }
292
293    #[test]
294    fn a_provided_density_reaches_the_content_and_ends_with_it() {
295        use std::{cell::Cell, rc::Rc};
296
297        use cranpose_core::{Composition, MemoryApplier, location_key};
298
299        let mut composition = Composition::new(MemoryApplier::new());
300        let outer = Rc::new(Cell::new(0.0_f32));
301        let inside = Rc::new(Cell::new(0.0_f32));
302        let after = Rc::new(Cell::new(0.0_f32));
303
304        let key = location_key(file!(), line!(), column!());
305        {
306            let (outer, inside, after) = (Rc::clone(&outer), Rc::clone(&inside), Rc::clone(&after));
307            let mut render = move || {
308                outer.set(density().density());
309                ProvideDensity(Density::new(3.0, 1.0), || inside.set(density().density()));
310                after.set(density().density());
311            };
312            composition.render(key, &mut render).expect("render");
313        }
314
315        assert_eq!(inside.get(), 3.0, "the subtree reads the grid it was given");
316        assert_ne!(
317            outer.get(),
318            3.0,
319            "the provision does not escape upwards out of its content"
320        );
321        assert_eq!(
322            after.get(),
323            outer.get(),
324            "nor does it leak into what follows it"
325        );
326    }
327
328    #[test]
329    fn a_dp_length_lands_on_a_whole_device_pixel() {
330        let density = Density::new(2.0, 1.0);
331        assert_eq!(density.dp(13.3), 13.5);
332        assert_eq!(density.to_px(density.dp(13.3)), 27.0);
333        assert_eq!(density.dp(0.25), 0.5);
334    }
335
336    #[test]
337    fn a_text_size_moves_with_the_font_scale_and_a_dp_length_does_not() {
338        let density = Density::new(2.0, 1.24);
339        assert_eq!(density.dp(15.0), 15.0);
340        assert_eq!(density.to_px(density.sp(15.0)), 37.0);
341    }
342
343    #[test]
344    fn centring_puts_the_leading_gap_on_a_pixel_boundary() {
345        let density = Density::new(2.0, 1.0);
346        let leading = density.centre(52.0, 46.5);
347        assert_eq!(density.to_px(leading), 6.0);
348    }
349
350    #[test]
351    fn floor_and_ceil_move_whole_pixels_only() {
352        let density = Density::new(2.0, 1.0);
353        assert_eq!(density.floor(13.3), 13.0);
354        assert_eq!(density.ceil(13.3), 13.5);
355        assert_eq!(density.ceil(13.5), 13.5);
356    }
357
358    #[test]
359    fn a_nonsense_grid_falls_back_to_one_rather_than_dividing_by_zero() {
360        let density = Density::new(0.0, f32::NAN);
361        assert_eq!(density.density(), 1.0);
362        assert_eq!(density.font_scale(), 1.0);
363        assert_eq!(density.dp(3.7), 4.0);
364    }
365}