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::{CompositionLocalProvider, StaticCompositionLocal, staticCompositionLocalOf};
22use cranpose_ui_layout::{ceil_to_px, round_to_px};
23
24use crate::font_scale::FontScaleCurve;
25
26/// The device pixel grid a layout measures against: how many device pixels
27/// a layout point is worth, and how the platform converts a text size.
28///
29/// This is Compose's `Density`, and [`local_density`] is its `LocalDensity`.
30///
31/// Read it in a composable through [`density`], which reads the composition
32/// local rather than process-wide state, so a subtree can be given its own
33/// density. Tests and goldens construct one directly so a measurement does
34/// not depend on the machine it runs on.
35#[derive(Clone, Copy, Debug, PartialEq)]
36pub struct Density {
37    density: f32,
38    font_scale: FontScaleCurve,
39}
40
41impl Density {
42    /// The grid the host installed on this shell.
43    ///
44    /// This is the composition local's default, not the way a composable should
45    /// read the density -- use [`density`] for that, so a subtree provided its
46    /// own grid is honoured.
47    pub fn from_host() -> Self {
48        crate::render_state::current_host_density()
49            .map_or_else(Self::default, |(density, curve)| {
50                Self::with_curve(density, curve)
51            })
52    }
53
54    /// A grid stated outright, for a test or a golden. The setting is taken as
55    /// a plain multiplier, which is what it is wherever the platform reports no
56    /// conversion of its own.
57    pub fn new(density: f32, font_scale: f32) -> Self {
58        let font_scale = if font_scale.is_finite() && font_scale > 0.0 {
59            font_scale
60        } else {
61            1.0
62        };
63        Self::with_curve(density, FontScaleCurve::linear(font_scale))
64    }
65
66    /// A grid whose text size follows the platform's own `Sp` conversion rather
67    /// than a multiplier. See [`crate::font_scale`].
68    pub fn with_curve(density: f32, font_scale: FontScaleCurve) -> Self {
69        Self {
70            density: if density.is_finite() && density > 0.0 {
71                density
72            } else {
73                1.0
74            },
75            font_scale,
76        }
77    }
78
79    /// Device pixels per layout point.
80    pub fn density(self) -> f32 {
81        self.density
82    }
83
84    /// The user's text size setting.
85    pub fn font_scale(self) -> f32 {
86        self.font_scale.scale()
87    }
88
89    /// The conversion the platform performs for a size in `Sp`.
90    pub fn font_scale_curve(self) -> FontScaleCurve {
91        self.font_scale
92    }
93
94    /// `Dp.roundToPx()`, expressed back in points: a dp length snapped to the
95    /// whole device pixel Compose would give it.
96    pub fn dp(self, value: f32) -> f32 {
97        round_to_px(value, self.density)
98    }
99
100    /// A text size in scale-independent pixels, snapped the same way.
101    ///
102    /// Anything that must not move when the user changes their text size is
103    /// measured with [`Density::dp`] instead — that is the whole difference
104    /// between the two.
105    pub fn sp(self, value: f32) -> f32 {
106        self.dp(self.font_scale.sp_to_dp(value))
107    }
108
109    /// A length snapped to the nearest whole device pixel, exact halves up.
110    pub fn round(self, value: f32) -> f32 {
111        round_to_px(value, self.density)
112    }
113
114    /// A length snapped down to a whole device pixel.
115    pub fn floor(self, value: f32) -> f32 {
116        if value.is_finite() {
117            (value * self.density).floor() / self.density
118        } else {
119            value
120        }
121    }
122
123    /// A length snapped up to a whole device pixel.
124    ///
125    /// This is what a line box does with its own height, and what a container
126    /// does with a content size it must not clip.
127    pub fn ceil(self, value: f32) -> f32 {
128        ceil_to_px(value, self.density)
129    }
130
131    /// Points to device pixels.
132    pub fn to_px(self, points: f32) -> f32 {
133        points * self.density
134    }
135
136    /// Centres `content` in `available` the way Compose does: on a whole pixel.
137    ///
138    /// `Arrangement.Center` measures every child in whole pixels and rounds the
139    /// leading gap before placing any of them, so a column whose content is an
140    /// odd number of pixels tall starts on a pixel boundary rather than across
141    /// one. Halving in floats instead is the single easiest way to soften every
142    /// edge in a row.
143    pub fn centre(self, available: f32, content: f32) -> f32 {
144        self.round((available - content) * 0.5)
145    }
146}
147
148impl Default for Density {
149    fn default() -> Self {
150        Self::new(1.0, 1.0)
151    }
152}
153
154/// The production [`cranpose_ui_layout::MeasureScope`]: a grid captured at
155/// composition time, carried through measurement.
156///
157/// `MeasurePolicy::measure` runs with this as its scope, the way Compose's
158/// `MeasurePolicy.measure` runs with `MeasureScope` as its receiver. The grid
159/// is the [`LayoutNode`](crate::widgets::nodes::LayoutNode)'s own
160/// [`Density`] -- read at composition time by the `Layout` composable from
161/// [`density`], not the host default -- so a subtree given a different grid
162/// through [`ProvideDensity`] is measured on that grid.
163#[derive(Clone, Copy, Debug, PartialEq)]
164pub struct DensityMeasureScope {
165    density: Density,
166}
167
168impl DensityMeasureScope {
169    /// Wraps a captured [`Density`] as a measurement scope.
170    pub fn new(density: Density) -> Self {
171        Self { density }
172    }
173}
174
175impl cranpose_ui_layout::MeasureScope for DensityMeasureScope {
176    fn density(&self) -> f32 {
177        self.density.density()
178    }
179
180    fn font_scale(&self) -> f32 {
181        self.density.font_scale()
182    }
183}
184
185/// The [`StaticCompositionLocal`] carrying the device pixel grid.
186///
187/// Compose's `LocalDensity`, static as Compose's is: a read subscribes
188/// nothing, and a provider given a different grid recomposes everything
189/// under it. Its default is whatever grid the host installed on this shell,
190/// so an application that never provides one still measures against the
191/// real screen; providing one scopes a different grid to a subtree, which is
192/// what a preview, a scaled container or a density-specific golden needs.
193pub fn local_density() -> StaticCompositionLocal<Density> {
194    crate::thread_state::with_environment(|locals| {
195        locals
196            .density
197            .get_or_init(|| staticCompositionLocalOf(Density::from_host))
198            .clone()
199    })
200}
201
202/// The device pixel grid in force here.
203pub fn density() -> Density {
204    crate::thread_state::with_environment(|locals| {
205        locals
206            .density
207            .get_or_init(|| staticCompositionLocalOf(Density::from_host))
208            .current()
209    })
210}
211
212/// Runs `content` on `density`.
213///
214/// Compose's `CompositionLocalProvider(LocalDensity provides ...)`. A preview, a
215/// scaled container or a golden that must not depend on the machine it runs on
216/// states its grid here instead of moving the whole shell onto it.
217#[expect(non_snake_case)]
218#[track_caller]
219pub fn ProvideDensity(density: Density, content: impl FnOnce()) {
220    CompositionLocalProvider([local_density().provides(density)], content);
221}
222
223#[cfg(test)]
224#[path = "tests/density_tests.rs"]
225mod tests;