Skip to main content

cranpose_ui/widgets/wear/
list_header.rs

1//! A section heading in a Wear list.
2//!
3//! The one thing that is not obvious from the Kotlin: `ListHeader` **wraps its
4//! content width**. It does not span the list, so a header's background — were
5//! one ever set; the default is transparent — would be a capsule around the
6//! words rather than a band across the screen.
7//!
8//! The other is the 48dp floor. Its own padding plus one line of `titleMedium`
9//! comes to 47dp, which the floor rounds up to 48dp, and the surplus is split
10//! by re-centring the padded content in the taller box rather than being added
11//! below it. That is a one-pixel difference and it is on every header.
12//!
13//! The third is the alignment, and it only shows on a header long enough to
14//! wrap. `ListHeader` provides `LocalTextConfiguration` with
15//! `TextAlign.Center` alongside its `Arrangement.Center` — read out of
16//! `ListHeaderKt` in `compose-material3-1.6.2.aar`, where the neighbouring
17//! `ListSubHeader` provides `TextAlign.Start` — so **every** line of a wrapped
18//! header is centred in the block, not just placed under the start of the
19//! first. Centring the block alone is invisible on a one-line header and wrong
20//! on a two-line one: on a 384 px Credits screen, `ORBIT BREAKER` wraps after
21//! `ORBIT` and Compose puts that line at x 147, while a block-only centring
22//! puts it at x 121, 25 px to the left of the `BREAKER` under it.
23
24#![allow(non_snake_case)]
25
26use crate::composable;
27use crate::modifier::Modifier;
28use crate::text::paragraph::TextAlign;
29use crate::widgets::wear::density::WearDensity;
30use crate::widgets::wear::theme::{WearColors, WearTextStyle};
31use crate::widgets::{Layout, Text};
32use cranpose_core::NodeId;
33use cranpose_ui_graphics::Size;
34use cranpose_ui_layout::{Constraints, Measurable, MeasurePolicy, MeasureResult, Placement};
35
36/// `ListHeaderDefaults` plus `ListHeaderTokens.Height`.
37#[derive(Clone, Copy, Debug, PartialEq)]
38pub struct ListHeaderSpec {
39    pub colors: WearColors,
40    pub text_style: WearTextStyle,
41    /// `ListHeaderTokens.Height`.
42    pub min_height: f32,
43    pub padding_start: f32,
44    pub padding_top: f32,
45    pub padding_end: f32,
46    pub padding_bottom: f32,
47}
48
49impl Default for ListHeaderSpec {
50    fn default() -> Self {
51        Self {
52            colors: WearColors::default(),
53            // `TITLE_MEDIUM` is `ListHeaderTokens`; the alignment is the
54            // `LocalTextConfiguration` the widget provides around it. It lives
55            // on the spec rather than being forced at the call site so a
56            // caller who overrides `text_style` overrides the alignment too,
57            // the way overriding `LocalTextConfiguration` does in Compose.
58            text_style: WearTextStyle::TITLE_MEDIUM.aligned(TextAlign::Center),
59            min_height: 48.0,
60            padding_start: 14.0,
61            padding_top: 16.0,
62            padding_end: 14.0,
63            padding_bottom: 12.0,
64        }
65    }
66}
67
68impl ListHeaderSpec {
69    pub fn colors(mut self, colors: WearColors) -> Self {
70        self.colors = colors;
71        self
72    }
73}
74
75/// A section heading.
76///
77/// The label is taken as text rather than as a content slot because the widget
78/// owns the style: `ListHeader` provides `titleMedium` and `onBackground`
79/// through composition locals in Compose, and Cranpose has no such local to
80/// provide through. Taking the string is the version that cannot be wired up
81/// wrong.
82#[composable]
83pub fn ListHeader<S>(modifier: Modifier, spec: ListHeaderSpec, label: S) -> NodeId
84where
85    S: crate::widgets::IntoTextSource + Clone + PartialEq + 'static,
86{
87    let style = spec.text_style.resolve(spec.colors.on_background);
88    Layout(
89        modifier,
90        ListHeaderMeasurePolicy {
91            spec,
92            density: WearDensity::current().density(),
93        },
94        move || {
95            Text(label.clone(), Modifier::empty(), style.clone());
96        },
97    )
98}
99
100#[derive(Clone, Debug, PartialEq)]
101struct ListHeaderMeasurePolicy {
102    spec: ListHeaderSpec,
103    density: f32,
104}
105
106impl ListHeaderMeasurePolicy {
107    fn horizontal(&self, density: WearDensity) -> f32 {
108        density.dp(self.spec.padding_start) + density.dp(self.spec.padding_end)
109    }
110
111    fn vertical(&self, density: WearDensity) -> f32 {
112        density.dp(self.spec.padding_top) + density.dp(self.spec.padding_bottom)
113    }
114}
115
116impl MeasurePolicy for ListHeaderMeasurePolicy {
117    fn measure(
118        &self,
119        measurables: &[Box<dyn Measurable>],
120        constraints: Constraints,
121    ) -> MeasureResult {
122        let mut placements = Vec::new();
123        let size = self.measure_into(measurables, constraints, &mut placements);
124        MeasureResult::new(size, placements)
125    }
126
127    fn measure_into(
128        &self,
129        measurables: &[Box<dyn Measurable>],
130        constraints: Constraints,
131        placements: &mut Vec<Placement>,
132    ) -> Size {
133        placements.clear();
134        let density = WearDensity::new(self.density, 1.0);
135        let horizontal = self.horizontal(density);
136        let available = (constraints.max_width - horizontal).max(0.0);
137        let child_constraints = Constraints {
138            min_width: 0.0,
139            max_width: available,
140            min_height: 0.0,
141            max_height: f32::INFINITY,
142        };
143
144        let mut content_width = 0.0f32;
145        let mut content_height = 0.0f32;
146        let mut placeables = Vec::with_capacity(measurables.len());
147        for measurable in measurables {
148            let placeable = measurable.measure(child_constraints);
149            content_width = content_width.max(placeable.width());
150            content_height = content_height.max(placeable.height());
151            placeables.push(placeable);
152        }
153
154        // `wrapContentSize()`: the header is as wide as its words plus its own
155        // padding, not as wide as the list.
156        let width = density
157            .ceil(content_width + horizontal)
158            .clamp(constraints.min_width, constraints.max_width);
159        let padded = density.ceil(content_height) + self.vertical(density);
160        let height = density
161            .dp(self.spec.min_height)
162            .max(padded)
163            .clamp(constraints.min_height, constraints.max_height);
164        // The floor wins over the padded content by a couple of pixels, and
165        // `wrapContentSize`'s default `Alignment.Center` re-centres inside the
166        // taller box rather than hanging the surplus off the bottom.
167        let surplus = density.centre(height, padded);
168        let top = density.dp(self.spec.padding_top) + surplus;
169
170        for placeable in placeables {
171            let x = density.centre(width, placeable.width());
172            placements.push(Placement::new(placeable.node_id(), x, top, 0));
173        }
174        Size::new(width, height)
175    }
176
177    fn min_intrinsic_width(&self, measurables: &[Box<dyn Measurable>], height: f32) -> f32 {
178        let density = WearDensity::new(self.density, 1.0);
179        measurables
180            .iter()
181            .map(|m| m.min_intrinsic_width(height))
182            .fold(0.0, f32::max)
183            + self.horizontal(density)
184    }
185
186    fn max_intrinsic_width(&self, measurables: &[Box<dyn Measurable>], height: f32) -> f32 {
187        let density = WearDensity::new(self.density, 1.0);
188        measurables
189            .iter()
190            .map(|m| m.max_intrinsic_width(height))
191            .fold(0.0, f32::max)
192            + self.horizontal(density)
193    }
194
195    fn min_intrinsic_height(&self, measurables: &[Box<dyn Measurable>], width: f32) -> f32 {
196        let density = WearDensity::new(self.density, 1.0);
197        let content = measurables
198            .iter()
199            .map(|m| m.min_intrinsic_height(width))
200            .fold(0.0, f32::max);
201        density
202            .dp(self.spec.min_height)
203            .max(density.ceil(content) + self.vertical(density))
204    }
205
206    fn max_intrinsic_height(&self, measurables: &[Box<dyn Measurable>], width: f32) -> f32 {
207        self.min_intrinsic_height(measurables, width)
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    #[test]
216    fn the_defaults_are_the_ones_the_tokens_declare() {
217        let spec = ListHeaderSpec::default();
218        assert_eq!(spec.min_height, 48.0, "ListHeaderTokens.Height");
219        assert_eq!(spec.padding_start, 14.0);
220        assert_eq!(spec.padding_end, 14.0);
221        assert_eq!(spec.padding_top, 16.0);
222        assert_eq!(spec.padding_bottom, 12.0);
223        // The type token, plus the alignment the widget provides around it:
224        // Compose's ListHeader wraps its content in LocalTextConfiguration
225        // with TextAlign.Center, so the style an app actually gets is the
226        // centred one. Asserting the bare token here would pass while every
227        // real header wrapped its second line to the left.
228        assert_eq!(
229            spec.text_style,
230            WearTextStyle::TITLE_MEDIUM.aligned(TextAlign::Center)
231        );
232    }
233}