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#[expect(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)]
228#[path = "tests/density_tests.rs"]
229mod tests;