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
160m = 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        enabled: is_enabled,
172        checked: Some(checked),
173        ..Default::default()
174    });
175    Box(m.then(config.modifier)).child(
176        Box(Modifier::new()
177            .size(sz, sz)
178            .background(fill)
179            .border(bd_w, bd, CheckboxDefaults::CORNER_RADIUS)
180            .clip_rounded(CheckboxDefaults::CORNER_RADIUS)
181            .align_items(AlignItems::CENTER)
182            .justify_content(JustifyContent::CENTER))
183        .child(if check_alpha > 0.01 {
184            Box(Modifier::new().alpha(check_alpha)).child(
185                Icon(Symbol::new("done", '\u{E876}'))
186                    .color(check_col)
187                    .size(CheckboxDefaults::CHECK_ICON_SIZE),
188            )
189        } else {
190            Box(Modifier::new())
191        }),
192    )
193}
194
195/// Three-state value for [`TriStateCheckbox`].
196#[derive(Clone, Copy, Debug, PartialEq)]
197pub enum TriState {
198    Checked,
199    Unchecked,
200    Indeterminate,
201}
202
203/// M3 Tri-State Checkbox - cycles through Checked -> Indeterminate -> Unchecked.
204/// Indeterminate shows a dash instead of a checkmark.
205pub fn TriStateCheckbox(
206    state: TriState,
207    on_change: impl Fn(TriState) + 'static,
208    config: CheckboxConfig,
209) -> View {
210    let th = theme();
211    let sz = CheckboxDefaults::BOX_SIZE;
212
213    let id = remember(|| CHECKBOX_COUNTER.fetch_add(1, Ordering::Relaxed));
214    let spec = th.motion.color_fast;
215
216    let is_checked = state == TriState::Checked;
217    let is_indeterminate = state == TriState::Indeterminate;
218    let has_fill = is_checked || is_indeterminate;
219    let is_enabled = config.enabled;
220
221    let fill = animate_color(
222        format!("tc_fill_{}", id),
223        if !is_enabled {
224            if has_fill {
225                config.disabled_indeterminate_box_color
226            } else {
227                config.disabled_unchecked_box_color
228            }
229        } else if has_fill {
230            config.checked_color
231        } else {
232            Color::TRANSPARENT
233        },
234        spec,
235    );
236    let bd_w = animate_f32(
237        format!("tc_bw_{}", id),
238        if !is_enabled {
239            if has_fill {
240                0.0
241            } else {
242                CheckboxDefaults::STROKE_WIDTH
243            }
244        } else if has_fill {
245            0.0
246        } else {
247            CheckboxDefaults::STROKE_WIDTH
248        },
249        spec,
250    );
251    let bd = animate_color(
252        format!("tc_bd_{}", id),
253        if !is_enabled {
254            if has_fill {
255                config.disabled_indeterminate_border_color
256            } else {
257                config.disabled_unchecked_border_color
258            }
259        } else if has_fill {
260            Color::TRANSPARENT
261        } else {
262            config.unchecked_border_color
263        },
264        spec,
265    );
266    let symbol_alpha = animate_f32(
267        format!("tc_sa_{}", id),
268        if has_fill { 1.0 } else { 0.0 },
269        spec,
270    );
271    let symbol_col = if !is_enabled {
272        config.disabled_checkmark_color
273    } else {
274        config.checkmark_color
275    };
276
277    let tc_source: Rc<MutableInteractionSource> = config
278        .interaction_source
279        .clone()
280        .map(Rc::new)
281        .unwrap_or_else(|| remember(MutableInteractionSource::new));
282
283    // Compose parity: state-layer color = indicator (checked container / on_surface).
284    let ripple_col = if has_fill {
285        config.checked_color
286    } else {
287        th.on_surface
288    };
289
290    let mut m = Modifier::new()
291        .width(CheckboxDefaults::TOUCH_TARGET_SIZE)
292        .height(CheckboxDefaults::TOUCH_TARGET_SIZE)
293        .padding(0.0)
294        .background(Color::TRANSPARENT)
295        .state_colors(config.state_colors)
296        .align_items(AlignItems::CENTER)
297        .justify_content(JustifyContent::CENTER);
298
299    m = apply_m3_clickable_ex(
300        m,
301        &tc_source,
302        ripple_col,
303        is_enabled,
304        move || {
305            on_change(match state {
306                TriState::Checked => TriState::Unchecked,
307                TriState::Indeterminate => TriState::Checked,
308                TriState::Unchecked => TriState::Checked,
309            })
310        },
311        false, // unbounded
312        Some(20.0),
313    );
314    m = m.semantics(Semantics {
315        role: Role::Checkbox,
316        enabled: is_enabled,
317        checked: if is_indeterminate {
318            None
319        } else {
320            Some(is_checked)
321        },
322        ..Default::default()
323    });
324    Box(m.then(config.modifier)).child(
325        Box(Modifier::new()
326            .size(sz, sz)
327            .background(fill)
328            .border(bd_w, bd, CheckboxDefaults::CORNER_RADIUS)
329            .clip_rounded(CheckboxDefaults::CORNER_RADIUS)
330            .align_items(AlignItems::CENTER)
331            .justify_content(JustifyContent::CENTER))
332        .child(if symbol_alpha > 0.01 {
333            Box(Modifier::new().alpha(symbol_alpha)).child(if is_indeterminate {
334                // Dash for indeterminate
335                Box(Modifier::new()
336                    .width(10.0)
337                    .height(2.0)
338                    .background(symbol_col)
339                    .clip_rounded(1.0))
340            } else {
341                Icon(Symbol::new("done", '\u{E876}'))
342                    .color(symbol_col)
343                    .size(CheckboxDefaults::CHECK_ICON_SIZE)
344            })
345        } else {
346            Box(Modifier::new())
347        }),
348    )
349}
350
351/// Configuration for [`RadioButton`].
352#[derive(Clone, Debug)]
353pub struct RadioButtonConfig {
354    pub modifier: Modifier,
355    /// When false, renders disabled colors and does not respond to clicks.
356    pub enabled: bool,
357    pub selected_color: Color,
358    pub unselected_color: Color,
359    pub disabled_selected_color: Color,
360    pub disabled_unselected_color: Color,
361    pub state_colors: StateColors,
362    pub interaction_source: Option<MutableInteractionSource>,
363}
364
365impl Default for RadioButtonConfig {
366    fn default() -> Self {
367        Self {
368            modifier: Modifier::new(),
369            enabled: true,
370            selected_color: RadioButtonDefaults::selected_color(),
371            unselected_color: RadioButtonDefaults::unselected_color(),
372            disabled_selected_color: RadioButtonDefaults::disabled_selected_color(),
373            disabled_unselected_color: RadioButtonDefaults::disabled_unselected_color(),
374            state_colors: RadioButtonDefaults::state_colors_default(),
375            interaction_source: None,
376        }
377    }
378}
379
380/// M3 RadioButton.
381/// Renders a 40dp touch-target with a 20dp outer circle + inner dot.
382/// Ring color animates with 100ms FastOutSlowIn; dot size animates with spring.
383static RADIO_COUNTER: AtomicU64 = AtomicU64::new(0);
384pub fn RadioButton(
385    selected: bool,
386    on_select: impl Fn() + 'static,
387    config: RadioButtonConfig,
388) -> View {
389    let th = theme();
390    let d = RadioButtonDefaults::OUTER_RADIUS * 2.0;
391
392    let id = remember(|| RADIO_COUNTER.fetch_add(1, Ordering::Relaxed));
393    let color_spec = th.motion.color_fast;
394    let spring = th.motion.spring;
395
396    let ring_col = animate_color(
397        format!("rb_ring_{}", id),
398        if !config.enabled {
399            if selected {
400                config.disabled_selected_color
401            } else {
402                config.disabled_unselected_color
403            }
404        } else if selected {
405            config.selected_color
406        } else {
407            config.unselected_color
408        },
409        color_spec,
410    );
411    let dot_size = animate_f32(
412        format!("rb_dot_{}", id),
413        if selected {
414            RadioButtonDefaults::DOT_RADIUS * 2.0
415        } else {
416            0.0
417        },
418        spring,
419    );
420    let dot_col = if !config.enabled {
421        config.disabled_selected_color
422    } else {
423        config.selected_color
424    };
425
426    let cb = move || {
427        if config.enabled {
428            on_select()
429        }
430    };
431
432    let rb_source: Rc<MutableInteractionSource> = config
433        .interaction_source
434        .clone()
435        .map(Rc::new)
436        .unwrap_or_else(|| remember(MutableInteractionSource::new));
437
438    // Compose parity: state-layer color = indicator (selected / on_surface).
439    let ripple_col = if selected {
440        config.selected_color
441    } else {
442        th.on_surface
443    };
444
445    let mut m = Modifier::new()
446        .width(RadioButtonDefaults::TOUCH_TARGET_SIZE)
447        .height(RadioButtonDefaults::TOUCH_TARGET_SIZE)
448        .padding(0.0)
449        .background(Color::TRANSPARENT)
450        .state_colors(config.state_colors)
451        .align_items(AlignItems::CENTER)
452        .justify_content(JustifyContent::CENTER);
453
454    m = apply_m3_clickable_ex(
455        m,
456        &rb_source,
457        ripple_col,
458        config.enabled,
459        cb,
460        false,
461        Some(20.0),
462    );
463    m = m.semantics(Semantics {
464        role: Role::RadioButton,
465        enabled: config.enabled,
466        selected: Some(selected),
467        ..Default::default()
468    });
469    Box(m.then(config.modifier)).child(
470        Box(Modifier::new()
471            .size(d, d)
472            .border(RadioButtonDefaults::STROKE_WIDTH, ring_col, d * 0.5)
473            .clip_rounded(d * 0.5)
474            .align_items(AlignItems::CENTER)
475            .justify_content(JustifyContent::CENTER))
476        .child(if dot_size > 0.5 {
477            Box(Modifier::new()
478                .size(dot_size, dot_size)
479                .background(dot_col)
480                .clip_rounded(dot_size * 0.5))
481        } else {
482            Box(Modifier::new())
483        }),
484    )
485}
486
487/// Configuration for [`Switch`].
488#[derive(Clone, Debug)]
489pub struct SwitchConfig {
490    pub modifier: Modifier,
491    /// When false, renders disabled colors and does not respond to clicks.
492    pub enabled: bool,
493    pub checked_track_color: Color,
494    pub unchecked_track_color: Color,
495    pub checked_thumb_color: Color,
496    pub unchecked_thumb_color: Color,
497    /// Icon color for the thumb content when checked. Default: `on_primary`.
498    pub checked_icon_color: Color,
499    /// Icon color for the thumb content when unchecked. Default: `outline`.
500    pub unchecked_icon_color: Color,
501    /// Border color when checked. Default: transparent.
502    pub checked_border_color: Color,
503    /// Border color when unchecked.
504    pub unchecked_border_color: Color,
505    pub disabled_checked_thumb_color: Color,
506    pub disabled_checked_track_color: Color,
507    pub disabled_checked_border_color: Color,
508    pub disabled_checked_icon_color: Color,
509    pub disabled_unchecked_thumb_color: Color,
510    pub disabled_unchecked_track_color: Color,
511    pub disabled_unchecked_border_color: Color,
512    pub disabled_unchecked_icon_color: Color,
513    pub state_colors: StateColors,
514    pub thumb_content: Option<View>,
515    pub interaction_source: Option<MutableInteractionSource>,
516}
517
518impl Default for SwitchConfig {
519    fn default() -> Self {
520        Self {
521            modifier: Modifier::new(),
522            enabled: true,
523            checked_track_color: SwitchDefaults::checked_track_color(),
524            unchecked_track_color: SwitchDefaults::unchecked_track_color(),
525            checked_thumb_color: SwitchDefaults::checked_thumb_color(),
526            unchecked_thumb_color: SwitchDefaults::unchecked_thumb_color(),
527            checked_icon_color: SwitchDefaults::checked_icon_color(),
528            unchecked_icon_color: SwitchDefaults::unchecked_icon_color(),
529            checked_border_color: Color::TRANSPARENT,
530            unchecked_border_color: SwitchDefaults::unchecked_border_color(),
531            disabled_checked_thumb_color: SwitchDefaults::disabled_checked_thumb_color(),
532            disabled_checked_track_color: SwitchDefaults::disabled_checked_track_color(),
533            disabled_checked_border_color: Color::TRANSPARENT,
534            disabled_checked_icon_color: SwitchDefaults::disabled_checked_icon_color(),
535            disabled_unchecked_thumb_color: SwitchDefaults::disabled_unchecked_thumb_color(),
536            disabled_unchecked_track_color: SwitchDefaults::disabled_unchecked_track_color(),
537            disabled_unchecked_border_color: SwitchDefaults::disabled_unchecked_border_color(),
538            disabled_unchecked_icon_color: SwitchDefaults::disabled_unchecked_icon_color(),
539            state_colors: SwitchDefaults::state_colors_default(),
540            thumb_content: None,
541            interaction_source: None,
542        }
543    }
544}
545
546/// M3 Switch.
547/// Renders a pill track with an animated thumb knob.
548/// Thumb position, size, and colors animate with spring/tween physics.
549static SWITCH_COUNTER: AtomicU64 = AtomicU64::new(0);
550pub fn Switch(checked: bool, on_change: impl Fn(bool) + 'static, config: SwitchConfig) -> View {
551    let th = theme();
552    let track_w = SwitchDefaults::TRACK_WIDTH;
553    let track_h = SwitchDefaults::TRACK_HEIGHT;
554
555    let id = remember(|| SWITCH_COUNTER.fetch_add(1, Ordering::Relaxed));
556
557    // Thumb: spring-animated position and size
558    let thumb_target_pos = if checked {
559        track_w - SwitchDefaults::THUMB_CHECKED_SIZE - 4.0
560    } else {
561        8.0
562    };
563    let thumb_target_d = if checked {
564        SwitchDefaults::THUMB_CHECKED_SIZE
565    } else {
566        SwitchDefaults::THUMB_UNCHECKED_SIZE
567    };
568    let spring = th.motion.spring;
569
570    let thumb_left = animate_f32(format!("sw_pos_{}", id), thumb_target_pos, spring);
571    let thumb_d = animate_f32(format!("sw_d_{}", id), thumb_target_d, spring);
572    let thumb_top = (track_h - thumb_d) * 0.5;
573
574    let color_spec = th.motion.color_fast;
575    let is_enabled = config.enabled;
576
577    let track_bg = animate_color(
578        format!("sw_tbg_{}", id),
579        if !is_enabled {
580            if checked {
581                config.disabled_checked_track_color
582            } else {
583                config.disabled_unchecked_track_color
584            }
585        } else if checked {
586            config.checked_track_color
587        } else {
588            config.unchecked_track_color
589        },
590        color_spec,
591    );
592    let thumb_bg = animate_color(
593        format!("sw_tmbg_{}", id),
594        if !is_enabled {
595            if checked {
596                config.disabled_checked_thumb_color
597            } else {
598                config.disabled_unchecked_thumb_color
599            }
600        } else if checked {
601            config.checked_thumb_color
602        } else {
603            config.unchecked_thumb_color
604        },
605        color_spec,
606    );
607    let track_border = animate_f32(
608        format!("sw_tb_{}", id),
609        if !is_enabled {
610            if checked { 0.0 } else { 2.0 }
611        } else if checked {
612            0.0
613        } else {
614            2.0
615        },
616        color_spec,
617    );
618    let border_color = animate_color(
619        format!("sw_bc_{}", id),
620        if !is_enabled {
621            if checked {
622                config.disabled_checked_border_color
623            } else {
624                config.disabled_unchecked_border_color
625            }
626        } else if checked {
627            config.checked_border_color
628        } else {
629            config.unchecked_border_color
630        },
631        color_spec,
632    );
633
634    let sw_source: Rc<MutableInteractionSource> = config
635        .interaction_source
636        .clone()
637        .map(Rc::new)
638        .unwrap_or_else(|| remember(MutableInteractionSource::new));
639
640    let mut track = Modifier::new()
641        .size(track_w, track_h)
642        .padding(0.0)
643        .clip_rounded(track_h * 0.5)
644        .background(track_bg)
645        .border(track_border, border_color, track_h * 0.5)
646        .interaction_source(&sw_source);
647
648    track = apply_enabled_click(track, is_enabled, {
649        let cb = on_change;
650        move || cb(!checked)
651    });
652    track = track.semantics(Semantics {
653        role: Role::Switch,
654        enabled: is_enabled,
655        checked: Some(checked),
656        ..Default::default()
657    });
658
659    Box(track.then(config.modifier)).child((
660        Box(Modifier::new()
661            .size(thumb_d, thumb_d)
662            .background(thumb_bg)
663            .clip_rounded(thumb_d * 0.5)
664            .hit_passthrough()
665            .align_items(AlignItems::CENTER)
666            .justify_content(JustifyContent::CENTER)
667            .absolute()
668            .offset(Some(thumb_left), Some(thumb_top), None, None))
669        .child(
670            config
671                .thumb_content
672                .map(|v| {
673                    let icon_col = if !is_enabled {
674                        if checked {
675                            config.disabled_checked_icon_color
676                        } else {
677                            config.disabled_unchecked_icon_color
678                        }
679                    } else if checked {
680                        config.checked_icon_color
681                    } else {
682                        config.unchecked_icon_color
683                    };
684                    with_content_color(icon_col, move || v)
685                })
686                .unwrap_or(Box(Modifier::new())),
687        ),
688        Box(Modifier::new()
689            .size(40.0, 40.0)
690            .hit_passthrough()
691            .interaction_source(&sw_source)
692            .indication(crate::ripple::ripple(crate::ripple::RippleConfig {
693                color: Some(th.on_surface),
694                bounded: false,
695                radius: Some(20.0),
696                ..Default::default()
697            }))
698            .absolute()
699            .offset(
700                Some(thumb_left + thumb_d * 0.5 - 20.0),
701                Some(track_h * 0.5 - 20.0),
702                None,
703                None,
704            )),
705    ))
706}