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 crate::composable;
23use crate::modifier::{Brush, Color, CornerRadii, Modifier, SemanticsConfiguration};
24use crate::widgets::wear::density::WearDensity;
25use crate::widgets::wear::theme::{WearColors, WearTextStyle};
26use crate::widgets::{Layout, Text};
27use crate::SemanticsWidgetRole;
28use cranpose_core::NodeId;
29use cranpose_ui_graphics::{DrawScope, Size};
30use cranpose_ui_layout::{Constraints, Measurable, MeasurePolicy, MeasureResult, Placement};
31
32/// `ButtonDefaults` plus `FilledButtonTokens`.
33#[derive(Clone, Copy, Debug, PartialEq)]
34pub struct WearButtonSpec {
35    pub colors: WearColors,
36    pub label_style: WearTextStyle,
37    pub secondary_style: WearTextStyle,
38    /// `FilledButtonTokens.ContainerHeight`.
39    pub min_height: f32,
40    /// `ShapeTokens.CornerLarge`.
41    pub corner_radius: f32,
42    pub padding_horizontal: f32,
43    pub padding_vertical: f32,
44    /// `Spacer(Modifier.size(1.dp))` between the two labels.
45    pub label_spacing: f32,
46    /// The alpha a secondary label draws its colour at.
47    pub secondary_alpha: f32,
48}
49
50impl Default for WearButtonSpec {
51    fn default() -> Self {
52        Self {
53            colors: WearColors::default(),
54            label_style: WearTextStyle::LABEL_MEDIUM,
55            secondary_style: WearTextStyle::LABEL_SMALL,
56            min_height: 52.0,
57            corner_radius: 26.0,
58            padding_horizontal: 14.0,
59            padding_vertical: 6.0,
60            label_spacing: 1.0,
61            secondary_alpha: 0.8,
62        }
63    }
64}
65
66impl WearButtonSpec {
67    pub fn colors(mut self, colors: WearColors) -> Self {
68        self.colors = colors;
69        self
70    }
71}
72
73/// A filled Wear button with a label and an optional secondary label.
74#[composable]
75pub fn WearButton<F>(
76    modifier: Modifier,
77    spec: WearButtonSpec,
78    label: String,
79    secondary_label: Option<String>,
80    on_click: F,
81) -> NodeId
82where
83    F: FnMut() + 'static,
84{
85    let density = WearDensity::current();
86    let container = spec.colors.primary;
87    let radius = density.dp(spec.corner_radius);
88    let description = match &secondary_label {
89        Some(secondary) => format!("{label}, {secondary}"),
90        None => label.clone(),
91    };
92    let chrome = modifier
93        .draw_behind(move |scope: &mut dyn DrawScope| {
94            scope.draw_round_rect(Brush::Solid(container), CornerRadii::uniform(radius));
95        })
96        .clickable(move |_point| on_click())
97        .semantics(move |config: &mut SemanticsConfiguration| {
98            // `Role.Button` on the `combinedClickable`.
99            config.role = Some(SemanticsWidgetRole::Button);
100            config.is_clickable = true;
101            config.content_description = Some(description.clone());
102        });
103
104    let label_style = spec.label_style.resolve(spec.colors.on_primary);
105    let secondary_style = spec
106        .secondary_style
107        .resolve(fade(spec.colors.on_primary, spec.secondary_alpha));
108    Layout(
109        chrome,
110        WearButtonMeasurePolicy {
111            spec,
112            density: density.density(),
113        },
114        move || {
115            Text(label.clone(), Modifier::empty(), label_style.clone());
116            if let Some(secondary) = secondary_label.clone() {
117                Text(secondary, Modifier::empty(), secondary_style.clone());
118            }
119        },
120    )
121}
122
123fn fade(color: Color, alpha: f32) -> Color {
124    Color::rgba(color.0, color.1, color.2, color.3 * alpha)
125}
126
127#[derive(Clone, Debug, PartialEq)]
128struct WearButtonMeasurePolicy {
129    spec: WearButtonSpec,
130    density: f32,
131}
132
133impl MeasurePolicy for WearButtonMeasurePolicy {
134    fn measure(
135        &self,
136        measurables: &[Box<dyn Measurable>],
137        constraints: Constraints,
138    ) -> MeasureResult {
139        let mut placements = Vec::new();
140        let size = self.measure_into(measurables, constraints, &mut placements);
141        MeasureResult::new(size, placements)
142    }
143
144    fn measure_into(
145        &self,
146        measurables: &[Box<dyn Measurable>],
147        constraints: Constraints,
148        placements: &mut Vec<Placement>,
149    ) -> Size {
150        placements.clear();
151        let density = WearDensity::new(self.density, 1.0);
152        let horizontal = density.dp(self.spec.padding_horizontal) * 2.0;
153        let vertical = density.dp(self.spec.padding_vertical) * 2.0;
154        let width = if constraints.max_width.is_finite() {
155            constraints.max_width
156        } else {
157            constraints.min_width
158        };
159        let available = (width - horizontal).max(0.0);
160        let child_constraints = Constraints {
161            min_width: 0.0,
162            max_width: available,
163            min_height: 0.0,
164            max_height: f32::INFINITY,
165        };
166
167        let mut placeables = Vec::with_capacity(measurables.len());
168        for measurable in measurables {
169            placeables.push(measurable.measure(child_constraints));
170        }
171
172        let spacing = density.dp(self.spec.label_spacing);
173        let column: f32 = placeables
174            .iter()
175            .map(|placeable| density.ceil(placeable.height()))
176            .sum::<f32>()
177            + spacing * placeables.len().saturating_sub(1) as f32;
178
179        let height = density
180            .dp(self.spec.min_height)
181            .max(density.ceil(column) + vertical)
182            .clamp(constraints.min_height, constraints.max_height);
183
184        // The label column is centred in the content area, not in the whole
185        // capsule: the vertical padding is taken off first and the slack is
186        // then split on a whole pixel.
187        let content = height - vertical;
188        let mut y = density.dp(self.spec.padding_vertical) + density.centre(content, column);
189        let x = density.dp(self.spec.padding_horizontal);
190        for placeable in &placeables {
191            // The label column has no weight, so it wraps its content and sits
192            // at the start; the text inside is start-aligned too.
193            placements.push(Placement::new(placeable.node_id(), x, y, 0));
194            y += density.ceil(placeable.height()) + spacing;
195        }
196        Size::new(width, height)
197    }
198
199    fn min_intrinsic_width(&self, measurables: &[Box<dyn Measurable>], height: f32) -> f32 {
200        let density = WearDensity::new(self.density, 1.0);
201        measurables
202            .iter()
203            .map(|m| m.min_intrinsic_width(height))
204            .fold(0.0, f32::max)
205            + density.dp(self.spec.padding_horizontal) * 2.0
206    }
207
208    fn max_intrinsic_width(&self, measurables: &[Box<dyn Measurable>], height: f32) -> f32 {
209        let density = WearDensity::new(self.density, 1.0);
210        measurables
211            .iter()
212            .map(|m| m.max_intrinsic_width(height))
213            .fold(0.0, f32::max)
214            + density.dp(self.spec.padding_horizontal) * 2.0
215    }
216
217    fn min_intrinsic_height(&self, measurables: &[Box<dyn Measurable>], width: f32) -> f32 {
218        let density = WearDensity::new(self.density, 1.0);
219        let spacing =
220            density.dp(self.spec.label_spacing) * measurables.len().saturating_sub(1) as f32;
221        let column = measurables
222            .iter()
223            .map(|m| m.min_intrinsic_height(width))
224            .sum::<f32>()
225            + spacing;
226        density
227            .dp(self.spec.min_height)
228            .max(density.ceil(column) + density.dp(self.spec.padding_vertical) * 2.0)
229    }
230
231    fn max_intrinsic_height(&self, measurables: &[Box<dyn Measurable>], width: f32) -> f32 {
232        self.min_intrinsic_height(measurables, width)
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    #[test]
241    fn the_defaults_are_the_ones_the_tokens_declare() {
242        let spec = WearButtonSpec::default();
243        assert_eq!(spec.min_height, 52.0, "FilledButtonTokens.ContainerHeight");
244        assert_eq!(spec.corner_radius, 26.0, "ShapeTokens.CornerLarge");
245        assert_eq!(spec.padding_horizontal, 14.0);
246        assert_eq!(spec.padding_vertical, 6.0);
247        assert_eq!(spec.label_spacing, 1.0);
248        assert_eq!(spec.secondary_alpha, 0.8);
249        assert_eq!(spec.label_style, WearTextStyle::LABEL_MEDIUM);
250        assert_eq!(spec.secondary_style, WearTextStyle::LABEL_SMALL);
251    }
252
253    #[test]
254    fn a_secondary_label_draws_its_colour_at_four_fifths() {
255        let faded = fade(Color::rgba(1.0, 1.0, 1.0, 1.0), 0.8);
256        assert_eq!(faded.3, 0.8);
257    }
258}