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