Skip to main content

repose_material/material3/
selection.rs

1#![allow(non_snake_case)]
2
3use std::rc::Rc;
4use std::sync::atomic::{AtomicU64, Ordering};
5
6use crate::{Icon, Symbol};
7use repose_core::*;
8use repose_ui::{
9    Box, TextStyle, ViewExt,
10    anim::{animate_color, animate_f32},
11};
12
13use super::util::{apply_enabled_click, apply_m3_clickable_ex};
14
15use super::*;
16
17/// Configuration for [`Checkbox`].
18#[derive(Clone, Debug)]
19pub struct CheckboxConfig {
20    pub modifier: Modifier,
21    /// When false, the checkbox renders disabled colors and does not respond to clicks.
22    pub enabled: bool,
23    pub checked_color: Color,
24    pub unchecked_color: Color,
25    pub checkmark_color: Color,
26    /// Border color when checked. Default: same as `checked_color`.
27    pub checked_border_color: Color,
28    /// Border color when unchecked. Default: same as `unchecked_color`.
29    pub unchecked_border_color: Color,
30    pub disabled_checked_box_color: Color,
31    pub disabled_unchecked_box_color: Color,
32    pub disabled_indeterminate_box_color: Color,
33    pub disabled_checkmark_color: Color,
34    pub disabled_checked_border_color: Color,
35    pub disabled_unchecked_border_color: Color,
36    pub disabled_indeterminate_border_color: Color,
37    pub state_colors: StateColors,
38    pub interaction_source: Option<MutableInteractionSource>,
39}
40
41impl Default for CheckboxConfig {
42    fn default() -> Self {
43        Self {
44            modifier: Modifier::new(),
45            enabled: true,
46            checked_color: CheckboxDefaults::checked_color(),
47            unchecked_color: CheckboxDefaults::unchecked_color(),
48            checkmark_color: CheckboxDefaults::checkmark_color(),
49            checked_border_color: CheckboxDefaults::checked_color(),
50            unchecked_border_color: CheckboxDefaults::unchecked_color(),
51            disabled_checked_box_color: CheckboxDefaults::disabled_checked_box_color(),
52            disabled_unchecked_box_color: Color::TRANSPARENT,
53            disabled_indeterminate_box_color: CheckboxDefaults::disabled_checked_box_color(),
54            disabled_checkmark_color: CheckboxDefaults::disabled_checkmark_color(),
55            disabled_checked_border_color: CheckboxDefaults::disabled_checked_box_color(),
56            disabled_unchecked_border_color: CheckboxDefaults::disabled_unchecked_border_color(),
57            disabled_indeterminate_border_color: CheckboxDefaults::disabled_checked_box_color(),
58            state_colors: CheckboxDefaults::state_colors_default(),
59            interaction_source: None,
60        }
61    }
62}
63
64/// M3 Checkbox.
65/// Renders a 40dp touch-target with an 18dp check box inside.
66/// Fill, border, and check mark animate with 100ms FastOutSlowIn.
67static CHECKBOX_COUNTER: AtomicU64 = AtomicU64::new(0);
68pub fn Checkbox(checked: bool, on_change: impl Fn(bool) + 'static, config: CheckboxConfig) -> View {
69    let th = theme();
70    let sz = CheckboxDefaults::BOX_SIZE;
71
72    let id = remember(|| CHECKBOX_COUNTER.fetch_add(1, Ordering::Relaxed));
73    let spec = th.motion.color_fast;
74
75    let is_enabled = config.enabled;
76
77    let fill = animate_color(
78        format!("cb_fill_{}", id),
79        if !is_enabled {
80            if checked {
81                config.disabled_checked_box_color
82            } else {
83                config.disabled_unchecked_box_color
84            }
85        } else if checked {
86            config.checked_color
87        } else {
88            Color::TRANSPARENT
89        },
90        spec,
91    );
92    let bd_w = animate_f32(
93        format!("cb_bw_{}", id),
94        if !is_enabled && checked {
95            0.0
96        } else if !is_enabled {
97            CheckboxDefaults::STROKE_WIDTH
98        } else if checked {
99            0.0
100        } else {
101            CheckboxDefaults::STROKE_WIDTH
102        },
103        spec,
104    );
105    let bd = animate_color(
106        format!("cb_bd_{}", id),
107        if !is_enabled {
108            if checked {
109                config.disabled_checked_border_color
110            } else {
111                config.disabled_unchecked_border_color
112            }
113        } else if checked {
114            Color::TRANSPARENT
115        } else {
116            config.unchecked_border_color
117        },
118        spec,
119    );
120    let check_alpha = animate_f32(
121        format!("cb_ca_{}", id),
122        if checked { 1.0 } else { 0.0 },
123        spec,
124    );
125    let check_col = if !is_enabled {
126        config.disabled_checkmark_color
127    } else {
128        config.checkmark_color
129    };
130
131    let cb = move || {
132        if config.enabled {
133            on_change(!checked)
134        }
135    };
136
137    let cb_source: Rc<MutableInteractionSource> = config
138        .interaction_source
139        .clone()
140        .map(Rc::new)
141        .unwrap_or_else(|| remember(MutableInteractionSource::new));
142
143    // Compose: unbounded state-layer (StateLayerSize/2 = 20dp), color = indicator
144    // (checked container / on_surface when unchecked). Matches Checkbox.kt + tokens.
145    let ripple_col = if checked {
146        config.checked_color
147    } else {
148        th.on_surface
149    };
150
151    let mut m = Modifier::new()
152        .width(CheckboxDefaults::TOUCH_TARGET_SIZE)
153        .height(CheckboxDefaults::TOUCH_TARGET_SIZE)
154        .padding(0.0)
155        .background(Color::TRANSPARENT)
156        .state_colors(config.state_colors) // keep for dragged/disabled overlays if any
157        .align_items(AlignItems::CENTER)
158        .justify_content(JustifyContent::CENTER);
159
160    m = apply_m3_clickable_ex(
161        m,
162        &cb_source,
163        ripple_col,
164        is_enabled,
165        cb,
166        false, // unbounded
167        Some(20.0),
168    );
169    m = m.semantics(Semantics {
170        role: Role::Checkbox,
171        label: None,
172        focused: false,
173        enabled: is_enabled,
174        selectable_group: false,
175    });
176    Box(m.then(config.modifier)).child(
177        Box(Modifier::new()
178            .size(sz, sz)
179            .background(fill)
180            .border(bd_w, bd, CheckboxDefaults::CORNER_RADIUS)
181            .clip_rounded(CheckboxDefaults::CORNER_RADIUS)
182            .align_items(AlignItems::CENTER)
183            .justify_content(JustifyContent::CENTER))
184        .child(if check_alpha > 0.01 {
185            Box(Modifier::new().alpha(check_alpha)).child(
186                Icon(Symbol::new("done", '\u{E876}'))
187                    .color(check_col)
188                    .size(CheckboxDefaults::CHECK_ICON_SIZE),
189            )
190        } else {
191            Box(Modifier::new())
192        }),
193    )
194}
195
196/// Three-state value for [`TriStateCheckbox`].
197#[derive(Clone, Copy, Debug, PartialEq)]
198pub enum TriState {
199    Checked,
200    Unchecked,
201    Indeterminate,
202}
203
204/// M3 Tri-State Checkbox - cycles through Checked -> Indeterminate -> Unchecked.
205/// Indeterminate shows a dash instead of a checkmark.
206pub fn TriStateCheckbox(
207    state: TriState,
208    on_change: impl Fn(TriState) + 'static,
209    config: CheckboxConfig,
210) -> View {
211    let th = theme();
212    let sz = CheckboxDefaults::BOX_SIZE;
213
214    let id = remember(|| CHECKBOX_COUNTER.fetch_add(1, Ordering::Relaxed));
215    let spec = th.motion.color_fast;
216
217    let is_checked = state == TriState::Checked;
218    let is_indeterminate = state == TriState::Indeterminate;
219    let has_fill = is_checked || is_indeterminate;
220    let is_enabled = config.enabled;
221
222    let fill = animate_color(
223        format!("tc_fill_{}", id),
224        if !is_enabled {
225            if has_fill {
226                config.disabled_indeterminate_box_color
227            } else {
228                config.disabled_unchecked_box_color
229            }
230        } else if has_fill {
231            config.checked_color
232        } else {
233            Color::TRANSPARENT
234        },
235        spec,
236    );
237    let bd_w = animate_f32(
238        format!("tc_bw_{}", id),
239        if !is_enabled {
240            if has_fill {
241                0.0
242            } else {
243                CheckboxDefaults::STROKE_WIDTH
244            }
245        } else if has_fill {
246            0.0
247        } else {
248            CheckboxDefaults::STROKE_WIDTH
249        },
250        spec,
251    );
252    let bd = animate_color(
253        format!("tc_bd_{}", id),
254        if !is_enabled {
255            if has_fill {
256                config.disabled_indeterminate_border_color
257            } else {
258                config.disabled_unchecked_border_color
259            }
260        } else if has_fill {
261            Color::TRANSPARENT
262        } else {
263            config.unchecked_border_color
264        },
265        spec,
266    );
267    let symbol_alpha = animate_f32(
268        format!("tc_sa_{}", id),
269        if has_fill { 1.0 } else { 0.0 },
270        spec,
271    );
272    let symbol_col = if !is_enabled {
273        config.disabled_checkmark_color
274    } else {
275        config.checkmark_color
276    };
277
278    let tc_source: Rc<MutableInteractionSource> = config
279        .interaction_source
280        .clone()
281        .map(Rc::new)
282        .unwrap_or_else(|| remember(MutableInteractionSource::new));
283
284    // Compose parity: state-layer color = indicator (checked container / on_surface).
285    let ripple_col = if has_fill {
286        config.checked_color
287    } else {
288        th.on_surface
289    };
290
291    let mut m = Modifier::new()
292        .width(CheckboxDefaults::TOUCH_TARGET_SIZE)
293        .height(CheckboxDefaults::TOUCH_TARGET_SIZE)
294        .padding(0.0)
295        .background(Color::TRANSPARENT)
296        .state_colors(config.state_colors)
297        .align_items(AlignItems::CENTER)
298        .justify_content(JustifyContent::CENTER);
299
300    m = apply_m3_clickable_ex(
301        m,
302        &tc_source,
303        ripple_col,
304        is_enabled,
305        move || {
306            on_change(match state {
307                TriState::Checked => TriState::Unchecked,
308                TriState::Indeterminate => TriState::Checked,
309                TriState::Unchecked => TriState::Checked,
310            })
311        },
312        false, // unbounded
313        Some(20.0),
314    );
315    m = m.semantics(Semantics {
316        role: Role::Checkbox,
317        label: None,
318        focused: false,
319        enabled: is_enabled,
320        selectable_group: false,
321    });
322    Box(m.then(config.modifier)).child(
323        Box(Modifier::new()
324            .size(sz, sz)
325            .background(fill)
326            .border(bd_w, bd, CheckboxDefaults::CORNER_RADIUS)
327            .clip_rounded(CheckboxDefaults::CORNER_RADIUS)
328            .align_items(AlignItems::CENTER)
329            .justify_content(JustifyContent::CENTER))
330        .child(if symbol_alpha > 0.01 {
331            Box(Modifier::new().alpha(symbol_alpha)).child(if is_indeterminate {
332                // Dash for indeterminate
333                Box(Modifier::new()
334                    .width(10.0)
335                    .height(2.0)
336                    .background(symbol_col)
337                    .clip_rounded(1.0))
338            } else {
339                Icon(Symbol::new("done", '\u{E876}'))
340                    .color(symbol_col)
341                    .size(CheckboxDefaults::CHECK_ICON_SIZE)
342            })
343        } else {
344            Box(Modifier::new())
345        }),
346    )
347}
348
349/// Configuration for [`RadioButton`].
350#[derive(Clone, Debug)]
351pub struct RadioButtonConfig {
352    pub modifier: Modifier,
353    /// When false, renders disabled colors and does not respond to clicks.
354    pub enabled: bool,
355    pub selected_color: Color,
356    pub unselected_color: Color,
357    pub disabled_selected_color: Color,
358    pub disabled_unselected_color: Color,
359    pub state_colors: StateColors,
360    pub interaction_source: Option<MutableInteractionSource>,
361}
362
363impl Default for RadioButtonConfig {
364    fn default() -> Self {
365        Self {
366            modifier: Modifier::new(),
367            enabled: true,
368            selected_color: RadioButtonDefaults::selected_color(),
369            unselected_color: RadioButtonDefaults::unselected_color(),
370            disabled_selected_color: RadioButtonDefaults::disabled_selected_color(),
371            disabled_unselected_color: RadioButtonDefaults::disabled_unselected_color(),
372            state_colors: RadioButtonDefaults::state_colors_default(),
373            interaction_source: None,
374        }
375    }
376}
377
378/// M3 RadioButton.
379/// Renders a 40dp touch-target with a 20dp outer circle + inner dot.
380/// Ring color animates with 100ms FastOutSlowIn; dot size animates with spring.
381static RADIO_COUNTER: AtomicU64 = AtomicU64::new(0);
382pub fn RadioButton(
383    selected: bool,
384    on_select: impl Fn() + 'static,
385    config: RadioButtonConfig,
386) -> View {
387    let th = theme();
388    let d = RadioButtonDefaults::OUTER_RADIUS * 2.0;
389
390    let id = remember(|| RADIO_COUNTER.fetch_add(1, Ordering::Relaxed));
391    let color_spec = th.motion.color_fast;
392    let spring = th.motion.spring;
393
394    let ring_col = animate_color(
395        format!("rb_ring_{}", id),
396        if !config.enabled {
397            if selected {
398                config.disabled_selected_color
399            } else {
400                config.disabled_unselected_color
401            }
402        } else if selected {
403            config.selected_color
404        } else {
405            config.unselected_color
406        },
407        color_spec,
408    );
409    let dot_size = animate_f32(
410        format!("rb_dot_{}", id),
411        if selected {
412            RadioButtonDefaults::DOT_RADIUS * 2.0
413        } else {
414            0.0
415        },
416        spring,
417    );
418    let dot_col = if !config.enabled {
419        config.disabled_selected_color
420    } else {
421        config.selected_color
422    };
423
424    let cb = move || {
425        if config.enabled {
426            on_select()
427        }
428    };
429
430    let rb_source: Rc<MutableInteractionSource> = config
431        .interaction_source
432        .clone()
433        .map(Rc::new)
434        .unwrap_or_else(|| remember(MutableInteractionSource::new));
435
436    // Compose parity: state-layer color = indicator (selected / on_surface).
437    let ripple_col = if selected {
438        config.selected_color
439    } else {
440        th.on_surface
441    };
442
443    let mut m = Modifier::new()
444        .width(RadioButtonDefaults::TOUCH_TARGET_SIZE)
445        .height(RadioButtonDefaults::TOUCH_TARGET_SIZE)
446        .padding(0.0)
447        .background(Color::TRANSPARENT)
448        .state_colors(config.state_colors)
449        .align_items(AlignItems::CENTER)
450        .justify_content(JustifyContent::CENTER);
451
452    m = apply_m3_clickable_ex(
453        m,
454        &rb_source,
455        ripple_col,
456        config.enabled,
457        cb,
458        false,
459        Some(20.0),
460    );
461    m = m.semantics(Semantics {
462        role: Role::RadioButton,
463        label: None,
464        focused: false,
465        enabled: config.enabled,
466        selectable_group: false,
467    });
468    Box(m.then(config.modifier)).child(
469        Box(Modifier::new()
470            .size(d, d)
471            .border(RadioButtonDefaults::STROKE_WIDTH, ring_col, d * 0.5)
472            .clip_rounded(d * 0.5)
473            .align_items(AlignItems::CENTER)
474            .justify_content(JustifyContent::CENTER))
475        .child(if dot_size > 0.5 {
476            Box(Modifier::new()
477                .size(dot_size, dot_size)
478                .background(dot_col)
479                .clip_rounded(dot_size * 0.5))
480        } else {
481            Box(Modifier::new())
482        }),
483    )
484}
485
486/// Configuration for [`Switch`].
487#[derive(Clone, Debug)]
488pub struct SwitchConfig {
489    pub modifier: Modifier,
490    /// When false, renders disabled colors and does not respond to clicks.
491    pub enabled: bool,
492    pub checked_track_color: Color,
493    pub unchecked_track_color: Color,
494    pub checked_thumb_color: Color,
495    pub unchecked_thumb_color: Color,
496    /// Icon color for the thumb content when checked. Default: `on_primary`.
497    pub checked_icon_color: Color,
498    /// Icon color for the thumb content when unchecked. Default: `outline`.
499    pub unchecked_icon_color: Color,
500    /// Border color when checked. Default: transparent.
501    pub checked_border_color: Color,
502    /// Border color when unchecked.
503    pub unchecked_border_color: Color,
504    pub disabled_checked_thumb_color: Color,
505    pub disabled_checked_track_color: Color,
506    pub disabled_checked_border_color: Color,
507    pub disabled_checked_icon_color: Color,
508    pub disabled_unchecked_thumb_color: Color,
509    pub disabled_unchecked_track_color: Color,
510    pub disabled_unchecked_border_color: Color,
511    pub disabled_unchecked_icon_color: Color,
512    pub state_colors: StateColors,
513    pub thumb_content: Option<View>,
514    pub interaction_source: Option<MutableInteractionSource>,
515}
516
517impl Default for SwitchConfig {
518    fn default() -> Self {
519        Self {
520            modifier: Modifier::new(),
521            enabled: true,
522            checked_track_color: SwitchDefaults::checked_track_color(),
523            unchecked_track_color: SwitchDefaults::unchecked_track_color(),
524            checked_thumb_color: SwitchDefaults::checked_thumb_color(),
525            unchecked_thumb_color: SwitchDefaults::unchecked_thumb_color(),
526            checked_icon_color: SwitchDefaults::checked_icon_color(),
527            unchecked_icon_color: SwitchDefaults::unchecked_icon_color(),
528            checked_border_color: Color::TRANSPARENT,
529            unchecked_border_color: SwitchDefaults::unchecked_border_color(),
530            disabled_checked_thumb_color: SwitchDefaults::disabled_checked_thumb_color(),
531            disabled_checked_track_color: SwitchDefaults::disabled_checked_track_color(),
532            disabled_checked_border_color: Color::TRANSPARENT,
533            disabled_checked_icon_color: SwitchDefaults::disabled_checked_icon_color(),
534            disabled_unchecked_thumb_color: SwitchDefaults::disabled_unchecked_thumb_color(),
535            disabled_unchecked_track_color: SwitchDefaults::disabled_unchecked_track_color(),
536            disabled_unchecked_border_color: SwitchDefaults::disabled_unchecked_border_color(),
537            disabled_unchecked_icon_color: SwitchDefaults::disabled_unchecked_icon_color(),
538            state_colors: SwitchDefaults::state_colors_default(),
539            thumb_content: None,
540            interaction_source: None,
541        }
542    }
543}
544
545/// M3 Switch.
546/// Renders a pill track with an animated thumb knob.
547/// Thumb position, size, and colors animate with spring/tween physics.
548static SWITCH_COUNTER: AtomicU64 = AtomicU64::new(0);
549pub fn Switch(checked: bool, on_change: impl Fn(bool) + 'static, config: SwitchConfig) -> View {
550    let th = theme();
551    let track_w = SwitchDefaults::TRACK_WIDTH;
552    let track_h = SwitchDefaults::TRACK_HEIGHT;
553
554    let id = remember(|| SWITCH_COUNTER.fetch_add(1, Ordering::Relaxed));
555
556    // Thumb: spring-animated position and size
557    let thumb_target_pos = if checked {
558        track_w - SwitchDefaults::THUMB_CHECKED_SIZE - 4.0
559    } else {
560        8.0
561    };
562    let thumb_target_d = if checked {
563        SwitchDefaults::THUMB_CHECKED_SIZE
564    } else {
565        SwitchDefaults::THUMB_UNCHECKED_SIZE
566    };
567    let spring = th.motion.spring;
568
569    let thumb_left = animate_f32(format!("sw_pos_{}", id), thumb_target_pos, spring);
570    let thumb_d = animate_f32(format!("sw_d_{}", id), thumb_target_d, spring);
571    let thumb_top = (track_h - thumb_d) * 0.5;
572
573    let color_spec = th.motion.color_fast;
574    let is_enabled = config.enabled;
575
576    let track_bg = animate_color(
577        format!("sw_tbg_{}", id),
578        if !is_enabled {
579            if checked {
580                config.disabled_checked_track_color
581            } else {
582                config.disabled_unchecked_track_color
583            }
584        } else if checked {
585            config.checked_track_color
586        } else {
587            config.unchecked_track_color
588        },
589        color_spec,
590    );
591    let thumb_bg = animate_color(
592        format!("sw_tmbg_{}", id),
593        if !is_enabled {
594            if checked {
595                config.disabled_checked_thumb_color
596            } else {
597                config.disabled_unchecked_thumb_color
598            }
599        } else if checked {
600            config.checked_thumb_color
601        } else {
602            config.unchecked_thumb_color
603        },
604        color_spec,
605    );
606    let track_border = animate_f32(
607        format!("sw_tb_{}", id),
608        if !is_enabled {
609            if checked { 0.0 } else { 2.0 }
610        } else if checked {
611            0.0
612        } else {
613            2.0
614        },
615        color_spec,
616    );
617    let border_color = animate_color(
618        format!("sw_bc_{}", id),
619        if !is_enabled {
620            if checked {
621                config.disabled_checked_border_color
622            } else {
623                config.disabled_unchecked_border_color
624            }
625        } else if checked {
626            config.checked_border_color
627        } else {
628            config.unchecked_border_color
629        },
630        color_spec,
631    );
632
633    let sw_source: Rc<MutableInteractionSource> = config
634        .interaction_source
635        .clone()
636        .map(Rc::new)
637        .unwrap_or_else(|| remember(MutableInteractionSource::new));
638
639    let mut track = Modifier::new()
640        .size(track_w, track_h)
641        .padding(0.0)
642        .clip_rounded(track_h * 0.5)
643        .background(track_bg)
644        .border(track_border, border_color, track_h * 0.5)
645        .interaction_source(&sw_source);
646
647    track = apply_enabled_click(track, is_enabled, {
648        let cb = on_change;
649        move || cb(!checked)
650    });
651
652    Box(track.then(config.modifier)).child((
653        Box(Modifier::new()
654            .size(thumb_d, thumb_d)
655            .background(thumb_bg)
656            .clip_rounded(thumb_d * 0.5)
657            .hit_passthrough()
658            .align_items(AlignItems::CENTER)
659            .justify_content(JustifyContent::CENTER)
660            .absolute()
661            .offset(Some(thumb_left), Some(thumb_top), None, None))
662        .child(
663            config
664                .thumb_content
665                .map(|v| {
666                    let icon_col = if !is_enabled {
667                        if checked {
668                            config.disabled_checked_icon_color
669                        } else {
670                            config.disabled_unchecked_icon_color
671                        }
672                    } else if checked {
673                        config.checked_icon_color
674                    } else {
675                        config.unchecked_icon_color
676                    };
677                    with_content_color(icon_col, move || v)
678                })
679                .unwrap_or(Box(Modifier::new())),
680        ),
681        Box(Modifier::new()
682            .size(40.0, 40.0)
683            .hit_passthrough()
684            .interaction_source(&sw_source)
685            .indication(crate::ripple::ripple(crate::ripple::RippleConfig {
686                color: Some(th.on_surface),
687                bounded: false,
688                radius: Some(20.0),
689                ..Default::default()
690            }))
691            .absolute()
692            .offset(
693                Some(thumb_left + thumb_d * 0.5 - 20.0),
694                Some(track_h * 0.5 - 20.0),
695                None,
696                None,
697            )),
698    ))
699}