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        let inset = spec.track_border_width * 0.5;
211        scope.draw_round_rect_at_stroked(
212            Rect {
213                x: inset,
214                y: inset,
215                width: size.width - spec.track_border_width,
216                height: size.height - spec.track_border_width,
217            },
218            Brush::Solid(colors.track_border),
219            CornerRadii::uniform(radius - inset),
220            cranpose_ui_graphics::Stroke::new(spec.track_border_width),
221        );
222    }
223
224    let (centre_x, thumb_radius) = switch_thumb(spec, spec.progress);
225    scope.draw_circle(
226        Brush::Solid(colors.thumb),
227        Point {
228            x: centre_x,
229            y: radius,
230        },
231        thumb_radius,
232    );
233
234    draw_tick(
235        scope,
236        Point {
237            x: centre_x,
238            y: radius,
239        },
240        switch_tick_scale(spec.progress),
241        colors.tick,
242    );
243}
244
245/// Wear's tick, centred on a point.
246///
247/// The path lives in a 24x24dp design box and is **two disjoint subpaths**, not
248/// one polyline: the joint is two overlapping round caps rather than a stroke
249/// join, which is a visibly different corner.
250fn draw_tick(scope: &mut dyn DrawScope, centre: Point, scale: f32, color: Color) {
251    if scale <= 0.0 || color.3 <= 0.0 {
252        return;
253    }
254    const SEGMENTS: [((f32, f32), (f32, f32)); 2] = [
255        ((7.4 - 12.0, 13.0 - 12.0), (9.9 - 12.0, 15.5 - 12.0)),
256        ((10.5 - 12.0, 15.1 - 12.0), (16.5 - 12.0, 9.1 - 12.0)),
257    ];
258    const STROKE: f32 = 2.0;
259    let half = STROKE * scale * 0.5;
260    for (start, end) in SEGMENTS {
261        let a = Point {
262            x: centre.x + start.0 * scale,
263            y: centre.y + start.1 * scale,
264        };
265        let b = Point {
266            x: centre.x + end.0 * scale,
267            y: centre.y + end.1 * scale,
268        };
269        stroke_round_capped(scope, a, b, half, color);
270    }
271}
272
273/// A round-capped straight stroke, as its exact constituent shapes.
274///
275/// A round-capped stroke is the sum of the segment and a disc, so it is a
276/// parallelogram body between two circles. `DrawScope` has no line primitive
277/// and `draw_vector_path` fills rather than strokes, so this composes the three
278/// shapes it is made of rather than approximating it.
279fn stroke_round_capped(scope: &mut dyn DrawScope, a: Point, b: Point, half: f32, color: Color) {
280    if half <= 0.0 {
281        return;
282    }
283    let (dx, dy) = (b.x - a.x, b.y - a.y);
284    let length = (dx * dx + dy * dy).sqrt();
285    if length > f32::EPSILON {
286        let (nx, ny) = (-dy / length * half, dx / length * half);
287        let body = format!(
288            "M {} {} L {} {} L {} {} L {} {} Z",
289            a.x + nx,
290            a.y + ny,
291            b.x + nx,
292            b.y + ny,
293            b.x - nx,
294            b.y - ny,
295            a.x - nx,
296            a.y - ny
297        );
298        if let Ok(path) = VectorPath::parse(&body) {
299            scope.draw_vector_path(&path, Brush::Solid(color));
300        }
301    }
302    scope.draw_circle(Brush::Solid(color), a, half);
303    scope.draw_circle(Brush::Solid(color), b, half);
304}
305
306/// A switch row: a label, an optional secondary label, and a switch.
307///
308/// `on_checked_change` is handed the **new** value, matching
309/// `Modifier.toggleable`. It is unwrapped into a zero-argument callback for the
310/// node below because a composable's callback parameters take no arguments —
311/// the macro re-installs them through an `Fn()`.
312pub fn SwitchButton<F>(
313    modifier: Modifier,
314    spec: SwitchButtonSpec,
315    checked: bool,
316    label: String,
317    secondary_label: Option<String>,
318    on_checked_change: F,
319) -> NodeId
320where
321    F: Fn(bool) + 'static,
322{
323    SwitchButtonNode(modifier, spec, checked, label, secondary_label, move || {
324        on_checked_change(!checked)
325    })
326}
327
328/// The node half of [`SwitchButton`].
329#[composable]
330pub fn SwitchButtonNode<F>(
331    modifier: Modifier,
332    spec: SwitchButtonSpec,
333    checked: bool,
334    label: String,
335    secondary_label: Option<String>,
336    on_toggle: F,
337) -> NodeId
338where
339    F: FnMut() + 'static,
340{
341    let density = crate::density::density();
342    let colors = SwitchColors::of(spec.colors, checked);
343    let radius = density.dp(spec.corner_radius);
344    let container = colors.container;
345    let chrome = modifier
346        .draw_behind(move |scope: &mut dyn DrawScope| {
347            scope.draw_round_rect(Brush::Solid(container), CornerRadii::uniform(radius));
348        })
349        .toggleable(
350            checked,
351            Some(format!("{label}, {}", if checked { "on" } else { "off" })),
352            Some(SemanticsWidgetRole::Switch),
353            move |_next| on_toggle(),
354        );
355
356    let label_style = spec.label_style.resolve(colors.label);
357    let secondary_style = spec.secondary_style.resolve(colors.secondary_label);
358    let label_text = label;
359    Layout(
360        chrome,
361        SwitchButtonMeasurePolicy {
362            spec,
363            density: density.density(),
364            has_secondary: secondary_label.is_some(),
365        },
366        move || {
367            Text(label_text.clone(), Modifier::empty(), label_style.clone());
368            if let Some(secondary) = secondary_label.clone() {
369                Text(secondary, Modifier::empty(), secondary_style.clone());
370            }
371            SwitchGraphic(Modifier::empty(), spec, colors);
372        },
373    )
374}
375
376/// The switch itself, as its own node so it has its own hit rect and its own
377/// place in the tree.
378#[composable]
379pub fn SwitchGraphic(modifier: Modifier, spec: SwitchButtonSpec, colors: SwitchColors) -> NodeId {
380    crate::widgets::Canvas(
381        modifier.size_points(spec.switch_width, spec.switch_height),
382        move |scope: &mut dyn DrawScope| draw_switch(scope, spec, colors),
383    )
384}
385
386#[derive(Clone, Debug, PartialEq)]
387struct SwitchButtonMeasurePolicy {
388    spec: SwitchButtonSpec,
389    density: f32,
390    has_secondary: bool,
391}
392
393impl MeasurePolicy for SwitchButtonMeasurePolicy {
394    fn measure(
395        &self,
396        scope: &dyn MeasureScope,
397        measurables: &[Box<dyn Measurable>],
398        constraints: Constraints,
399    ) -> MeasureResult {
400        let mut placements = Vec::new();
401        let size = self.measure_into(scope, measurables, constraints, &mut placements);
402        MeasureResult::new(size, placements)
403    }
404
405    fn measure_into(
406        &self,
407        _scope: &dyn MeasureScope,
408        measurables: &[Box<dyn Measurable>],
409        constraints: Constraints,
410        placements: &mut Vec<Placement>,
411    ) -> Size {
412        placements.clear();
413        let density = Density::new(self.density, 1.0);
414        let horizontal = density.dp(self.spec.padding_horizontal) * 2.0;
415        let vertical = density.dp(self.spec.padding_vertical) * 2.0;
416        let width = if constraints.max_width.is_finite() {
417            constraints.max_width
418        } else {
419            constraints.min_width
420        };
421
422        let switch_width = density.dp(self.spec.switch_width);
423        let spacing = density.dp(self.spec.control_spacing);
424        let label_width = (width - horizontal - switch_width - spacing).max(0.0);
425        let label_constraints = Constraints {
426            min_width: 0.0,
427            max_width: label_width,
428            min_height: 0.0,
429            max_height: f32::INFINITY,
430        };
431
432        let label_count = if self.has_secondary { 2 } else { 1 };
433        let mut labels = Vec::with_capacity(label_count);
434        for measurable in measurables.iter().take(label_count) {
435            labels.push(measurable.measure(label_constraints));
436        }
437        let switch = measurables.get(label_count).map(|measurable| {
438            measurable.measure(Constraints {
439                min_width: 0.0,
440                max_width: switch_width,
441                min_height: 0.0,
442                max_height: f32::INFINITY,
443            })
444        });
445
446        let label_spacing = density.dp(self.spec.label_spacing);
447        let column: f32 = labels
448            .iter()
449            .map(|placeable| density.ceil(placeable.height()))
450            .sum::<f32>()
451            + label_spacing * labels.len().saturating_sub(1) as f32;
452        let slot = density.dp(self.spec.switch_slot_height);
453        let content_demand = column.max(slot);
454        let height = density
455            .dp(self.spec.min_height)
456            .max(density.ceil(content_demand) + vertical)
457            .clamp(constraints.min_height, constraints.max_height);
458
459        let content = height - vertical;
460        let padding_top = density.dp(self.spec.padding_vertical);
461        let mut y = padding_top + density.centre(content, column);
462        let x = density.dp(self.spec.padding_horizontal);
463        for placeable in &labels {
464            placements.push(Placement::new(placeable.node_id(), x, y, 0));
465            y += density.ceil(placeable.height()) + label_spacing;
466        }
467
468        if let Some(switch) = switch {
469            let slot_top = padding_top + density.centre(content, slot);
470            let switch_x = width - density.dp(self.spec.padding_horizontal) - switch.width();
471            placements.push(Placement::new(switch.node_id(), switch_x, slot_top, 0));
472        }
473
474        Size::new(width, height)
475    }
476
477    fn min_intrinsic_width(&self, measurables: &[Box<dyn Measurable>], height: f32) -> f32 {
478        let density = Density::new(self.density, 1.0);
479        measurables
480            .iter()
481            .map(|m| m.min_intrinsic_width(height))
482            .fold(0.0, f32::max)
483            + density.dp(self.spec.padding_horizontal) * 2.0
484            + density.dp(self.spec.switch_width)
485            + density.dp(self.spec.control_spacing)
486    }
487
488    fn max_intrinsic_width(&self, measurables: &[Box<dyn Measurable>], height: f32) -> f32 {
489        self.min_intrinsic_width(measurables, height)
490    }
491
492    fn min_intrinsic_height(&self, measurables: &[Box<dyn Measurable>], width: f32) -> f32 {
493        let density = Density::new(self.density, 1.0);
494        let label_count = if self.has_secondary { 2 } else { 1 };
495        let column: f32 = measurables
496            .iter()
497            .take(label_count)
498            .map(|m| m.min_intrinsic_height(width))
499            .sum::<f32>();
500        density.dp(self.spec.min_height).max(
501            density.ceil(column.max(density.dp(self.spec.switch_slot_height)))
502                + density.dp(self.spec.padding_vertical) * 2.0,
503        )
504    }
505
506    fn max_intrinsic_height(&self, measurables: &[Box<dyn Measurable>], width: f32) -> f32 {
507        self.min_intrinsic_height(measurables, width)
508    }
509}
510
511#[cfg(test)]
512mod tests {
513    use cranpose_ui_graphics::{DrawScopeDefault, Size as GraphicsSize};
514
515    use super::*;
516
517    fn colors() -> WearColors {
518        WearColors {
519            primary: Color::from_rgb_u8(0xB9, 0xF2, 0xFF),
520            primary_container: Color::from_rgb_u8(0x0F, 0x36, 0x4E),
521            surface_container: Color::from_rgb_u8(0x0A, 0x16, 0x22),
522            outline: Color::from_rgb_u8(0x1D, 0x4D, 0x69),
523            ..WearColors::default()
524        }
525    }
526
527    #[test]
528    fn the_defaults_are_the_ones_the_tokens_declare() {
529        let spec = SwitchButtonSpec::default();
530        assert_eq!(spec.min_height, 52.0);
531        assert_eq!(spec.corner_radius, 26.0);
532        assert_eq!(spec.padding_horizontal, 14.0);
533        assert_eq!(spec.padding_vertical, 8.0);
534        assert_eq!(spec.switch_width, 32.0);
535        assert_eq!(spec.switch_slot_height, 24.0);
536        assert_eq!(spec.switch_height, 22.0);
537        assert_eq!(spec.track_border_width, 2.0);
538        assert_eq!(spec.thumb_radius_unchecked, 6.0);
539        assert_eq!(spec.thumb_radius_checked, 9.0);
540        assert_eq!(spec.control_spacing, 6.0);
541    }
542
543    #[test]
544    fn the_thumb_travels_between_the_two_measured_positions() {
545        let spec = SwitchButtonSpec::default();
546        let (unchecked, radius_off) = switch_thumb(spec, 0.0);
547        assert_eq!(unchecked, 11.0, "22px at density 2");
548        assert_eq!(radius_off, 6.0);
549        let (checked, radius_on) = switch_thumb(spec, 1.0);
550        assert_eq!(checked, 21.0, "42px at density 2");
551        assert_eq!(radius_on, 9.0);
552    }
553
554    #[test]
555    fn a_checked_switch_draws_no_border_and_an_unchecked_one_does() {
556        let checked = SwitchColors::of(colors(), true);
557        assert_eq!(
558            checked.track_border.3, 0.0,
559            "track and border are both primary, so the border is suppressed"
560        );
561        assert_eq!(checked.track, colors().primary);
562        assert_eq!(checked.thumb, colors().primary_container);
563
564        let unchecked = SwitchColors::of(colors(), false);
565        assert!(unchecked.track_border.3 > 0.0);
566        assert_eq!(unchecked.track_border, colors().outline);
567    }
568
569    #[test]
570    fn a_checked_thumb_is_the_container_colour_and_still_gets_drawn() {
571        let scheme = colors();
572        let checked = SwitchColors::of(scheme, true);
573        assert_eq!(
574            checked.thumb, checked.container,
575            "invisible against the row, and still occluding the track"
576        );
577        let mut scope = DrawScopeDefault::new(GraphicsSize::new(32.0, 22.0));
578        draw_switch(
579            &mut scope,
580            SwitchButtonSpec::default().colors(scheme).progress(1.0),
581            checked,
582        );
583        let primitives = scope.into_primitives();
584        assert_eq!(primitives.len(), 8);
585    }
586
587    #[test]
588    fn an_unchecked_switch_draws_its_ring_and_no_tick() {
589        let mut scope = DrawScopeDefault::new(GraphicsSize::new(32.0, 22.0));
590        draw_switch(
591            &mut scope,
592            SwitchButtonSpec::default().colors(colors()).progress(0.0),
593            SwitchColors::of(colors(), false),
594        );
595        assert_eq!(scope.into_primitives().len(), 3);
596    }
597
598    #[test]
599    fn the_tick_eases_out_rather_than_growing_linearly() {
600        assert_eq!(switch_tick_scale(0.0), 0.0);
601        assert_eq!(switch_tick_scale(1.0), 1.0);
602        assert!(switch_tick_scale(0.5) > 0.5);
603        assert!((switch_tick_scale(0.5) - 0.875).abs() < 1e-6);
604    }
605
606    #[test]
607    fn the_springs_are_the_ones_the_standard_motion_scheme_declares() {
608        assert_eq!(SWITCH_THUMB_STIFFNESS, 1400.0);
609        assert_eq!(SWITCH_COLOR_STIFFNESS, 260.0);
610        assert_eq!(SWITCH_DAMPING_RATIO, 1.0);
611    }
612}