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