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