Skip to main content

repose_material/material3/
buttons.rs

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