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