Skip to main content

repose_material/material3/
buttons.rs

1#![allow(non_snake_case)]
2
3use std::rc::Rc;
4
5use crate::ripple::{RippleConfig, ripple};
6use repose_core::*;
7use repose_ui::{
8    Box,
9    ViewExt,
10};
11
12use super::*;
13
14/// Color slots for buttons (matching Compose Material3 `ButtonColors`).
15#[derive(Clone, Copy, Debug)]
16pub struct ButtonColors {
17    pub container_color: Color,
18    pub content_color: Color,
19    pub disabled_container_color: Color,
20    pub disabled_content_color: Color,
21}
22
23impl ButtonColors {
24    pub fn container(&self, enabled: bool) -> Color {
25        if enabled {
26            self.container_color
27        } else {
28            self.disabled_container_color
29        }
30    }
31    pub fn content(&self, enabled: bool) -> Color {
32        if enabled {
33            self.content_color
34        } else {
35            self.disabled_content_color
36        }
37    }
38}
39
40/// Elevation levels for buttons (matching Compose Material3 `ButtonElevation`).
41#[derive(Clone, Copy, Debug)]
42pub struct ButtonElevation {
43    pub default: f32,
44    pub pressed: f32,
45    pub focused: f32,
46    pub hovered: f32,
47    pub disabled: f32,
48}
49
50/// Configuration for button components.
51#[derive(Clone, Debug)]
52pub struct ButtonConfig {
53    pub modifier: Modifier,
54    pub enabled: bool,
55    pub content_color: Option<Color>,
56    pub container_color: Option<Color>,
57    pub state_colors: StateColors,
58    pub state_elevation: Option<StateElevation>,
59    pub border: Option<(f32, Color, f32)>,
60    pub shape_radius: f32,
61    pub content_padding: Option<PaddingValues>,
62    pub height: f32,
63    pub colors: Option<ButtonColors>,
64    pub elevation: Option<ButtonElevation>,
65    pub interaction_source: Option<MutableInteractionSource>,
66}
67
68impl Default for ButtonConfig {
69    fn default() -> Self {
70        Self {
71            modifier: Modifier::new(),
72            enabled: true,
73            content_color: None,
74            container_color: None,
75            state_colors: ButtonDefaults::state_colors_default(),
76            state_elevation: None,
77            border: None,
78            shape_radius: ButtonDefaults::SHAPE_RADIUS,
79            content_padding: None,
80            height: ButtonDefaults::HEIGHT,
81            colors: None,
82            elevation: None,
83            interaction_source: None,
84        }
85    }
86}
87
88/// Resolve effective button colors from config, given the variant's default colors.
89/// When `config.colors` is set, it takes priority over individual fields.
90fn resolve_button_colors(
91    config: &ButtonConfig,
92    def: ButtonColors,
93) -> (Color, Option<Color>, StateColors, Option<StateElevation>) {
94    if let Some(colors) = &config.colors {
95        let bg = if config.enabled {
96            colors.container_color
97        } else {
98            colors.disabled_container_color
99        };
100        let cc = if config.enabled {
101            colors.content_color
102        } else {
103            colors.disabled_content_color
104        };
105        let sc = StateColors {
106            default: Color::TRANSPARENT,
107            hovered: colors.content_color.with_alpha_f32(0.08),
108            pressed: colors.content_color.with_alpha_f32(0.12),
109            disabled: Color::TRANSPARENT,
110        };
111        let se = config.elevation.map(|e| StateElevation {
112            default: e.default,
113            hovered: e.hovered,
114            pressed: e.pressed,
115            disabled: e.disabled,
116        });
117        (cc, Some(bg), sc, se)
118    } else {
119        let cc = config.content_color.unwrap_or(def.content_color);
120        let bg = Some(config.container_color.unwrap_or(def.container_color));
121        let sc = if config.enabled {
122            config.state_colors
123        } else {
124            StateColors {
125                default: Color::TRANSPARENT,
126                hovered: Color::TRANSPARENT,
127                pressed: Color::TRANSPARENT,
128                disabled: config.state_colors.disabled,
129            }
130        };
131        let se = config.state_elevation;
132        (cc, bg, sc, se)
133    }
134}
135
136fn button_impl(
137    outer_modifier: Modifier,
138    on_click: impl Fn() + 'static,
139    content: impl FnOnce() -> View,
140    content_color: Color,
141    container_color: Option<Color>,
142    state_colors: StateColors,
143    state_elevation: Option<StateElevation>,
144    border: Option<(f32, Color, f32)>,
145    padding_left: f32,
146    padding_right: f32,
147    height: f32,
148    shape_radius: f32,
149    enabled: bool,
150    interaction_source: Option<MutableInteractionSource>,
151) -> View {
152    let mut m = Modifier::new().min_height(height).min_width(48.0).flex_shrink(0.0);
153    if let Some(bg) = container_color {
154        m = m.background(bg);
155    }
156    m = m.state_colors(if enabled {
157        state_colors
158    } else {
159        StateColors {
160            default: Color::TRANSPARENT,
161            hovered: Color::TRANSPARENT,
162            pressed: Color::TRANSPARENT,
163            disabled: state_colors.disabled,
164        }
165    });
166    if let Some(se) = state_elevation {
167        m = m.state_elevation(se);
168    }
169    if let Some((w, c, r)) = border {
170        m = m.border(w, c, r);
171    }
172    m = m
173        .clip_rounded(shape_radius)
174        .padding_values(PaddingValues {
175            left: padding_left,
176            right: padding_right,
177            top: 8.0,
178            bottom: 8.0,
179        })
180        .align_items(AlignItems::CENTER)
181        .justify_content(JustifyContent::CENTER);
182
183    // Interaction source + ripple indication
184    let source: Rc<MutableInteractionSource> = interaction_source.map(Rc::new).unwrap_or_else(|| {
185        match outer_modifier.key {
186            Some(k) => {
187                remember_with_key(format!("m3_btn_src:{k}"), MutableInteractionSource::new)
188            }
189            None => remember(MutableInteractionSource::new),
190        }
191    });
192    m = m.interaction_source(&*source);
193    m = m.indication(ripple(RippleConfig {
194        color: Some(content_color),
195        bounded: true,
196        ..Default::default()
197    }));
198
199    if enabled {
200        m = m.clickable().on_click(move || on_click());
201    }
202    m = m.then(outer_modifier);
203    let effective = if enabled {
204        content_color
205    } else {
206        content_color.with_alpha_f32(0.38)
207    };
208    let content = with_content_color(effective, content);
209    Box(m).child(content)
210}
211
212/// M3 Button - prominent action button with primary color fill.
213/// (Equivalent to Compose Material3's `Button`.)
214pub fn Button(
215    modifier: Modifier,
216    on_click: impl Fn() + 'static,
217    config: ButtonConfig,
218    content: impl FnOnce() -> View,
219) -> View {
220    let def = ButtonColors {
221        container_color: ButtonDefaults::container_color(),
222        content_color: ButtonDefaults::content_color(),
223        disabled_container_color: ButtonDefaults::container_color()
224            .with_alpha_f32(0.12)
225            .composite_over(theme().surface_container_low),
226        disabled_content_color: ButtonDefaults::content_color().with_alpha_f32(0.38),
227    };
228    let (cc, bg, sc, se) = resolve_button_colors(&config, def);
229    let pad = config.content_padding.unwrap_or(PaddingValues {
230        left: 24.0,
231        right: 24.0,
232        top: 0.0,
233        bottom: 0.0,
234    });
235    button_impl(
236        modifier.then(config.modifier),
237        on_click,
238        content,
239        cc,
240        bg,
241        sc,
242        se.or(Some(ButtonDefaults::state_elevation_default())),
243        config.border,
244        pad.left,
245        pad.right,
246        config.height,
247        config.shape_radius,
248        config.enabled,
249        config.interaction_source.clone(),
250    )
251}
252
253/// M3 Filled Tonal Button - uses secondary container colors.
254pub fn FilledTonalButton(
255    modifier: Modifier,
256    on_click: impl Fn() + 'static,
257    config: ButtonConfig,
258    content: impl FnOnce() -> View,
259) -> View {
260    let th = theme();
261    let def = ButtonColors {
262        container_color: ButtonDefaults::tonal_container_color(),
263        content_color: ButtonDefaults::tonal_content_color(),
264        disabled_container_color: th
265            .on_surface
266            .with_alpha_f32(0.12)
267            .composite_over(th.surface_container_low),
268        disabled_content_color: th.on_surface.with_alpha_f32(0.38),
269    };
270    let (cc, bg, sc, se) = resolve_button_colors(&config, def);
271    let pad = config.content_padding.unwrap_or(PaddingValues {
272        left: 24.0,
273        right: 24.0,
274        top: 0.0,
275        bottom: 0.0,
276    });
277    button_impl(
278        modifier.then(config.modifier),
279        on_click,
280        content,
281        cc,
282        bg,
283        sc,
284        se.or(Some(ButtonDefaults::state_elevation_default())),
285        config.border,
286        pad.left,
287        pad.right,
288        config.height,
289        config.shape_radius,
290        config.enabled,
291        config.interaction_source.clone(),
292    )
293}
294
295/// M3 Outlined Button - button with an outline border and no fill.
296pub fn OutlinedButton(
297    modifier: Modifier,
298    on_click: impl Fn() + 'static,
299    config: ButtonConfig,
300    content: impl FnOnce() -> View,
301) -> View {
302    let th = theme();
303    let def = ButtonColors {
304        container_color: Color::TRANSPARENT,
305        content_color: ButtonDefaults::outlined_content_color(),
306        disabled_container_color: Color::TRANSPARENT,
307        disabled_content_color: th.on_surface.with_alpha_f32(0.38),
308    };
309    let (cc, bg, sc, se) = resolve_button_colors(&config, def);
310    let border = config
311        .border
312        .unwrap_or((1.0, ButtonDefaults::outlined_border_color(), 20.0));
313    let pad = config.content_padding.unwrap_or(PaddingValues {
314        left: 24.0,
315        right: 24.0,
316        top: 0.0,
317        bottom: 0.0,
318    });
319    button_impl(
320        modifier.then(config.modifier),
321        on_click,
322        content,
323        cc,
324        bg,
325        sc,
326        se,
327        Some(border),
328        pad.left,
329        pad.right,
330        config.height,
331        config.shape_radius,
332        config.enabled,
333        config.interaction_source.clone(),
334    )
335}
336
337/// M3 Text Button - a low-emphasis button.
338pub fn TextButton(
339    modifier: Modifier,
340    on_click: impl Fn() + 'static,
341    config: ButtonConfig,
342    content: impl FnOnce() -> View,
343) -> View {
344    let th = theme();
345    let def = ButtonColors {
346        container_color: Color::TRANSPARENT,
347        content_color: ButtonDefaults::text_content_color(),
348        disabled_container_color: Color::TRANSPARENT,
349        disabled_content_color: th.on_surface.with_alpha_f32(0.38),
350    };
351    let (cc, bg, sc, se) = resolve_button_colors(&config, def);
352    let pad = config.content_padding.unwrap_or(PaddingValues {
353        left: 12.0,
354        right: 12.0,
355        top: 0.0,
356        bottom: 0.0,
357    });
358    button_impl(
359        modifier.then(config.modifier),
360        on_click,
361        content,
362        cc,
363        bg,
364        sc,
365        se,
366        None,
367        pad.left,
368        pad.right,
369        config.height,
370        config.shape_radius,
371        config.enabled,
372        config.interaction_source.clone(),
373    )
374}
375
376/// M3 Elevated Button - uses `surface_container_low` background with elevation.
377pub fn ElevatedButton(
378    modifier: Modifier,
379    on_click: impl Fn() + 'static,
380    config: ButtonConfig,
381    content: impl FnOnce() -> View,
382) -> View {
383    let th = theme();
384    let def = ButtonColors {
385        container_color: ButtonDefaults::elevated_container_color(),
386        content_color: ButtonDefaults::elevated_content_color(),
387        disabled_container_color: th.on_surface.with_alpha_f32(0.04),
388        disabled_content_color: th.on_surface.with_alpha_f32(0.38),
389    };
390    let (cc, bg, sc, se) = resolve_button_colors(&config, def);
391    let pad = config.content_padding.unwrap_or(PaddingValues {
392        left: 24.0,
393        right: 24.0,
394        top: 0.0,
395        bottom: 0.0,
396    });
397    button_impl(
398        modifier.then(config.modifier),
399        on_click,
400        content,
401        cc,
402        bg,
403        sc,
404        se.or(Some(ButtonDefaults::elevated_state_elevation())),
405        config.border,
406        pad.left,
407        pad.right,
408        config.height,
409        config.shape_radius,
410        config.enabled,
411        config.interaction_source.clone(),
412    )
413}
414
415/// Configuration for toggle button components.
416#[derive(Clone, Debug)]
417pub struct ToggleButtonConfig {
418    pub modifier: Modifier,
419    pub enabled: bool,
420    pub container_color: Option<Color>,
421    pub content_color: Option<Color>,
422    pub checked_container_color: Option<Color>,
423    pub checked_content_color: Option<Color>,
424    pub state_colors: StateColors,
425    pub state_elevation: Option<StateElevation>,
426    pub border: Option<(f32, Color, f32)>,
427    pub shape_radius: f32,
428    pub height: f32,
429    pub content_padding: Option<PaddingValues>,
430    pub interaction_source: Option<MutableInteractionSource>,
431}
432
433impl Default for ToggleButtonConfig {
434    fn default() -> Self {
435        Self {
436            modifier: Modifier::new(),
437            enabled: true,
438            container_color: None,
439            content_color: None,
440            checked_container_color: None,
441            checked_content_color: None,
442            state_colors: ToggleButtonDefaults::state_colors_default(),
443            state_elevation: None,
444            border: None,
445            shape_radius: ToggleButtonDefaults::SHAPE_RADIUS,
446            height: ToggleButtonDefaults::HEIGHT,
447            content_padding: None,
448            interaction_source: None,
449        }
450    }
451}
452
453fn toggle_button_impl(
454    checked: bool,
455    on_checked_change: impl Fn(bool) + 'static,
456    content: impl FnOnce(bool) -> View,
457    content_color: Color,
458    container_color: Option<Color>,
459    checked_container_color: Option<Color>,
460    checked_content_color: Option<Color>,
461    state_colors: StateColors,
462    state_elevation: StateElevation,
463    border: Option<(f32, Color, f32)>,
464    pad_left: f32,
465    pad_right: f32,
466    height: f32,
467    shape_radius: f32,
468    enabled: bool,
469    interaction_source: Option<MutableInteractionSource>,
470) -> View {
471    let th = theme();
472    let bg = if checked {
473        checked_container_color.unwrap_or(th.primary)
474    } else {
475        container_color.unwrap_or(Color::TRANSPARENT)
476    };
477    let fg = if checked {
478        checked_content_color.unwrap_or(th.on_primary)
479    } else {
480        content_color
481    };
482    let mut m = Modifier::new()
483        .min_height(height)
484        .padding_values(PaddingValues {
485            left: pad_left,
486            right: pad_right,
487            top: 8.0,
488            bottom: 8.0,
489        })
490        .background(bg)
491        .clip_rounded(shape_radius)
492        .align_items(AlignItems::CENTER)
493        .justify_content(JustifyContent::CENTER)
494        .state_colors(state_colors)
495        .state_elevation(state_elevation);
496    let tg_source: Rc<MutableInteractionSource> = interaction_source
497        .map(Rc::new)
498        .unwrap_or_else(|| remember(MutableInteractionSource::new));
499    m = m.interaction_source(&*tg_source);
500    if let Some((w, c, r)) = border {
501        m = m.border(w, c, r);
502    }
503    if enabled {
504        let cb = on_checked_change;
505        m = m.clickable().on_click(move || cb(!checked));
506    } else {
507        m = m.alpha(0.38);
508    }
509    with_content_color(fg, || Box(m).child(content(checked)))
510}
511
512/// M3 Toggle Button - a button that toggles between checked/unchecked states.
513pub fn ToggleButton(
514    checked: bool,
515    on_checked_change: impl Fn(bool) + 'static,
516    config: ToggleButtonConfig,
517    content: impl FnOnce(bool) -> View,
518) -> View {
519    let cc = config
520        .content_color
521        .unwrap_or_else(ToggleButtonDefaults::content_color);
522    let checked_cc = config
523        .checked_content_color
524        .unwrap_or_else(ToggleButtonDefaults::checked_content_color);
525    let checked_bg = config
526        .checked_container_color
527        .unwrap_or_else(ToggleButtonDefaults::checked_container_color);
528    let se = config
529        .state_elevation
530        .unwrap_or_else(ToggleButtonDefaults::state_elevation_default);
531    let pad_l = config
532        .content_padding
533        .map(|p| p.left)
534        .unwrap_or(ToggleButtonDefaults::HORIZONTAL_PADDING);
535    let pad_r = config
536        .content_padding
537        .map(|p| p.right)
538        .unwrap_or(ToggleButtonDefaults::HORIZONTAL_PADDING);
539    toggle_button_impl(
540        checked,
541        on_checked_change,
542        content,
543        cc,
544        None,
545        Some(checked_bg),
546        Some(checked_cc),
547        config.state_colors,
548        se,
549        config.border,
550        pad_l,
551        pad_r,
552        config.height,
553        config.shape_radius,
554        config.enabled,
555        config.interaction_source.clone(),
556    )
557}
558
559/// M3 Tonal Toggle Button - uses secondary container colors.
560pub fn TonalToggleButton(
561    checked: bool,
562    on_checked_change: impl Fn(bool) + 'static,
563    config: ToggleButtonConfig,
564    content: impl FnOnce(bool) -> View,
565) -> View {
566    let cc = config
567        .content_color
568        .unwrap_or_else(ToggleButtonDefaults::tonal_content_color);
569    let checked_cc = config
570        .checked_content_color
571        .unwrap_or_else(ToggleButtonDefaults::tonal_checked_content_color);
572    let checked_bg = config
573        .checked_container_color
574        .unwrap_or_else(ToggleButtonDefaults::tonal_checked_container_color);
575    let se = config
576        .state_elevation
577        .unwrap_or_else(ToggleButtonDefaults::state_elevation_default);
578    toggle_button_impl(
579        checked,
580        on_checked_change,
581        content,
582        cc,
583        None,
584        Some(checked_bg),
585        Some(checked_cc),
586        config.state_colors,
587        se,
588        config.border,
589        config
590            .content_padding
591            .map(|p| p.left)
592            .unwrap_or(ToggleButtonDefaults::HORIZONTAL_PADDING),
593        config
594            .content_padding
595            .map(|p| p.right)
596            .unwrap_or(ToggleButtonDefaults::HORIZONTAL_PADDING),
597        config.height,
598        config.shape_radius,
599        config.enabled,
600        config.interaction_source.clone(),
601    )
602}
603
604/// M3 Outlined Toggle Button - outlined button that toggles between states.
605pub fn OutlinedToggleButton(
606    checked: bool,
607    on_checked_change: impl Fn(bool) + 'static,
608    config: ToggleButtonConfig,
609    content: impl FnOnce(bool) -> View,
610) -> View {
611    let cc = config
612        .content_color
613        .unwrap_or_else(ToggleButtonDefaults::outlined_content_color);
614    let checked_cc = config
615        .checked_content_color
616        .unwrap_or_else(ToggleButtonDefaults::outlined_checked_content_color);
617    let checked_bg = config
618        .checked_container_color
619        .unwrap_or_else(ToggleButtonDefaults::outlined_checked_container_color);
620    let se = config
621        .state_elevation
622        .unwrap_or_else(ToggleButtonDefaults::state_elevation_default);
623    let border = if !checked {
624        Some(config.border.unwrap_or((
625            1.0,
626            ToggleButtonDefaults::outlined_border_color(),
627            config.shape_radius,
628        )))
629    } else {
630        config.border
631    };
632    toggle_button_impl(
633        checked,
634        on_checked_change,
635        content,
636        cc,
637        None,
638        Some(checked_bg),
639        Some(checked_cc),
640        config.state_colors,
641        se,
642        border,
643        config
644            .content_padding
645            .map(|p| p.left)
646            .unwrap_or(ToggleButtonDefaults::HORIZONTAL_PADDING),
647        config
648            .content_padding
649            .map(|p| p.right)
650            .unwrap_or(ToggleButtonDefaults::HORIZONTAL_PADDING),
651        config.height,
652        config.shape_radius,
653        config.enabled,
654        config.interaction_source.clone(),
655    )
656}
657
658/// M3 Elevated Toggle Button - elevated button that toggles between states.
659pub fn ElevatedToggleButton(
660    checked: bool,
661    on_checked_change: impl Fn(bool) + 'static,
662    config: ToggleButtonConfig,
663    content: impl FnOnce(bool) -> View,
664) -> View {
665    let cc = config
666        .content_color
667        .unwrap_or_else(ToggleButtonDefaults::elevated_content_color);
668    let checked_cc = config
669        .checked_content_color
670        .unwrap_or_else(ToggleButtonDefaults::elevated_checked_content_color);
671    let checked_bg = config
672        .checked_container_color
673        .unwrap_or_else(ToggleButtonDefaults::elevated_checked_container_color);
674    let se = config
675        .state_elevation
676        .unwrap_or_else(ToggleButtonDefaults::elevated_state_elevation);
677    toggle_button_impl(
678        checked,
679        on_checked_change,
680        content,
681        cc,
682        None,
683        Some(checked_bg),
684        Some(checked_cc),
685        config.state_colors,
686        se,
687        config.border,
688        config
689            .content_padding
690            .map(|p| p.left)
691            .unwrap_or(ToggleButtonDefaults::HORIZONTAL_PADDING),
692        config
693            .content_padding
694            .map(|p| p.right)
695            .unwrap_or(ToggleButtonDefaults::HORIZONTAL_PADDING),
696        config.height,
697        config.shape_radius,
698        config.enabled,
699        config.interaction_source.clone(),
700    )
701}