Skip to main content

cranpose_ui/widgets/wear/
switch_button.rs

1//! A Wear switch row: a 52dp capsule with a label and a 32x22dp switch.
2//!
3//! Three details here are measured, not guessed, and each is a place a
4//! reasonable implementation goes wrong:
5//!
6//! - **The switch graphic sits 2dp above the row's centre.** Its slot is
7//!   32x24dp but the switch draws at 32x22dp, and the slot aligns width only —
8//!   vertical placement falls back to the top. Centring the graphic in the row,
9//!   which is the obvious thing to do, is wrong by 2 pixels at density 2.
10//! - **A checked switch has no border.** The track and the border both resolve
11//!   to `primary`, and the drawing suppresses a border equal to its track. Only
12//!   the unchecked state shows the outline ring.
13//! - **A checked thumb is often invisible, and still matters.** In a scheme
14//!   where the thumb and the container are the same colour it shows only where
15//!   it overlaps the light track — but it still occludes the track, so it
16//!   cannot be optimised away.
17
18#![expect(non_snake_case)]
19
20use cranpose_core::NodeId;
21use cranpose_foundation::SemanticsWidgetRole;
22use cranpose_ui_graphics::{DrawScope, Size, VectorPath};
23use cranpose_ui_layout::{
24    Constraints, Measurable, MeasurePolicy, MeasureResult, MeasureScope, Placement,
25};
26
27use crate::{
28    composable,
29    density::Density,
30    modifier::{Brush, Color, CornerRadii, Modifier, Point, Rect},
31    widgets::{
32        Layout, Text,
33        wear::theme::{WearColors, WearTextStyle},
34    },
35};
36
37/// `SwitchButtonDefaults` plus `ToggleButton`'s layout constants.
38#[derive(Clone, Copy, Debug, PartialEq)]
39pub struct SwitchButtonSpec {
40    /// Colors of the container, the labels and the switch.
41    pub colors: WearColors,
42    /// Text style of the main label.
43    pub label_style: WearTextStyle,
44    /// Text style of the secondary label.
45    pub secondary_style: WearTextStyle,
46    /// Minimum height of the button, in dp.
47    pub min_height: f32,
48    /// Corner radius of the container, in dp.
49    pub corner_radius: f32,
50    /// Space between the container edge and the content on both horizontal sides, in dp.
51    pub padding_horizontal: f32,
52    /// Space between the container edge and the content at the top and the bottom, in dp.
53    pub padding_vertical: f32,
54    /// `SWITCH_WIDTH`.
55    pub switch_width: f32,
56    /// `SWITCH_OUTER_HEIGHT` — the slot the graphic is placed in.
57    pub switch_slot_height: f32,
58    /// `SWITCH_INNER_HEIGHT` — what the graphic actually draws.
59    pub switch_height: f32,
60    /// `SWITCH_TRACK_WIDTH` — the unchecked state's border stroke.
61    pub track_border_width: f32,
62    /// Radius of the thumb when the switch is off, in dp.
63    pub thumb_radius_unchecked: f32,
64    /// Radius of the thumb when the switch is on, in dp.
65    pub thumb_radius_checked: f32,
66    /// `TOGGLE_CONTROL_SPACING`.
67    pub control_spacing: f32,
68    /// `LabelSpacerSize`.
69    pub label_spacing: f32,
70    /// How far the thumb has travelled, `0.0` unchecked and `1.0` checked.
71    ///
72    /// A caller animating the transition drives this; a caller that does not
73    /// passes the checked state itself. Wear animates it with
74    /// `motionScheme.fastEffectsSpec()`, a critically damped spring of
75    /// stiffness 1400.
76    pub progress: f32,
77}
78
79impl Default for SwitchButtonSpec {
80    fn default() -> Self {
81        Self {
82            colors: WearColors::default(),
83            label_style: WearTextStyle::LABEL_MEDIUM,
84            secondary_style: WearTextStyle::LABEL_SMALL,
85            min_height: 52.0,
86            corner_radius: 26.0,
87            padding_horizontal: 14.0,
88            padding_vertical: 8.0,
89            switch_width: 32.0,
90            switch_slot_height: 24.0,
91            switch_height: 22.0,
92            track_border_width: 2.0,
93            thumb_radius_unchecked: 6.0,
94            thumb_radius_checked: 9.0,
95            control_spacing: 6.0,
96            label_spacing: 1.0,
97            progress: 0.0,
98        }
99    }
100}
101
102impl SwitchButtonSpec {
103    /// Sets the colors of the container, the labels and the switch.
104    pub fn colors(mut self, colors: WearColors) -> Self {
105        self.colors = colors;
106        self
107    }
108
109    /// The thumb travel, which a caller animates or snaps.
110    pub fn progress(mut self, progress: f32) -> Self {
111        self.progress = progress.clamp(0.0, 1.0);
112        self
113    }
114}
115
116/// The stiffness and damping of the two springs a Wear switch animates on.
117///
118/// `MotionScheme.standard()`: `fastEffectsSpec` moves the thumb, its radius and
119/// the tick's scale; `slowEffectsSpec` moves every colour. Both are critically
120/// damped — a switch does not bounce.
121pub const SWITCH_THUMB_STIFFNESS: f32 = 1400.0;
122/// Stiffness of the spring that animates the switch colors.
123pub const SWITCH_COLOR_STIFFNESS: f32 = 260.0;
124/// Damping ratio of both switch springs. 1.0 is critically damped.
125pub const SWITCH_DAMPING_RATIO: f32 = 1.0;
126
127/// The colours one state of the switch draws with.
128#[derive(Clone, Copy, Debug, PartialEq)]
129pub struct SwitchColors {
130    /// Fill of the button container.
131    pub container: Color,
132    /// Color of the main label.
133    pub label: Color,
134    /// Color of the secondary label.
135    pub secondary_label: Color,
136    /// Fill of the switch track.
137    pub track: Color,
138    /// `Color::rgba(_, _, _, 0.0)` when the border is suppressed.
139    pub track_border: Color,
140    /// Fill of the switch thumb.
141    pub thumb: Color,
142    /// Color of the check mark.
143    pub tick: Color,
144}
145
146impl SwitchColors {
147    /// The slots a switch resolves to at a given checked state.
148    ///
149    /// The border suppression lives here: `if currentTrackColor ==
150    /// currentTrackBorderColor { borderColor = Color.Transparent }`, which in
151    /// the checked state is always true because both are `primary`.
152    pub fn of(colors: WearColors, checked: bool) -> Self {
153        let (container, label, secondary_label, track, border, thumb, tick) = if checked {
154            (
155                colors.primary_container,
156                colors.on_primary_container,
157                fade(colors.on_primary_container, 0.9),
158                colors.primary,
159                colors.primary,
160                colors.primary_container,
161                colors.primary,
162            )
163        } else {
164            (
165                colors.surface_container,
166                colors.on_surface,
167                colors.on_surface_variant,
168                colors.surface_container,
169                colors.outline,
170                colors.outline,
171                colors.primary,
172            )
173        };
174        let track_border = if border == track {
175            Color::rgba(border.0, border.1, border.2, 0.0)
176        } else {
177            border
178        };
179        Self {
180            container,
181            label,
182            secondary_label,
183            track,
184            track_border,
185            thumb,
186            tick,
187        }
188    }
189}
190
191fn fade(color: Color, alpha: f32) -> Color {
192    Color::rgba(color.0, color.1, color.2, color.3 * alpha)
193}
194
195/// Where the thumb is and how big, at a given progress.
196///
197/// `thumbPadding` is `height/2 - radius` on each side, so the thumb grows into
198/// its own margin as it travels: 5dp of clearance unchecked and 2dp checked.
199pub fn switch_thumb(spec: SwitchButtonSpec, progress: f32) -> (f32, f32) {
200    let progress = progress.clamp(0.0, 1.0);
201    let radius = spec.thumb_radius_unchecked
202        + (spec.thumb_radius_checked - spec.thumb_radius_unchecked) * progress;
203    let half = spec.switch_height * 0.5;
204    let start = radius + (half - spec.thumb_radius_unchecked);
205    let end = spec.switch_width - radius - (half - spec.thumb_radius_checked);
206    (start + (end - start) * progress, radius)
207}
208
209/// The tick's scale factor: a cubic ease-out on the thumb progress.
210///
211/// The stroke scales with the canvas, so the tick's own line thins as it grows
212/// in rather than being drawn thin and widening.
213pub fn switch_tick_scale(progress: f32) -> f32 {
214    let remaining = 1.0 - progress.clamp(0.0, 1.0);
215    1.0 - remaining * remaining * remaining
216}
217
218/// Draws the 32x22dp switch graphic into a scope of exactly that size.
219pub fn draw_switch(scope: &mut dyn DrawScope, spec: SwitchButtonSpec, colors: SwitchColors) {
220    let size = scope.size();
221    if size.width <= 0.0 || size.height <= 0.0 {
222        return;
223    }
224    let radius = size.height * 0.5;
225    scope.draw_round_rect(Brush::Solid(colors.track), CornerRadii::uniform(radius));
226
227    if colors.track_border.3 > 0.0 {
228        let inset = spec.track_border_width * 0.5;
229        scope.draw_round_rect_at_stroked(
230            Rect {
231                x: inset,
232                y: inset,
233                width: size.width - spec.track_border_width,
234                height: size.height - spec.track_border_width,
235            },
236            Brush::Solid(colors.track_border),
237            CornerRadii::uniform(radius - inset),
238            cranpose_ui_graphics::Stroke::new(spec.track_border_width),
239        );
240    }
241
242    let (centre_x, thumb_radius) = switch_thumb(spec, spec.progress);
243    scope.draw_circle(
244        Brush::Solid(colors.thumb),
245        Point {
246            x: centre_x,
247            y: radius,
248        },
249        thumb_radius,
250    );
251
252    draw_tick(
253        scope,
254        Point {
255            x: centre_x,
256            y: radius,
257        },
258        switch_tick_scale(spec.progress),
259        colors.tick,
260    );
261}
262
263/// Wear's tick, centred on a point.
264///
265/// The path lives in a 24x24dp design box and is **two disjoint subpaths**, not
266/// one polyline: the joint is two overlapping round caps rather than a stroke
267/// join, which is a visibly different corner.
268fn draw_tick(scope: &mut dyn DrawScope, centre: Point, scale: f32, color: Color) {
269    if scale <= 0.0 || color.3 <= 0.0 {
270        return;
271    }
272    const SEGMENTS: [((f32, f32), (f32, f32)); 2] = [
273        ((7.4 - 12.0, 13.0 - 12.0), (9.9 - 12.0, 15.5 - 12.0)),
274        ((10.5 - 12.0, 15.1 - 12.0), (16.5 - 12.0, 9.1 - 12.0)),
275    ];
276    const STROKE: f32 = 2.0;
277    let half = STROKE * scale * 0.5;
278    for (start, end) in SEGMENTS {
279        let a = Point {
280            x: centre.x + start.0 * scale,
281            y: centre.y + start.1 * scale,
282        };
283        let b = Point {
284            x: centre.x + end.0 * scale,
285            y: centre.y + end.1 * scale,
286        };
287        stroke_round_capped(scope, a, b, half, color);
288    }
289}
290
291/// A round-capped straight stroke, as its exact constituent shapes.
292///
293/// A round-capped stroke is the sum of the segment and a disc, so it is a
294/// parallelogram body between two circles. `DrawScope` has no line primitive
295/// and `draw_vector_path` fills rather than strokes, so this composes the three
296/// shapes it is made of rather than approximating it.
297fn stroke_round_capped(scope: &mut dyn DrawScope, a: Point, b: Point, half: f32, color: Color) {
298    if half <= 0.0 {
299        return;
300    }
301    let (dx, dy) = (b.x - a.x, b.y - a.y);
302    let length = (dx * dx + dy * dy).sqrt();
303    if length > f32::EPSILON {
304        let (nx, ny) = (-dy / length * half, dx / length * half);
305        let body = format!(
306            "M {} {} L {} {} L {} {} L {} {} Z",
307            a.x + nx,
308            a.y + ny,
309            b.x + nx,
310            b.y + ny,
311            b.x - nx,
312            b.y - ny,
313            a.x - nx,
314            a.y - ny
315        );
316        if let Ok(path) = VectorPath::parse(&body) {
317            scope.draw_vector_path(&path, Brush::Solid(color));
318        }
319    }
320    scope.draw_circle(Brush::Solid(color), a, half);
321    scope.draw_circle(Brush::Solid(color), b, half);
322}
323
324/// A switch row: a label, an optional secondary label, and a switch.
325///
326/// `on_checked_change` is handed the **new** value, matching
327/// `Modifier.toggleable`. It is unwrapped into a zero-argument callback for the
328/// node below because a composable's callback parameters take no arguments —
329/// the macro re-installs them through an `Fn()`.
330pub fn SwitchButton<F>(
331    modifier: Modifier,
332    spec: SwitchButtonSpec,
333    checked: bool,
334    label: String,
335    secondary_label: Option<String>,
336    on_checked_change: F,
337) -> NodeId
338where
339    F: Fn(bool) + 'static,
340{
341    SwitchButtonNode(modifier, spec, checked, label, secondary_label, move || {
342        on_checked_change(!checked);
343    })
344}
345
346/// The node half of [`SwitchButton`].
347#[composable]
348pub fn SwitchButtonNode<F>(
349    modifier: Modifier,
350    spec: SwitchButtonSpec,
351    checked: bool,
352    label: String,
353    secondary_label: Option<String>,
354    on_toggle: F,
355) -> NodeId
356where
357    F: FnMut() + 'static,
358{
359    let density = crate::density::density();
360    let colors = SwitchColors::of(spec.colors, checked);
361    let radius = density.dp(spec.corner_radius);
362    let container = colors.container;
363    let chrome = modifier
364        .draw_behind(move |scope: &mut dyn DrawScope| {
365            scope.draw_round_rect(Brush::Solid(container), CornerRadii::uniform(radius));
366        })
367        .toggleable(
368            checked,
369            Some(format!("{label}, {}", if checked { "on" } else { "off" })),
370            Some(SemanticsWidgetRole::Switch),
371            move |_next| on_toggle(),
372        );
373
374    let label_style = spec.label_style.resolve(colors.label);
375    let secondary_style = spec.secondary_style.resolve(colors.secondary_label);
376    let label_text = label;
377    Layout(
378        chrome,
379        SwitchButtonMeasurePolicy {
380            spec,
381            density: density.density(),
382            has_secondary: secondary_label.is_some(),
383        },
384        move || {
385            Text(label_text.clone(), Modifier::empty(), label_style.clone());
386            if let Some(secondary) = secondary_label.clone() {
387                Text(secondary, Modifier::empty(), secondary_style.clone());
388            }
389            SwitchGraphic(Modifier::empty(), spec, colors);
390        },
391    )
392}
393
394/// The switch itself, as its own node so it has its own hit rect and its own
395/// place in the tree.
396#[composable]
397pub fn SwitchGraphic(modifier: Modifier, spec: SwitchButtonSpec, colors: SwitchColors) -> NodeId {
398    crate::widgets::Canvas(
399        modifier.size_points(spec.switch_width, spec.switch_height),
400        move |scope: &mut dyn DrawScope| draw_switch(scope, spec, colors),
401    )
402}
403
404#[derive(Clone, Debug, PartialEq)]
405struct SwitchButtonMeasurePolicy {
406    spec: SwitchButtonSpec,
407    density: f32,
408    has_secondary: bool,
409}
410
411impl MeasurePolicy for SwitchButtonMeasurePolicy {
412    fn measure(
413        &self,
414        scope: &dyn MeasureScope,
415        measurables: &[Box<dyn Measurable>],
416        constraints: Constraints,
417    ) -> MeasureResult {
418        let mut placements = Vec::new();
419        let measurement = self.measure_into(scope, measurables, constraints, &mut placements);
420        MeasureResult::new(measurement, placements)
421    }
422
423    fn measure_into(
424        &self,
425        _scope: &dyn MeasureScope,
426        measurables: &[Box<dyn Measurable>],
427        constraints: Constraints,
428        placements: &mut Vec<Placement>,
429    ) -> cranpose_ui_layout::Measurement {
430        placements.clear();
431        let density = Density::new(self.density, 1.0);
432        let horizontal = density.dp(self.spec.padding_horizontal) * 2.0;
433        let vertical = density.dp(self.spec.padding_vertical) * 2.0;
434        let width = if constraints.max_width.is_finite() {
435            constraints.max_width
436        } else {
437            constraints.min_width
438        };
439
440        let switch_width = density.dp(self.spec.switch_width);
441        let spacing = density.dp(self.spec.control_spacing);
442        let label_width = (width - horizontal - switch_width - spacing).max(0.0);
443        let label_constraints = Constraints {
444            min_width: 0.0,
445            max_width: label_width,
446            min_height: 0.0,
447            max_height: f32::INFINITY,
448        };
449
450        let label_count = if self.has_secondary { 2 } else { 1 };
451        let mut labels = Vec::with_capacity(label_count);
452        for measurable in measurables.iter().take(label_count) {
453            labels.push(measurable.measure(label_constraints));
454        }
455        let switch = measurables.get(label_count).map(|measurable| {
456            measurable.measure(Constraints {
457                min_width: 0.0,
458                max_width: switch_width,
459                min_height: 0.0,
460                max_height: f32::INFINITY,
461            })
462        });
463
464        let label_spacing = density.dp(self.spec.label_spacing);
465        let column: f32 = labels
466            .iter()
467            .map(|placeable| density.ceil(placeable.height()))
468            .sum::<f32>()
469            + label_spacing * labels.len().saturating_sub(1) as f32;
470        let slot = density.dp(self.spec.switch_slot_height);
471        let content_demand = column.max(slot);
472        let height = density
473            .dp(self.spec.min_height)
474            .max(density.ceil(content_demand) + vertical)
475            .clamp(constraints.min_height, constraints.max_height);
476
477        let content = height - vertical;
478        let padding_top = density.dp(self.spec.padding_vertical);
479        let mut y = padding_top + density.centre(content, column);
480        let x = density.dp(self.spec.padding_horizontal);
481        for placeable in &labels {
482            placements.push(Placement::new(placeable.node_id(), x, y, 0));
483            y += density.ceil(placeable.height()) + label_spacing;
484        }
485
486        if let Some(switch) = switch {
487            let slot_top = padding_top + density.centre(content, slot);
488            let switch_x = width - density.dp(self.spec.padding_horizontal) - switch.width();
489            placements.push(Placement::new(switch.node_id(), switch_x, slot_top, 0));
490        }
491
492        Size::new(width, height).into()
493    }
494
495    fn min_intrinsic_width(&self, measurables: &[Box<dyn Measurable>], height: f32) -> f32 {
496        let density = Density::new(self.density, 1.0);
497        measurables
498            .iter()
499            .map(|m| m.min_intrinsic_width(height))
500            .fold(0.0, f32::max)
501            + density.dp(self.spec.padding_horizontal) * 2.0
502            + density.dp(self.spec.switch_width)
503            + density.dp(self.spec.control_spacing)
504    }
505
506    fn max_intrinsic_width(&self, measurables: &[Box<dyn Measurable>], height: f32) -> f32 {
507        self.min_intrinsic_width(measurables, height)
508    }
509
510    fn min_intrinsic_height(&self, measurables: &[Box<dyn Measurable>], width: f32) -> f32 {
511        let density = Density::new(self.density, 1.0);
512        let label_count = if self.has_secondary { 2 } else { 1 };
513        let column: f32 = measurables
514            .iter()
515            .take(label_count)
516            .map(|m| m.min_intrinsic_height(width))
517            .sum::<f32>();
518        density.dp(self.spec.min_height).max(
519            density.ceil(column.max(density.dp(self.spec.switch_slot_height)))
520                + density.dp(self.spec.padding_vertical) * 2.0,
521        )
522    }
523
524    fn max_intrinsic_height(&self, measurables: &[Box<dyn Measurable>], width: f32) -> f32 {
525        self.min_intrinsic_height(measurables, width)
526    }
527}
528
529#[cfg(test)]
530#[path = "tests/switch_button_tests.rs"]
531mod tests;