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