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
20use cranpose_core::NodeId;
21use cranpose_ui_graphics::{DrawScope, Size};
22use cranpose_ui_layout::{
23    Constraints, Measurable, MeasurePolicy, MeasureResult, MeasureScope, Placement,
24};
25
26use crate::{
27    SemanticsWidgetRole, composable,
28    density::Density,
29    modifier::{Brush, Color, CornerRadii, Modifier, SemanticsConfiguration},
30    widgets::{
31        Layout, Text,
32        wear::theme::{WearColors, WearTextStyle},
33    },
34};
35
36/// `ButtonDefaults` plus `FilledButtonTokens`.
37#[derive(Clone, Copy, Debug, PartialEq)]
38pub struct WearButtonSpec {
39    pub colors: WearColors,
40    pub label_style: WearTextStyle,
41    pub secondary_style: WearTextStyle,
42    /// `FilledButtonTokens.ContainerHeight`.
43    pub min_height: f32,
44    /// `ShapeTokens.CornerLarge`.
45    pub corner_radius: f32,
46    pub padding_horizontal: f32,
47    pub padding_vertical: f32,
48    /// `Spacer(Modifier.size(1.dp))` between the two labels.
49    pub label_spacing: f32,
50    /// The alpha a secondary label draws its colour at.
51    pub secondary_alpha: f32,
52}
53
54impl Default for WearButtonSpec {
55    fn default() -> Self {
56        Self {
57            colors: WearColors::default(),
58            label_style: WearTextStyle::LABEL_MEDIUM,
59            secondary_style: WearTextStyle::LABEL_SMALL,
60            min_height: 52.0,
61            corner_radius: 26.0,
62            padding_horizontal: 14.0,
63            padding_vertical: 6.0,
64            label_spacing: 1.0,
65            secondary_alpha: 0.8,
66        }
67    }
68}
69
70impl WearButtonSpec {
71    pub fn colors(mut self, colors: WearColors) -> Self {
72        self.colors = colors;
73        self
74    }
75}
76
77/// A filled Wear button with a label and an optional secondary label.
78#[composable]
79pub fn WearButton<F>(
80    modifier: Modifier,
81    spec: WearButtonSpec,
82    label: String,
83    secondary_label: Option<String>,
84    on_click: F,
85) -> NodeId
86where
87    F: FnMut() + 'static,
88{
89    let density = crate::density::density();
90    let container = spec.colors.primary;
91    let radius = density.dp(spec.corner_radius);
92    let description = match &secondary_label {
93        Some(secondary) => format!("{label}, {secondary}"),
94        None => label.clone(),
95    };
96    let chrome = modifier
97        .draw_behind(move |scope: &mut dyn DrawScope| {
98            scope.draw_round_rect(Brush::Solid(container), CornerRadii::uniform(radius));
99        })
100        .clickable(move |_point| on_click())
101        .stable_semantics(move |config: &mut SemanticsConfiguration| {
102            config.role = Some(SemanticsWidgetRole::Button);
103            config.is_clickable = true;
104            config.content_description = Some(description.clone());
105        });
106
107    let label_style = spec.label_style.resolve(spec.colors.on_primary);
108    let secondary_style = spec
109        .secondary_style
110        .resolve(fade(spec.colors.on_primary, spec.secondary_alpha));
111    Layout(
112        chrome,
113        WearButtonMeasurePolicy {
114            spec,
115            density: density.density(),
116        },
117        move || {
118            Text(label.clone(), Modifier::empty(), label_style.clone());
119            if let Some(secondary) = secondary_label.clone() {
120                Text(secondary, Modifier::empty(), secondary_style.clone());
121            }
122        },
123    )
124}
125
126fn fade(color: Color, alpha: f32) -> Color {
127    Color::rgba(color.0, color.1, color.2, color.3 * alpha)
128}
129
130#[derive(Clone, Debug, PartialEq)]
131struct WearButtonMeasurePolicy {
132    spec: WearButtonSpec,
133    density: f32,
134}
135
136impl MeasurePolicy for WearButtonMeasurePolicy {
137    fn measure(
138        &self,
139        scope: &dyn MeasureScope,
140        measurables: &[Box<dyn Measurable>],
141        constraints: Constraints,
142    ) -> MeasureResult {
143        let mut placements = Vec::new();
144        let measurement = self.measure_into(scope, measurables, constraints, &mut placements);
145        MeasureResult::new(measurement, placements)
146    }
147
148    fn measure_into(
149        &self,
150        _scope: &dyn MeasureScope,
151        measurables: &[Box<dyn Measurable>],
152        constraints: Constraints,
153        placements: &mut Vec<Placement>,
154    ) -> cranpose_ui_layout::Measurement {
155        placements.clear();
156        let density = Density::new(self.density, 1.0);
157        let horizontal = density.dp(self.spec.padding_horizontal) * 2.0;
158        let vertical = density.dp(self.spec.padding_vertical) * 2.0;
159        let width = if constraints.max_width.is_finite() {
160            constraints.max_width
161        } else {
162            constraints.min_width
163        };
164        let available = (width - horizontal).max(0.0);
165        let child_constraints = Constraints {
166            min_width: 0.0,
167            max_width: available,
168            min_height: 0.0,
169            max_height: f32::INFINITY,
170        };
171
172        let mut placeables = Vec::with_capacity(measurables.len());
173        for measurable in measurables {
174            placeables.push(measurable.measure(child_constraints));
175        }
176
177        let spacing = density.dp(self.spec.label_spacing);
178        let column: f32 = placeables
179            .iter()
180            .map(|placeable| density.ceil(placeable.height()))
181            .sum::<f32>()
182            + spacing * placeables.len().saturating_sub(1) as f32;
183
184        let height = density
185            .dp(self.spec.min_height)
186            .max(density.ceil(column) + vertical)
187            .clamp(constraints.min_height, constraints.max_height);
188
189        let content = height - vertical;
190        let mut y = density.dp(self.spec.padding_vertical) + density.centre(content, column);
191        let x = density.dp(self.spec.padding_horizontal);
192        for placeable in &placeables {
193            placements.push(Placement::new(placeable.node_id(), x, y, 0));
194            y += density.ceil(placeable.height()) + spacing;
195        }
196        Size::new(width, height).into()
197    }
198
199    fn min_intrinsic_width(&self, measurables: &[Box<dyn Measurable>], height: f32) -> f32 {
200        let density = Density::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 = Density::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 = Density::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)]
237#[path = "tests/button_tests.rs"]
238mod tests;