Skip to main content

cranpose_ui/widgets/wear/
button.rs

1//! A Wear filled button: a 52dp capsule with one or two label lines.
2//!
3//! This is a sibling of [`crate::widgets::Button`], not an extension of it.
4//! That one is `Layout` plus `clickable` — no shape, no background, no minimum
5//! size, no colours and no second label slot. A Wear button is all five of
6//! those, and folding them into the Material button would put a watch's
7//! geometry on every platform.
8//!
9//! Two behaviours worth naming, because both are easy to implement the obvious
10//! way and be wrong:
11//!
12//! - the caller's `fillMaxWidth()` sits **outside** the button's own
13//!   `width(IntrinsicSize.Max)`, so it pins `min == max` and the intrinsic
14//!   width is coerced away. A port that honours the intrinsic faithfully but
15//!   gets the coercion order wrong shrinks every row to its text width;
16//! - there is **no press animation**. No shape morph, no scale. `IconButton`
17//!   and `TextButton` do morph, and a port that generalises from them adds
18//!   motion the platform does not have.
19
20#![allow(non_snake_case)]
21
22use cranpose_core::NodeId;
23use cranpose_ui_graphics::{DrawScope, Size};
24use cranpose_ui_layout::{
25    Constraints, Measurable, MeasurePolicy, MeasureResult, MeasureScope, Placement,
26};
27
28use crate::{
29    SemanticsWidgetRole, composable,
30    density::Density,
31    modifier::{Brush, Color, CornerRadii, Modifier, SemanticsConfiguration},
32    widgets::{
33        Layout, Text,
34        wear::theme::{WearColors, WearTextStyle},
35    },
36};
37
38/// `ButtonDefaults` plus `FilledButtonTokens`.
39#[derive(Clone, Copy, Debug, PartialEq)]
40pub struct WearButtonSpec {
41    pub colors: WearColors,
42    pub label_style: WearTextStyle,
43    pub secondary_style: WearTextStyle,
44    /// `FilledButtonTokens.ContainerHeight`.
45    pub min_height: f32,
46    /// `ShapeTokens.CornerLarge`.
47    pub corner_radius: f32,
48    pub padding_horizontal: f32,
49    pub padding_vertical: f32,
50    /// `Spacer(Modifier.size(1.dp))` between the two labels.
51    pub label_spacing: f32,
52    /// The alpha a secondary label draws its colour at.
53    pub secondary_alpha: f32,
54}
55
56impl Default for WearButtonSpec {
57    fn default() -> Self {
58        Self {
59            colors: WearColors::default(),
60            label_style: WearTextStyle::LABEL_MEDIUM,
61            secondary_style: WearTextStyle::LABEL_SMALL,
62            min_height: 52.0,
63            corner_radius: 26.0,
64            padding_horizontal: 14.0,
65            padding_vertical: 6.0,
66            label_spacing: 1.0,
67            secondary_alpha: 0.8,
68        }
69    }
70}
71
72impl WearButtonSpec {
73    pub fn colors(mut self, colors: WearColors) -> Self {
74        self.colors = colors;
75        self
76    }
77}
78
79/// A filled Wear button with a label and an optional secondary label.
80#[composable]
81pub fn WearButton<F>(
82    modifier: Modifier,
83    spec: WearButtonSpec,
84    label: String,
85    secondary_label: Option<String>,
86    on_click: F,
87) -> NodeId
88where
89    F: FnMut() + 'static,
90{
91    let density = crate::density::density();
92    let container = spec.colors.primary;
93    let radius = density.dp(spec.corner_radius);
94    let description = match &secondary_label {
95        Some(secondary) => format!("{label}, {secondary}"),
96        None => label.clone(),
97    };
98    let chrome = modifier
99        .draw_behind(move |scope: &mut dyn DrawScope| {
100            scope.draw_round_rect(Brush::Solid(container), CornerRadii::uniform(radius));
101        })
102        .clickable(move |_point| on_click())
103        .semantics(move |config: &mut SemanticsConfiguration| {
104            config.role = Some(SemanticsWidgetRole::Button);
105            config.is_clickable = true;
106            config.content_description = Some(description.clone());
107        });
108
109    let label_style = spec.label_style.resolve(spec.colors.on_primary);
110    let secondary_style = spec
111        .secondary_style
112        .resolve(fade(spec.colors.on_primary, spec.secondary_alpha));
113    Layout(
114        chrome,
115        WearButtonMeasurePolicy {
116            spec,
117            density: density.density(),
118        },
119        move || {
120            Text(label.clone(), Modifier::empty(), label_style.clone());
121            if let Some(secondary) = secondary_label.clone() {
122                Text(secondary, Modifier::empty(), secondary_style.clone());
123            }
124        },
125    )
126}
127
128fn fade(color: Color, alpha: f32) -> Color {
129    Color::rgba(color.0, color.1, color.2, color.3 * alpha)
130}
131
132#[derive(Clone, Debug, PartialEq)]
133struct WearButtonMeasurePolicy {
134    spec: WearButtonSpec,
135    density: f32,
136}
137
138impl MeasurePolicy for WearButtonMeasurePolicy {
139    fn measure(
140        &self,
141        scope: &dyn MeasureScope,
142        measurables: &[Box<dyn Measurable>],
143        constraints: Constraints,
144    ) -> MeasureResult {
145        let mut placements = Vec::new();
146        let size = self.measure_into(scope, measurables, constraints, &mut placements);
147        MeasureResult::new(size, placements)
148    }
149
150    fn measure_into(
151        &self,
152        _scope: &dyn MeasureScope,
153        measurables: &[Box<dyn Measurable>],
154        constraints: Constraints,
155        placements: &mut Vec<Placement>,
156    ) -> Size {
157        placements.clear();
158        let density = Density::new(self.density, 1.0);
159        let horizontal = density.dp(self.spec.padding_horizontal) * 2.0;
160        let vertical = density.dp(self.spec.padding_vertical) * 2.0;
161        let width = if constraints.max_width.is_finite() {
162            constraints.max_width
163        } else {
164            constraints.min_width
165        };
166        let available = (width - horizontal).max(0.0);
167        let child_constraints = Constraints {
168            min_width: 0.0,
169            max_width: available,
170            min_height: 0.0,
171            max_height: f32::INFINITY,
172        };
173
174        let mut placeables = Vec::with_capacity(measurables.len());
175        for measurable in measurables {
176            placeables.push(measurable.measure(child_constraints));
177        }
178
179        let spacing = density.dp(self.spec.label_spacing);
180        let column: f32 = placeables
181            .iter()
182            .map(|placeable| density.ceil(placeable.height()))
183            .sum::<f32>()
184            + spacing * placeables.len().saturating_sub(1) as f32;
185
186        let height = density
187            .dp(self.spec.min_height)
188            .max(density.ceil(column) + vertical)
189            .clamp(constraints.min_height, constraints.max_height);
190
191        let content = height - vertical;
192        let mut y = density.dp(self.spec.padding_vertical) + density.centre(content, column);
193        let x = density.dp(self.spec.padding_horizontal);
194        for placeable in &placeables {
195            placements.push(Placement::new(placeable.node_id(), x, y, 0));
196            y += density.ceil(placeable.height()) + spacing;
197        }
198        Size::new(width, height)
199    }
200
201    fn min_intrinsic_width(&self, measurables: &[Box<dyn Measurable>], height: f32) -> f32 {
202        let density = Density::new(self.density, 1.0);
203        measurables
204            .iter()
205            .map(|m| m.min_intrinsic_width(height))
206            .fold(0.0, f32::max)
207            + density.dp(self.spec.padding_horizontal) * 2.0
208    }
209
210    fn max_intrinsic_width(&self, measurables: &[Box<dyn Measurable>], height: f32) -> f32 {
211        let density = Density::new(self.density, 1.0);
212        measurables
213            .iter()
214            .map(|m| m.max_intrinsic_width(height))
215            .fold(0.0, f32::max)
216            + density.dp(self.spec.padding_horizontal) * 2.0
217    }
218
219    fn min_intrinsic_height(&self, measurables: &[Box<dyn Measurable>], width: f32) -> f32 {
220        let density = Density::new(self.density, 1.0);
221        let spacing =
222            density.dp(self.spec.label_spacing) * measurables.len().saturating_sub(1) as f32;
223        let column = measurables
224            .iter()
225            .map(|m| m.min_intrinsic_height(width))
226            .sum::<f32>()
227            + spacing;
228        density
229            .dp(self.spec.min_height)
230            .max(density.ceil(column) + density.dp(self.spec.padding_vertical) * 2.0)
231    }
232
233    fn max_intrinsic_height(&self, measurables: &[Box<dyn Measurable>], width: f32) -> f32 {
234        self.min_intrinsic_height(measurables, width)
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    #[test]
243    fn the_defaults_are_the_ones_the_tokens_declare() {
244        let spec = WearButtonSpec::default();
245        assert_eq!(spec.min_height, 52.0, "FilledButtonTokens.ContainerHeight");
246        assert_eq!(spec.corner_radius, 26.0, "ShapeTokens.CornerLarge");
247        assert_eq!(spec.padding_horizontal, 14.0);
248        assert_eq!(spec.padding_vertical, 6.0);
249        assert_eq!(spec.label_spacing, 1.0);
250        assert_eq!(spec.secondary_alpha, 0.8);
251        assert_eq!(spec.label_style, WearTextStyle::LABEL_MEDIUM);
252        assert_eq!(spec.secondary_style, WearTextStyle::LABEL_SMALL);
253    }
254
255    #[test]
256    fn a_secondary_label_draws_its_colour_at_four_fifths() {
257        let faded = fade(Color::rgba(1.0, 1.0, 1.0, 1.0), 0.8);
258        assert_eq!(faded.3, 0.8);
259    }
260}