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