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            // `Role.Button` on the `combinedClickable`.
105            config.role = Some(SemanticsWidgetRole::Button);
106            config.is_clickable = true;
107            config.content_description = Some(description.clone());
108        });
109
110    let label_style = spec.label_style.resolve(spec.colors.on_primary);
111    let secondary_style = spec
112        .secondary_style
113        .resolve(fade(spec.colors.on_primary, spec.secondary_alpha));
114    Layout(
115        chrome,
116        WearButtonMeasurePolicy {
117            spec,
118            density: density.density(),
119        },
120        move || {
121            Text(label.clone(), Modifier::empty(), label_style.clone());
122            if let Some(secondary) = secondary_label.clone() {
123                Text(secondary, Modifier::empty(), secondary_style.clone());
124            }
125        },
126    )
127}
128
129fn fade(color: Color, alpha: f32) -> Color {
130    Color::rgba(color.0, color.1, color.2, color.3 * alpha)
131}
132
133#[derive(Clone, Debug, PartialEq)]
134struct WearButtonMeasurePolicy {
135    spec: WearButtonSpec,
136    density: f32,
137}
138
139impl MeasurePolicy for WearButtonMeasurePolicy {
140    fn measure(
141        &self,
142        scope: &dyn MeasureScope,
143        measurables: &[Box<dyn Measurable>],
144        constraints: Constraints,
145    ) -> MeasureResult {
146        let mut placements = Vec::new();
147        let size = self.measure_into(scope, measurables, constraints, &mut placements);
148        MeasureResult::new(size, placements)
149    }
150
151    fn measure_into(
152        &self,
153        _scope: &dyn MeasureScope,
154        measurables: &[Box<dyn Measurable>],
155        constraints: Constraints,
156        placements: &mut Vec<Placement>,
157    ) -> Size {
158        placements.clear();
159        let density = Density::new(self.density, 1.0);
160        let horizontal = density.dp(self.spec.padding_horizontal) * 2.0;
161        let vertical = density.dp(self.spec.padding_vertical) * 2.0;
162        let width = if constraints.max_width.is_finite() {
163            constraints.max_width
164        } else {
165            constraints.min_width
166        };
167        let available = (width - horizontal).max(0.0);
168        let child_constraints = Constraints {
169            min_width: 0.0,
170            max_width: available,
171            min_height: 0.0,
172            max_height: f32::INFINITY,
173        };
174
175        let mut placeables = Vec::with_capacity(measurables.len());
176        for measurable in measurables {
177            placeables.push(measurable.measure(child_constraints));
178        }
179
180        let spacing = density.dp(self.spec.label_spacing);
181        let column: f32 = placeables
182            .iter()
183            .map(|placeable| density.ceil(placeable.height()))
184            .sum::<f32>()
185            + spacing * placeables.len().saturating_sub(1) as f32;
186
187        let height = density
188            .dp(self.spec.min_height)
189            .max(density.ceil(column) + vertical)
190            .clamp(constraints.min_height, constraints.max_height);
191
192        // The label column is centred in the content area, not in the whole
193        // capsule: the vertical padding is taken off first and the slack is
194        // then split on a whole pixel.
195        let content = height - vertical;
196        let mut y = density.dp(self.spec.padding_vertical) + density.centre(content, column);
197        let x = density.dp(self.spec.padding_horizontal);
198        for placeable in &placeables {
199            // The label column has no weight, so it wraps its content and sits
200            // at the start; the text inside is start-aligned too.
201            placements.push(Placement::new(placeable.node_id(), x, y, 0));
202            y += density.ceil(placeable.height()) + spacing;
203        }
204        Size::new(width, height)
205    }
206
207    fn min_intrinsic_width(&self, measurables: &[Box<dyn Measurable>], height: f32) -> f32 {
208        let density = Density::new(self.density, 1.0);
209        measurables
210            .iter()
211            .map(|m| m.min_intrinsic_width(height))
212            .fold(0.0, f32::max)
213            + density.dp(self.spec.padding_horizontal) * 2.0
214    }
215
216    fn max_intrinsic_width(&self, measurables: &[Box<dyn Measurable>], height: f32) -> f32 {
217        let density = Density::new(self.density, 1.0);
218        measurables
219            .iter()
220            .map(|m| m.max_intrinsic_width(height))
221            .fold(0.0, f32::max)
222            + density.dp(self.spec.padding_horizontal) * 2.0
223    }
224
225    fn min_intrinsic_height(&self, measurables: &[Box<dyn Measurable>], width: f32) -> f32 {
226        let density = Density::new(self.density, 1.0);
227        let spacing =
228            density.dp(self.spec.label_spacing) * measurables.len().saturating_sub(1) as f32;
229        let column = measurables
230            .iter()
231            .map(|m| m.min_intrinsic_height(width))
232            .sum::<f32>()
233            + spacing;
234        density
235            .dp(self.spec.min_height)
236            .max(density.ceil(column) + density.dp(self.spec.padding_vertical) * 2.0)
237    }
238
239    fn max_intrinsic_height(&self, measurables: &[Box<dyn Measurable>], width: f32) -> f32 {
240        self.min_intrinsic_height(measurables, width)
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    #[test]
249    fn the_defaults_are_the_ones_the_tokens_declare() {
250        let spec = WearButtonSpec::default();
251        assert_eq!(spec.min_height, 52.0, "FilledButtonTokens.ContainerHeight");
252        assert_eq!(spec.corner_radius, 26.0, "ShapeTokens.CornerLarge");
253        assert_eq!(spec.padding_horizontal, 14.0);
254        assert_eq!(spec.padding_vertical, 6.0);
255        assert_eq!(spec.label_spacing, 1.0);
256        assert_eq!(spec.secondary_alpha, 0.8);
257        assert_eq!(spec.label_style, WearTextStyle::LABEL_MEDIUM);
258        assert_eq!(spec.secondary_style, WearTextStyle::LABEL_SMALL);
259    }
260
261    #[test]
262    fn a_secondary_label_draws_its_colour_at_four_fifths() {
263        let faded = fade(Color::rgba(1.0, 1.0, 1.0, 1.0), 0.8);
264        assert_eq!(faded.3, 0.8);
265    }
266}