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: colors.content_color.with_alpha_f32(0.08),
105            focused: colors.content_color.with_alpha_f32(0.12),
106            pressed: colors.content_color.with_alpha_f32(0.12),
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    if let Some((w, c, r)) = border {
510        m = m.border(w, c, r);
511    }
512    if enabled {
513        let cb = on_checked_change;
514        m = m.clickable().on_click(move || cb(!checked));
515    } else {
516        m = m.alpha(0.38);
517    }
518    with_content_color(fg, || Box(m).child(content(checked)))
519}
520
521/// M3 Toggle Button - a button that toggles between checked/unchecked states.
522pub fn ToggleButton(
523    checked: bool,
524    on_checked_change: impl Fn(bool) + 'static,
525    config: ToggleButtonConfig,
526    content: impl FnOnce(bool) -> View,
527) -> View {
528    let cc = config
529        .content_color
530        .unwrap_or_else(ToggleButtonDefaults::content_color);
531    let checked_cc = config
532        .checked_content_color
533        .unwrap_or_else(ToggleButtonDefaults::checked_content_color);
534    let checked_bg = config
535        .checked_container_color
536        .unwrap_or_else(ToggleButtonDefaults::checked_container_color);
537    let se = config
538        .state_elevation
539        .unwrap_or_else(ToggleButtonDefaults::state_elevation_default);
540    let pad_l = config
541        .content_padding
542        .map(|p| p.left)
543        .unwrap_or(ToggleButtonDefaults::HORIZONTAL_PADDING);
544    let pad_r = config
545        .content_padding
546        .map(|p| p.right)
547        .unwrap_or(ToggleButtonDefaults::HORIZONTAL_PADDING);
548    toggle_button_impl(
549        checked,
550        on_checked_change,
551        content,
552        cc,
553        None,
554        Some(checked_bg),
555        Some(checked_cc),
556        config.state_colors,
557        se,
558        config.border,
559        pad_l,
560        pad_r,
561        config.height,
562        config.shape_radius,
563        config.enabled,
564        config.interaction_source.clone(),
565    )
566}
567
568/// M3 Tonal Toggle Button - uses secondary container colors.
569pub fn TonalToggleButton(
570    checked: bool,
571    on_checked_change: impl Fn(bool) + 'static,
572    config: ToggleButtonConfig,
573    content: impl FnOnce(bool) -> View,
574) -> View {
575    let cc = config
576        .content_color
577        .unwrap_or_else(ToggleButtonDefaults::tonal_content_color);
578    let checked_cc = config
579        .checked_content_color
580        .unwrap_or_else(ToggleButtonDefaults::tonal_checked_content_color);
581    let checked_bg = config
582        .checked_container_color
583        .unwrap_or_else(ToggleButtonDefaults::tonal_checked_container_color);
584    let se = config
585        .state_elevation
586        .unwrap_or_else(ToggleButtonDefaults::state_elevation_default);
587    toggle_button_impl(
588        checked,
589        on_checked_change,
590        content,
591        cc,
592        None,
593        Some(checked_bg),
594        Some(checked_cc),
595        config.state_colors,
596        se,
597        config.border,
598        config
599            .content_padding
600            .map(|p| p.left)
601            .unwrap_or(ToggleButtonDefaults::HORIZONTAL_PADDING),
602        config
603            .content_padding
604            .map(|p| p.right)
605            .unwrap_or(ToggleButtonDefaults::HORIZONTAL_PADDING),
606        config.height,
607        config.shape_radius,
608        config.enabled,
609        config.interaction_source.clone(),
610    )
611}
612
613/// M3 Outlined Toggle Button - outlined button that toggles between states.
614pub fn OutlinedToggleButton(
615    checked: bool,
616    on_checked_change: impl Fn(bool) + 'static,
617    config: ToggleButtonConfig,
618    content: impl FnOnce(bool) -> View,
619) -> View {
620    let cc = config
621        .content_color
622        .unwrap_or_else(ToggleButtonDefaults::outlined_content_color);
623    let checked_cc = config
624        .checked_content_color
625        .unwrap_or_else(ToggleButtonDefaults::outlined_checked_content_color);
626    let checked_bg = config
627        .checked_container_color
628        .unwrap_or_else(ToggleButtonDefaults::outlined_checked_container_color);
629    let se = config
630        .state_elevation
631        .unwrap_or_else(ToggleButtonDefaults::state_elevation_default);
632    let border = if !checked {
633        Some(config.border.unwrap_or((
634            1.0,
635            ToggleButtonDefaults::outlined_border_color(),
636            config.shape_radius,
637        )))
638    } else {
639        config.border
640    };
641    toggle_button_impl(
642        checked,
643        on_checked_change,
644        content,
645        cc,
646        None,
647        Some(checked_bg),
648        Some(checked_cc),
649        config.state_colors,
650        se,
651        border,
652        config
653            .content_padding
654            .map(|p| p.left)
655            .unwrap_or(ToggleButtonDefaults::HORIZONTAL_PADDING),
656        config
657            .content_padding
658            .map(|p| p.right)
659            .unwrap_or(ToggleButtonDefaults::HORIZONTAL_PADDING),
660        config.height,
661        config.shape_radius,
662        config.enabled,
663        config.interaction_source.clone(),
664    )
665}
666
667/// M3 Elevated Toggle Button - elevated button that toggles between states.
668pub fn ElevatedToggleButton(
669    checked: bool,
670    on_checked_change: impl Fn(bool) + 'static,
671    config: ToggleButtonConfig,
672    content: impl FnOnce(bool) -> View,
673) -> View {
674    let cc = config
675        .content_color
676        .unwrap_or_else(ToggleButtonDefaults::elevated_content_color);
677    let checked_cc = config
678        .checked_content_color
679        .unwrap_or_else(ToggleButtonDefaults::elevated_checked_content_color);
680    let checked_bg = config
681        .checked_container_color
682        .unwrap_or_else(ToggleButtonDefaults::elevated_checked_container_color);
683    let se = config
684        .state_elevation
685        .unwrap_or_else(ToggleButtonDefaults::elevated_state_elevation);
686    toggle_button_impl(
687        checked,
688        on_checked_change,
689        content,
690        cc,
691        None,
692        Some(checked_bg),
693        Some(checked_cc),
694        config.state_colors,
695        se,
696        config.border,
697        config
698            .content_padding
699            .map(|p| p.left)
700            .unwrap_or(ToggleButtonDefaults::HORIZONTAL_PADDING),
701        config
702            .content_padding
703            .map(|p| p.right)
704            .unwrap_or(ToggleButtonDefaults::HORIZONTAL_PADDING),
705        config.height,
706        config.shape_radius,
707        config.enabled,
708        config.interaction_source.clone(),
709    )
710}