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