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
24use cranpose_core::NodeId;
25use cranpose_ui_graphics::Size;
26use cranpose_ui_layout::{
27    Constraints, Measurable, MeasurePolicy, MeasureResult, MeasureScope, Placement,
28};
29
30use crate::{
31    composable,
32    density::Density,
33    modifier::Modifier,
34    text::paragraph::TextAlign,
35    widgets::{
36        Layout, Text,
37        wear::theme::{WearColors, WearTextStyle},
38    },
39};
40
41/// `ListHeaderDefaults` plus `ListHeaderTokens.Height`.
42#[derive(Clone, Copy, Debug, PartialEq)]
43pub struct ListHeaderSpec {
44    pub colors: WearColors,
45    pub text_style: WearTextStyle,
46    /// `ListHeaderTokens.Height`.
47    pub min_height: f32,
48    pub padding_start: f32,
49    pub padding_top: f32,
50    pub padding_end: f32,
51    pub padding_bottom: f32,
52}
53
54impl Default for ListHeaderSpec {
55    fn default() -> Self {
56        Self {
57            colors: WearColors::default(),
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: crate::density::density().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: Density) -> f32 {
108        density.dp(self.spec.padding_start) + density.dp(self.spec.padding_end)
109    }
110
111    fn vertical(&self, density: Density) -> 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        scope: &dyn MeasureScope,
120        measurables: &[Box<dyn Measurable>],
121        constraints: Constraints,
122    ) -> MeasureResult {
123        let mut placements = Vec::new();
124        let measurement = self.measure_into(scope, measurables, constraints, &mut placements);
125        MeasureResult::new(measurement, placements)
126    }
127
128    fn measure_into(
129        &self,
130        _scope: &dyn MeasureScope,
131        measurables: &[Box<dyn Measurable>],
132        constraints: Constraints,
133        placements: &mut Vec<Placement>,
134    ) -> cranpose_ui_layout::Measurement {
135        placements.clear();
136        let density = Density::new(self.density, 1.0);
137        let horizontal = self.horizontal(density);
138        let available = (constraints.max_width - horizontal).max(0.0);
139        let child_constraints = Constraints {
140            min_width: 0.0,
141            max_width: available,
142            min_height: 0.0,
143            max_height: f32::INFINITY,
144        };
145
146        let mut content_width = 0.0f32;
147        let mut content_height = 0.0f32;
148        let mut placeables = Vec::with_capacity(measurables.len());
149        for measurable in measurables {
150            let placeable = measurable.measure(child_constraints);
151            content_width = content_width.max(placeable.width());
152            content_height = content_height.max(placeable.height());
153            placeables.push(placeable);
154        }
155
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        let surplus = density.centre(height, padded);
165        let top = density.dp(self.spec.padding_top) + surplus;
166
167        for placeable in placeables {
168            let x = density.centre(width, placeable.width());
169            placements.push(Placement::new(placeable.node_id(), x, top, 0));
170        }
171        Size::new(width, height).into()
172    }
173
174    fn min_intrinsic_width(&self, measurables: &[Box<dyn Measurable>], height: f32) -> f32 {
175        let density = Density::new(self.density, 1.0);
176        measurables
177            .iter()
178            .map(|m| m.min_intrinsic_width(height))
179            .fold(0.0, f32::max)
180            + self.horizontal(density)
181    }
182
183    fn max_intrinsic_width(&self, measurables: &[Box<dyn Measurable>], height: f32) -> f32 {
184        let density = Density::new(self.density, 1.0);
185        measurables
186            .iter()
187            .map(|m| m.max_intrinsic_width(height))
188            .fold(0.0, f32::max)
189            + self.horizontal(density)
190    }
191
192    fn min_intrinsic_height(&self, measurables: &[Box<dyn Measurable>], width: f32) -> f32 {
193        let density = Density::new(self.density, 1.0);
194        let content = measurables
195            .iter()
196            .map(|m| m.min_intrinsic_height(width))
197            .fold(0.0, f32::max);
198        density
199            .dp(self.spec.min_height)
200            .max(density.ceil(content) + self.vertical(density))
201    }
202
203    fn max_intrinsic_height(&self, measurables: &[Box<dyn Measurable>], width: f32) -> f32 {
204        self.min_intrinsic_height(measurables, width)
205    }
206}
207
208#[cfg(test)]
209#[path = "tests/list_header_tests.rs"]
210mod tests;