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#![allow(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    pub colors: WearColors,
41    pub label_style: WearTextStyle,
42    pub secondary_style: WearTextStyle,
43    pub min_height: f32,
44    pub corner_radius: f32,
45    pub padding_horizontal: f32,
46    pub padding_vertical: f32,
47    /// `SWITCH_WIDTH`.
48    pub switch_width: f32,
49    /// `SWITCH_OUTER_HEIGHT` — the slot the graphic is placed in.
50    pub switch_slot_height: f32,
51    /// `SWITCH_INNER_HEIGHT` — what the graphic actually draws.
52    pub switch_height: f32,
53    /// `SWITCH_TRACK_WIDTH` — the unchecked state's border stroke.
54    pub track_border_width: f32,
55    pub thumb_radius_unchecked: f32,
56    pub thumb_radius_checked: f32,
57    /// `TOGGLE_CONTROL_SPACING`.
58    pub control_spacing: f32,
59    /// `LabelSpacerSize`.
60    pub label_spacing: f32,
61    /// How far the thumb has travelled, `0.0` unchecked and `1.0` checked.
62    ///
63    /// A caller animating the transition drives this; a caller that does not
64    /// passes the checked state itself. Wear animates it with
65    /// `motionScheme.fastEffectsSpec()`, a critically damped spring of
66    /// stiffness 1400.
67    pub progress: f32,
68}
69
70impl Default for SwitchButtonSpec {
71    fn default() -> Self {
72        Self {
73            colors: WearColors::default(),
74            label_style: WearTextStyle::LABEL_MEDIUM,
75            secondary_style: WearTextStyle::LABEL_SMALL,
76            min_height: 52.0,
77            corner_radius: 26.0,
78            padding_horizontal: 14.0,
79            padding_vertical: 8.0,
80            switch_width: 32.0,
81            switch_slot_height: 24.0,
82            switch_height: 22.0,
83            track_border_width: 2.0,
84            thumb_radius_unchecked: 6.0,
85            thumb_radius_checked: 9.0,
86            control_spacing: 6.0,
87            label_spacing: 1.0,
88            progress: 0.0,
89        }
90    }
91}
92
93impl SwitchButtonSpec {
94    pub fn colors(mut self, colors: WearColors) -> Self {
95        self.colors = colors;
96        self
97    }
98
99    /// The thumb travel, which a caller animates or snaps.
100    pub fn progress(mut self, progress: f32) -> Self {
101        self.progress = progress.clamp(0.0, 1.0);
102        self
103    }
104}
105
106/// The stiffness and damping of the two springs a Wear switch animates on.
107///
108/// `MotionScheme.standard()`: `fastEffectsSpec` moves the thumb, its radius and
109/// the tick's scale; `slowEffectsSpec` moves every colour. Both are critically
110/// damped — a switch does not bounce.
111pub const SWITCH_THUMB_STIFFNESS: f32 = 1400.0;
112pub const SWITCH_COLOR_STIFFNESS: f32 = 260.0;
113pub const SWITCH_DAMPING_RATIO: f32 = 1.0;
114
115/// The colours one state of the switch draws with.
116#[derive(Clone, Copy, Debug, PartialEq)]
117pub struct SwitchColors {
118    pub container: Color,
119    pub label: Color,
120    pub secondary_label: Color,
121    pub track: Color,
122    /// `Color::rgba(_, _, _, 0.0)` when the border is suppressed.
123    pub track_border: Color,
124    pub thumb: Color,
125    pub tick: Color,
126}
127
128impl SwitchColors {
129    /// The slots a switch resolves to at a given checked state.
130    ///
131    /// The border suppression lives here: `if currentTrackColor ==
132    /// currentTrackBorderColor { borderColor = Color.Transparent }`, which in
133    /// the checked state is always true because both are `primary`.
134    pub fn of(colors: WearColors, checked: bool) -> Self {
135        let (container, label, secondary_label, track, border, thumb, tick) = if checked {
136            (
137                colors.primary_container,
138                colors.on_primary_container,
139                fade(colors.on_primary_container, 0.9),
140                colors.primary,
141                colors.primary,
142                colors.primary_container,
143                colors.primary,
144            )
145        } else {
146            (
147                colors.surface_container,
148                colors.on_surface,
149                colors.on_surface_variant,
150                colors.surface_container,
151                colors.outline,
152                colors.outline,
153                colors.primary,
154            )
155        };
156        let track_border = if border == track {
157            Color::rgba(border.0, border.1, border.2, 0.0)
158        } else {
159            border
160        };
161        Self {
162            container,
163            label,
164            secondary_label,
165            track,
166            track_border,
167            thumb,
168            tick,
169        }
170    }
171}
172
173fn fade(color: Color, alpha: f32) -> Color {
174    Color::rgba(color.0, color.1, color.2, color.3 * alpha)
175}
176
177/// Where the thumb is and how big, at a given progress.
178///
179/// `thumbPadding` is `height/2 - radius` on each side, so the thumb grows into
180/// its own margin as it travels: 5dp of clearance unchecked and 2dp checked.
181pub fn switch_thumb(spec: SwitchButtonSpec, progress: f32) -> (f32, f32) {
182    let progress = progress.clamp(0.0, 1.0);
183    let radius = spec.thumb_radius_unchecked
184        + (spec.thumb_radius_checked - spec.thumb_radius_unchecked) * progress;
185    let half = spec.switch_height * 0.5;
186    let start = radius + (half - spec.thumb_radius_unchecked);
187    let end = spec.switch_width - radius - (half - spec.thumb_radius_checked);
188    (start + (end - start) * progress, radius)
189}
190
191/// The tick's scale factor: a cubic ease-out on the thumb progress.
192///
193/// The stroke scales with the canvas, so the tick's own line thins as it grows
194/// in rather than being drawn thin and widening.
195pub fn switch_tick_scale(progress: f32) -> f32 {
196    let remaining = 1.0 - progress.clamp(0.0, 1.0);
197    1.0 - remaining * remaining * remaining
198}
199
200/// Draws the 32x22dp switch graphic into a scope of exactly that size.
201pub fn draw_switch(scope: &mut dyn DrawScope, spec: SwitchButtonSpec, colors: SwitchColors) {
202    let size = scope.size();
203    if size.width <= 0.0 || size.height <= 0.0 {
204        return;
205    }
206    let radius = size.height * 0.5;
207    scope.draw_round_rect(Brush::Solid(colors.track), CornerRadii::uniform(radius));
208
209    if colors.track_border.3 > 0.0 {
210        // A centred stroke, so the ring is inset by half its own width and the
211        // rounded corner shrinks with it.
212        let inset = spec.track_border_width * 0.5;
213        scope.draw_round_rect_at_stroked(
214            Rect {
215                x: inset,
216                y: inset,
217                width: size.width - spec.track_border_width,
218                height: size.height - spec.track_border_width,
219            },
220            Brush::Solid(colors.track_border),
221            CornerRadii::uniform(radius - inset),
222            cranpose_ui_graphics::Stroke::new(spec.track_border_width),
223        );
224    }
225
226    let (centre_x, thumb_radius) = switch_thumb(spec, spec.progress);
227    scope.draw_circle(
228        Brush::Solid(colors.thumb),
229        Point {
230            x: centre_x,
231            y: radius,
232        },
233        thumb_radius,
234    );
235
236    draw_tick(
237        scope,
238        Point {
239            x: centre_x,
240            y: radius,
241        },
242        switch_tick_scale(spec.progress),
243        colors.tick,
244    );
245}
246
247/// Wear's tick, centred on a point.
248///
249/// The path lives in a 24x24dp design box and is **two disjoint subpaths**, not
250/// one polyline: the joint is two overlapping round caps rather than a stroke
251/// join, which is a visibly different corner.
252fn draw_tick(scope: &mut dyn DrawScope, centre: Point, scale: f32, color: Color) {
253    if scale <= 0.0 || color.3 <= 0.0 {
254        return;
255    }
256    // (start, end) in the 24x24 design box, relative to its (12, 12) centre.
257    const SEGMENTS: [((f32, f32), (f32, f32)); 2] = [
258        ((7.4 - 12.0, 13.0 - 12.0), (9.9 - 12.0, 15.5 - 12.0)),
259        ((10.5 - 12.0, 15.1 - 12.0), (16.5 - 12.0, 9.1 - 12.0)),
260    ];
261    const STROKE: f32 = 2.0;
262    let half = STROKE * scale * 0.5;
263    for (start, end) in SEGMENTS {
264        let a = Point {
265            x: centre.x + start.0 * scale,
266            y: centre.y + start.1 * scale,
267        };
268        let b = Point {
269            x: centre.x + end.0 * scale,
270            y: centre.y + end.1 * scale,
271        };
272        stroke_round_capped(scope, a, b, half, color);
273    }
274}
275
276/// A round-capped straight stroke, as its exact constituent shapes.
277///
278/// A round-capped stroke is the sum of the segment and a disc, so it is a
279/// parallelogram body between two circles. `DrawScope` has no line primitive
280/// and `draw_vector_path` fills rather than strokes, so this composes the three
281/// shapes it is made of rather than approximating it.
282fn stroke_round_capped(scope: &mut dyn DrawScope, a: Point, b: Point, half: f32, color: Color) {
283    if half <= 0.0 {
284        return;
285    }
286    let (dx, dy) = (b.x - a.x, b.y - a.y);
287    let length = (dx * dx + dy * dy).sqrt();
288    if length > f32::EPSILON {
289        let (nx, ny) = (-dy / length * half, dx / length * half);
290        let body = format!(
291            "M {} {} L {} {} L {} {} L {} {} Z",
292            a.x + nx,
293            a.y + ny,
294            b.x + nx,
295            b.y + ny,
296            b.x - nx,
297            b.y - ny,
298            a.x - nx,
299            a.y - ny
300        );
301        if let Ok(path) = VectorPath::parse(&body) {
302            scope.draw_vector_path(&path, Brush::Solid(color));
303        }
304    }
305    scope.draw_circle(Brush::Solid(color), a, half);
306    scope.draw_circle(Brush::Solid(color), b, half);
307}
308
309/// A switch row: a label, an optional secondary label, and a switch.
310///
311/// `on_checked_change` is handed the **new** value, matching
312/// `Modifier.toggleable`. It is unwrapped into a zero-argument callback for the
313/// node below because a composable's callback parameters take no arguments —
314/// the macro re-installs them through an `Fn()`.
315pub fn SwitchButton<F>(
316    modifier: Modifier,
317    spec: SwitchButtonSpec,
318    checked: bool,
319    label: String,
320    secondary_label: Option<String>,
321    on_checked_change: F,
322) -> NodeId
323where
324    F: Fn(bool) + 'static,
325{
326    SwitchButtonNode(modifier, spec, checked, label, secondary_label, move || {
327        on_checked_change(!checked)
328    })
329}
330
331/// The node half of [`SwitchButton`].
332#[composable]
333pub fn SwitchButtonNode<F>(
334    modifier: Modifier,
335    spec: SwitchButtonSpec,
336    checked: bool,
337    label: String,
338    secondary_label: Option<String>,
339    on_toggle: F,
340) -> NodeId
341where
342    F: FnMut() + 'static,
343{
344    let density = crate::density::density();
345    let colors = SwitchColors::of(spec.colors, checked);
346    let radius = density.dp(spec.corner_radius);
347    let container = colors.container;
348    let chrome = modifier
349        .draw_behind(move |scope: &mut dyn DrawScope| {
350            scope.draw_round_rect(Brush::Solid(container), CornerRadii::uniform(radius));
351        })
352        // `Role.Switch`, which is what tells a screen reader the row is a
353        // toggle before it is acted on rather than after. Wear puts it on the
354        // `Switch` control inside the row and lets the merge carry it up; this
355        // names it on the row, which is the same announcement and does not
356        // depend on the merge.
357        .toggleable(
358            checked,
359            Some(format!("{label}, {}", if checked { "on" } else { "off" })),
360            Some(SemanticsWidgetRole::Switch),
361            move |_next| on_toggle(),
362        );
363
364    let label_style = spec.label_style.resolve(colors.label);
365    let secondary_style = spec.secondary_style.resolve(colors.secondary_label);
366    let label_text = label;
367    Layout(
368        chrome,
369        SwitchButtonMeasurePolicy {
370            spec,
371            density: density.density(),
372            has_secondary: secondary_label.is_some(),
373        },
374        move || {
375            Text(label_text.clone(), Modifier::empty(), label_style.clone());
376            if let Some(secondary) = secondary_label.clone() {
377                Text(secondary, Modifier::empty(), secondary_style.clone());
378            }
379            SwitchGraphic(Modifier::empty(), spec, colors);
380        },
381    )
382}
383
384/// The switch itself, as its own node so it has its own hit rect and its own
385/// place in the tree.
386#[composable]
387pub fn SwitchGraphic(modifier: Modifier, spec: SwitchButtonSpec, colors: SwitchColors) -> NodeId {
388    crate::widgets::Canvas(
389        modifier.size_points(spec.switch_width, spec.switch_height),
390        move |scope: &mut dyn DrawScope| draw_switch(scope, spec, colors),
391    )
392}
393
394#[derive(Clone, Debug, PartialEq)]
395struct SwitchButtonMeasurePolicy {
396    spec: SwitchButtonSpec,
397    density: f32,
398    has_secondary: bool,
399}
400
401impl MeasurePolicy for SwitchButtonMeasurePolicy {
402    fn measure(
403        &self,
404        scope: &dyn MeasureScope,
405        measurables: &[Box<dyn Measurable>],
406        constraints: Constraints,
407    ) -> MeasureResult {
408        let mut placements = Vec::new();
409        let size = self.measure_into(scope, measurables, constraints, &mut placements);
410        MeasureResult::new(size, placements)
411    }
412
413    fn measure_into(
414        &self,
415        _scope: &dyn MeasureScope,
416        measurables: &[Box<dyn Measurable>],
417        constraints: Constraints,
418        placements: &mut Vec<Placement>,
419    ) -> Size {
420        placements.clear();
421        let density = Density::new(self.density, 1.0);
422        let horizontal = density.dp(self.spec.padding_horizontal) * 2.0;
423        let vertical = density.dp(self.spec.padding_vertical) * 2.0;
424        let width = if constraints.max_width.is_finite() {
425            constraints.max_width
426        } else {
427            constraints.min_width
428        };
429
430        // The switch is measured first because the label column gets whatever
431        // is left: `Labels` has `weight(1f)` and absorbs all the slack, so its
432        // width is a subtraction rather than an intrinsic.
433        let switch_width = density.dp(self.spec.switch_width);
434        let spacing = density.dp(self.spec.control_spacing);
435        let label_width = (width - horizontal - switch_width - spacing).max(0.0);
436        let label_constraints = Constraints {
437            min_width: 0.0,
438            max_width: label_width,
439            min_height: 0.0,
440            max_height: f32::INFINITY,
441        };
442
443        let label_count = if self.has_secondary { 2 } else { 1 };
444        let mut labels = Vec::with_capacity(label_count);
445        for measurable in measurables.iter().take(label_count) {
446            labels.push(measurable.measure(label_constraints));
447        }
448        let switch = measurables.get(label_count).map(|measurable| {
449            measurable.measure(Constraints {
450                min_width: 0.0,
451                max_width: switch_width,
452                min_height: 0.0,
453                max_height: f32::INFINITY,
454            })
455        });
456
457        let label_spacing = density.dp(self.spec.label_spacing);
458        let column: f32 = labels
459            .iter()
460            .map(|placeable| density.ceil(placeable.height()))
461            .sum::<f32>()
462            + label_spacing * labels.len().saturating_sub(1) as f32;
463        let slot = density.dp(self.spec.switch_slot_height);
464        let content_demand = column.max(slot);
465        let height = density
466            .dp(self.spec.min_height)
467            .max(density.ceil(content_demand) + vertical)
468            .clamp(constraints.min_height, constraints.max_height);
469
470        let content = height - vertical;
471        let padding_top = density.dp(self.spec.padding_vertical);
472        let mut y = padding_top + density.centre(content, column);
473        let x = density.dp(self.spec.padding_horizontal);
474        for placeable in &labels {
475            placements.push(Placement::new(placeable.node_id(), x, y, 0));
476            y += density.ceil(placeable.height()) + label_spacing;
477        }
478
479        if let Some(switch) = switch {
480            // The 24dp slot is centred in the content area, and the 22dp
481            // graphic is TOP-aligned inside that slot — so the graphic ends up
482            // one slot-slack above the row's centre, not on it.
483            let slot_top = padding_top + density.centre(content, slot);
484            let switch_x = width - density.dp(self.spec.padding_horizontal) - switch.width();
485            placements.push(Placement::new(switch.node_id(), switch_x, slot_top, 0));
486        }
487
488        Size::new(width, height)
489    }
490
491    fn min_intrinsic_width(&self, measurables: &[Box<dyn Measurable>], height: f32) -> f32 {
492        let density = Density::new(self.density, 1.0);
493        measurables
494            .iter()
495            .map(|m| m.min_intrinsic_width(height))
496            .fold(0.0, f32::max)
497            + density.dp(self.spec.padding_horizontal) * 2.0
498            + density.dp(self.spec.switch_width)
499            + density.dp(self.spec.control_spacing)
500    }
501
502    fn max_intrinsic_width(&self, measurables: &[Box<dyn Measurable>], height: f32) -> f32 {
503        self.min_intrinsic_width(measurables, height)
504    }
505
506    fn min_intrinsic_height(&self, measurables: &[Box<dyn Measurable>], width: f32) -> f32 {
507        let density = Density::new(self.density, 1.0);
508        let label_count = if self.has_secondary { 2 } else { 1 };
509        let column: f32 = measurables
510            .iter()
511            .take(label_count)
512            .map(|m| m.min_intrinsic_height(width))
513            .sum::<f32>();
514        density.dp(self.spec.min_height).max(
515            density.ceil(column.max(density.dp(self.spec.switch_slot_height)))
516                + density.dp(self.spec.padding_vertical) * 2.0,
517        )
518    }
519
520    fn max_intrinsic_height(&self, measurables: &[Box<dyn Measurable>], width: f32) -> f32 {
521        self.min_intrinsic_height(measurables, width)
522    }
523}
524
525#[cfg(test)]
526mod tests {
527    use cranpose_ui_graphics::{DrawScopeDefault, Size as GraphicsSize};
528
529    use super::*;
530
531    fn colors() -> WearColors {
532        WearColors {
533            primary: Color::from_rgb_u8(0xB9, 0xF2, 0xFF),
534            primary_container: Color::from_rgb_u8(0x0F, 0x36, 0x4E),
535            surface_container: Color::from_rgb_u8(0x0A, 0x16, 0x22),
536            outline: Color::from_rgb_u8(0x1D, 0x4D, 0x69),
537            ..WearColors::default()
538        }
539    }
540
541    #[test]
542    fn the_defaults_are_the_ones_the_tokens_declare() {
543        let spec = SwitchButtonSpec::default();
544        assert_eq!(spec.min_height, 52.0);
545        assert_eq!(spec.corner_radius, 26.0);
546        assert_eq!(spec.padding_horizontal, 14.0);
547        assert_eq!(spec.padding_vertical, 8.0);
548        assert_eq!(spec.switch_width, 32.0);
549        assert_eq!(spec.switch_slot_height, 24.0);
550        assert_eq!(spec.switch_height, 22.0);
551        assert_eq!(spec.track_border_width, 2.0);
552        assert_eq!(spec.thumb_radius_unchecked, 6.0);
553        assert_eq!(spec.thumb_radius_checked, 9.0);
554        assert_eq!(spec.control_spacing, 6.0);
555    }
556
557    #[test]
558    fn the_thumb_travels_between_the_two_measured_positions() {
559        let spec = SwitchButtonSpec::default();
560        let (unchecked, radius_off) = switch_thumb(spec, 0.0);
561        assert_eq!(unchecked, 11.0, "22px at density 2");
562        assert_eq!(radius_off, 6.0);
563        let (checked, radius_on) = switch_thumb(spec, 1.0);
564        assert_eq!(checked, 21.0, "42px at density 2");
565        assert_eq!(radius_on, 9.0);
566    }
567
568    #[test]
569    fn a_checked_switch_draws_no_border_and_an_unchecked_one_does() {
570        let checked = SwitchColors::of(colors(), true);
571        assert_eq!(
572            checked.track_border.3, 0.0,
573            "track and border are both primary, so the border is suppressed"
574        );
575        assert_eq!(checked.track, colors().primary);
576        assert_eq!(checked.thumb, colors().primary_container);
577
578        let unchecked = SwitchColors::of(colors(), false);
579        assert!(unchecked.track_border.3 > 0.0);
580        assert_eq!(unchecked.track_border, colors().outline);
581    }
582
583    #[test]
584    fn a_checked_thumb_is_the_container_colour_and_still_gets_drawn() {
585        let scheme = colors();
586        let checked = SwitchColors::of(scheme, true);
587        assert_eq!(
588            checked.thumb, checked.container,
589            "invisible against the row, and still occluding the track"
590        );
591        let mut scope = DrawScopeDefault::new(GraphicsSize::new(32.0, 22.0));
592        draw_switch(
593            &mut scope,
594            SwitchButtonSpec::default().colors(scheme).progress(1.0),
595            checked,
596        );
597        let primitives = scope.into_primitives();
598        // Track, thumb, and the tick's two segments as body + two caps each.
599        assert_eq!(primitives.len(), 8);
600    }
601
602    #[test]
603    fn an_unchecked_switch_draws_its_ring_and_no_tick() {
604        let mut scope = DrawScopeDefault::new(GraphicsSize::new(32.0, 22.0));
605        draw_switch(
606            &mut scope,
607            SwitchButtonSpec::default().colors(colors()).progress(0.0),
608            SwitchColors::of(colors(), false),
609        );
610        // Track, border ring, thumb — and nothing else, because the tick's
611        // scale is zero at progress zero.
612        assert_eq!(scope.into_primitives().len(), 3);
613    }
614
615    #[test]
616    fn the_tick_eases_out_rather_than_growing_linearly() {
617        assert_eq!(switch_tick_scale(0.0), 0.0);
618        assert_eq!(switch_tick_scale(1.0), 1.0);
619        // A cubic ease-out is past halfway before the thumb is.
620        assert!(switch_tick_scale(0.5) > 0.5);
621        assert!((switch_tick_scale(0.5) - 0.875).abs() < 1e-6);
622    }
623
624    #[test]
625    fn the_springs_are_the_ones_the_standard_motion_scheme_declares() {
626        assert_eq!(SWITCH_THUMB_STIFFNESS, 1400.0);
627        assert_eq!(SWITCH_COLOR_STIFFNESS, 260.0);
628        assert_eq!(SWITCH_DAMPING_RATIO, 1.0);
629    }
630}