Skip to main content

repose_material/material3/
advbuttons.rs

1#![allow(non_snake_case)]
2
3use std::rc::Rc;
4
5use super::SplitButtonDefaults;
6use super::util::apply_m3_clickable;
7use repose_core::{locals::with_content_color, *};
8use repose_ui::{Box, Row, Text, TextStyle, ViewExt};
9
10/// Configuration for [`SplitButtonLayout`].
11#[derive(Clone)]
12pub struct SplitButtonConfig {
13    pub modifier: Modifier,
14    pub spacing: f32,
15}
16
17impl Default for SplitButtonConfig {
18    fn default() -> Self {
19        Self {
20            modifier: Modifier::new(),
21            spacing: SplitButtonDefaults::SPACING,
22        }
23    }
24}
25
26/// M3 SplitButtonLayout -> places leading and trailing buttons side by side.
27///
28/// Pass styled buttons (via [`Button`], [`FilledTonalButton`], etc.) or use
29/// the factory helpers below for properly-shaped split button parts.
30pub fn SplitButtonLayout(
31    leading_button: View,
32    trailing_button: View,
33    config: SplitButtonConfig,
34) -> View {
35    Row(config
36        .modifier
37        .gap(config.spacing)
38        .align_items(AlignItems::CENTER))
39    .child((leading_button, trailing_button))
40}
41
42fn split_leading_shape_radii() -> [f32; 4] {
43    [
44        SplitButtonDefaults::OUTER_CORNER_SIZE,
45        SplitButtonDefaults::SMALL_INNER_CORNER_SIZE,
46        SplitButtonDefaults::SMALL_INNER_CORNER_SIZE,
47        SplitButtonDefaults::OUTER_CORNER_SIZE,
48    ]
49}
50
51fn split_trailing_shape_radii() -> [f32; 4] {
52    [
53        SplitButtonDefaults::SMALL_INNER_CORNER_SIZE,
54        SplitButtonDefaults::OUTER_CORNER_SIZE,
55        SplitButtonDefaults::OUTER_CORNER_SIZE,
56        SplitButtonDefaults::SMALL_INNER_CORNER_SIZE,
57    ]
58}
59
60fn split_button_impl(
61    outer_modifier: Modifier,
62    on_click: impl Fn() + 'static,
63    content: impl FnOnce() -> View,
64    content_color: Color,
65    container_color: Option<Color>,
66    state_colors: StateColors,
67    state_elevation: Option<StateElevation>,
68    border: Option<(f32, Color, f32)>,
69    pad_left: f32,
70    pad_right: f32,
71    height: f32,
72    enabled: bool,
73    interaction_source: Option<MutableInteractionSource>,
74) -> View {
75    let mut m = Modifier::new()
76        .height(height)
77        .min_width(48.0)
78        .padding_values(PaddingValues {
79            left: pad_left,
80            right: pad_right,
81            top: 0.0,
82            bottom: 0.0,
83        })
84        .align_items(AlignItems::CENTER)
85        .justify_content(JustifyContent::CENTER);
86    if let Some(bg) = container_color {
87        m = m.background(bg);
88    }
89    m = m.state_colors(if enabled {
90        state_colors
91    } else {
92        StateColors {
93            default: Color::TRANSPARENT,
94            hovered: Color::TRANSPARENT,
95            focused: Color::TRANSPARENT,
96            pressed: Color::TRANSPARENT,
97            dragged: Color::TRANSPARENT,
98            disabled: state_colors.disabled,
99        }
100    });
101    if let Some(se) = state_elevation {
102        m = m.state_elevation(se);
103    }
104    if let Some((w, c, r)) = border {
105        m = m.border(w, c, r);
106    }
107    let source: Rc<MutableInteractionSource> = interaction_source
108        .map(Rc::new)
109        .unwrap_or_else(|| remember(MutableInteractionSource::new));
110    m = m.interaction_source(&source);
111    m = m.indication(crate::ripple::ripple(crate::ripple::RippleConfig {
112        color: Some(content_color),
113        bounded: true,
114        ..Default::default()
115    }));
116    if enabled {
117        m = m.clickable().on_click(on_click);
118    }
119    m = m.then(outer_modifier);
120    let effective = if enabled {
121        content_color
122    } else {
123        content_color.with_alpha_f32(0.38)
124    };
125    with_content_color(effective, || Box(m).child(content()))
126}
127
128pub fn SplitButtonLeadingButton(
129    modifier: Modifier,
130    on_click: impl Fn() + 'static,
131    config: super::ButtonConfig,
132    content: impl FnOnce() -> View,
133) -> View {
134    let def = super::ButtonColors {
135        container_color: super::ButtonDefaults::container_color(),
136        content_color: super::ButtonDefaults::content_color(),
137        disabled_container_color: super::ButtonDefaults::container_color()
138            .with_alpha_f32(0.12)
139            .composite_over(theme().surface_container_low),
140        disabled_content_color: super::ButtonDefaults::content_color().with_alpha_f32(0.38),
141    };
142    let (cc, bg, sc, se) = resolve_button_colors(&config, def);
143    let pad = config
144        .content_padding
145        .unwrap_or(SplitButtonDefaults::small_leading_content_padding());
146    split_button_impl(
147        modifier.clip_rounded_radii(split_leading_shape_radii()),
148        on_click,
149        content,
150        cc,
151        bg,
152        sc,
153        se.or(Some(super::ButtonDefaults::state_elevation_default())),
154        config.border,
155        pad.left,
156        pad.right,
157        config.height,
158        config.enabled,
159        config.interaction_source.clone(),
160    )
161}
162
163pub fn SplitButtonTrailingButton(
164    modifier: Modifier,
165    on_click: impl Fn() + 'static,
166    config: super::ButtonConfig,
167    content: impl FnOnce() -> View,
168) -> View {
169    let def = super::ButtonColors {
170        container_color: super::ButtonDefaults::container_color(),
171        content_color: super::ButtonDefaults::content_color(),
172        disabled_container_color: super::ButtonDefaults::container_color()
173            .with_alpha_f32(0.12)
174            .composite_over(theme().surface_container_low),
175        disabled_content_color: super::ButtonDefaults::content_color().with_alpha_f32(0.38),
176    };
177    let (cc, bg, sc, se) = resolve_button_colors(&config, def);
178    let pad = config
179        .content_padding
180        .unwrap_or(SplitButtonDefaults::small_trailing_content_padding());
181    split_button_impl(
182        modifier.clip_rounded_radii(split_trailing_shape_radii()),
183        on_click,
184        content,
185        cc,
186        bg,
187        sc,
188        se.or(Some(super::ButtonDefaults::state_elevation_default())),
189        config.border,
190        pad.left,
191        pad.right,
192        config.height,
193        config.enabled,
194        config.interaction_source.clone(),
195    )
196}
197
198pub fn SplitButtonTrailingToggleButton(
199    checked: bool,
200    on_checked_change: impl Fn(bool) + 'static,
201    modifier: Modifier,
202    config: super::ToggleButtonConfig,
203    content: impl FnOnce(bool) -> View,
204) -> View {
205    let _th = theme();
206    let cc = config
207        .content_color
208        .unwrap_or_else(super::ToggleButtonDefaults::content_color);
209    let checked_cc = config
210        .checked_content_color
211        .unwrap_or_else(super::ToggleButtonDefaults::checked_content_color);
212    let checked_bg = config
213        .checked_container_color
214        .unwrap_or_else(super::ToggleButtonDefaults::checked_container_color);
215    let bg = if checked {
216        checked_bg
217    } else {
218        config.container_color.unwrap_or(Color::TRANSPARENT)
219    };
220    let fg = if checked { checked_cc } else { cc };
221    let se = config
222        .state_elevation
223        .unwrap_or_else(super::ToggleButtonDefaults::state_elevation_default);
224    let pad_l = config
225        .content_padding
226        .map(|p| p.left)
227        .unwrap_or(super::ToggleButtonDefaults::HORIZONTAL_PADDING);
228    let pad_r = config
229        .content_padding
230        .map(|p| p.right)
231        .unwrap_or(super::ToggleButtonDefaults::HORIZONTAL_PADDING);
232    let mut m = Modifier::new()
233        .height(config.height)
234        .min_width(48.0)
235        .padding_values(PaddingValues {
236            left: pad_l,
237            right: pad_r,
238            top: 0.0,
239            bottom: 0.0,
240        })
241        .background(bg)
242        .align_items(AlignItems::CENTER)
243        .justify_content(JustifyContent::CENTER)
244        .state_colors(config.state_colors)
245        .state_elevation(se);
246    let tg_source: Rc<MutableInteractionSource> = config
247        .interaction_source
248        .clone()
249        .map(Rc::new)
250        .unwrap_or_else(|| remember(MutableInteractionSource::new));
251    m = m.interaction_source(&tg_source);
252    if let Some((w, c, r)) = config.border {
253        m = m.border(w, c, r);
254    }
255    if config.enabled {
256        let cb = on_checked_change;
257        m = apply_m3_clickable(m, &tg_source, fg, true, move || cb(!checked));
258    } else {
259        m = m.alpha(0.38);
260    }
261    m = m.then(modifier.clip_rounded_radii(split_trailing_shape_radii()));
262    with_content_color(fg, || Box(m).child(content(checked)))
263}
264
265pub fn SplitButtonTonalLeadingButton(
266    modifier: Modifier,
267    on_click: impl Fn() + 'static,
268    config: super::ButtonConfig,
269    content: impl FnOnce() -> View,
270) -> View {
271    let def = super::ButtonColors {
272        container_color: super::ButtonDefaults::tonal_container_color(),
273        content_color: super::ButtonDefaults::tonal_content_color(),
274        disabled_container_color: theme()
275            .on_surface
276            .with_alpha_f32(0.12)
277            .composite_over(theme().surface_container_low),
278        disabled_content_color: theme().on_surface.with_alpha_f32(0.38),
279    };
280    let (cc, bg, sc, se) = resolve_button_colors(&config, def);
281    let pad = config
282        .content_padding
283        .unwrap_or(SplitButtonDefaults::small_leading_content_padding());
284    split_button_impl(
285        modifier.clip_rounded_radii(split_leading_shape_radii()),
286        on_click,
287        content,
288        cc,
289        bg,
290        sc,
291        se.or(Some(super::ButtonDefaults::elevated_state_elevation())),
292        config.border,
293        pad.left,
294        pad.right,
295        config.height,
296        config.enabled,
297        config.interaction_source.clone(),
298    )
299}
300
301pub fn SplitButtonTonalTrailingToggleButton(
302    checked: bool,
303    on_checked_change: impl Fn(bool) + 'static,
304    modifier: Modifier,
305    config: super::ToggleButtonConfig,
306    content: impl FnOnce(bool) -> View,
307) -> View {
308    let _th = theme();
309    let cc = config
310        .content_color
311        .unwrap_or_else(super::ToggleButtonDefaults::tonal_content_color);
312    let checked_cc = config
313        .checked_content_color
314        .unwrap_or_else(super::ToggleButtonDefaults::tonal_checked_content_color);
315    let checked_bg = config
316        .checked_container_color
317        .unwrap_or_else(super::ToggleButtonDefaults::tonal_checked_container_color);
318    let bg = if checked {
319        checked_bg
320    } else {
321        config.container_color.unwrap_or(Color::TRANSPARENT)
322    };
323    let fg = if checked { checked_cc } else { cc };
324    let se = config
325        .state_elevation
326        .unwrap_or_else(super::ToggleButtonDefaults::state_elevation_default);
327    let pad_l = config
328        .content_padding
329        .map(|p| p.left)
330        .unwrap_or(super::ToggleButtonDefaults::HORIZONTAL_PADDING);
331    let pad_r = config
332        .content_padding
333        .map(|p| p.right)
334        .unwrap_or(super::ToggleButtonDefaults::HORIZONTAL_PADDING);
335    let mut m = Modifier::new()
336        .height(config.height)
337        .min_width(48.0)
338        .padding_values(PaddingValues {
339            left: pad_l,
340            right: pad_r,
341            top: 0.0,
342            bottom: 0.0,
343        })
344        .background(bg)
345        .align_items(AlignItems::CENTER)
346        .justify_content(JustifyContent::CENTER)
347        .state_colors(config.state_colors)
348        .state_elevation(se);
349    let tg_source: Rc<MutableInteractionSource> = config
350        .interaction_source
351        .clone()
352        .map(Rc::new)
353        .unwrap_or_else(|| remember(MutableInteractionSource::new));
354    m = m.interaction_source(&tg_source);
355    if let Some((w, c, r)) = config.border {
356        m = m.border(w, c, r);
357    }
358    if config.enabled {
359        let cb = on_checked_change;
360        m = apply_m3_clickable(m, &tg_source, fg, true, move || cb(!checked));
361    } else {
362        m = m.alpha(0.38);
363    }
364    m = m.then(modifier.clip_rounded_radii(split_trailing_shape_radii()));
365    with_content_color(fg, || Box(m).child(content(checked)))
366}
367
368/// State for the overflow menu in [`ButtonGroup`].
369pub struct ButtonGroupMenuState {
370    pub is_showing: bool,
371}
372
373impl ButtonGroupMenuState {
374    pub fn dismiss(&mut self) {
375        self.is_showing = false;
376    }
377    pub fn show(&mut self) {
378        self.is_showing = true;
379    }
380}
381
382/// Scope passed to [`ButtonGroup`]'s content closure.
383pub struct ButtonGroupScope {
384    items: Vec<ButtonGroupItem>,
385}
386
387/// Internal item held by `ButtonGroupScope`.
388#[allow(dead_code)] // `menu_content` is populated via the public API (WIP overflow menus).
389struct ButtonGroupItem {
390    button_group_content: Box<dyn FnOnce() -> View>,
391    menu_content: Option<Box<dyn FnOnce(&mut ButtonGroupMenuState) -> View>>,
392}
393
394impl ButtonGroupScope {
395    fn new() -> Self {
396        Self { items: Vec::new() }
397    }
398
399    /// Add a clickable item (rendered as a [`Button`] internally).
400    pub fn clickable_item(
401        &mut self,
402        on_click: impl Fn() + 'static,
403        label: String,
404        icon: Option<View>,
405    ) {
406        let cb = Rc::new(on_click);
407        let cb2 = cb.clone();
408        self.items.push(ButtonGroupItem {
409            button_group_content: Box::new(move || {
410                let cb = cb2.clone();
411                let config = super::ButtonConfig {
412                    shape_radius: 0.0,
413                    ..Default::default()
414                };
415                super::Button(
416                    Modifier::new().flex_grow(1.0),
417                    move || (cb)(),
418                    config,
419                    move || {
420                        let label = label.clone();
421                        let t = Text(label).single_line();
422                        match icon.clone() {
423                            Some(ic) => Box(Modifier::new()).child((ic, t)),
424                            None => t,
425                        }
426                    },
427                )
428            }),
429            menu_content: None,
430        });
431    }
432
433    /// Add a toggleable item (rendered as a [`ToggleButton`] internally).
434    pub fn toggleable_item(
435        &mut self,
436        checked: bool,
437        on_checked_change: impl Fn(bool) + 'static,
438        label: String,
439        icon: Option<View>,
440    ) {
441        let cb = Rc::new(on_checked_change);
442        let cb2 = cb.clone();
443        self.items.push(ButtonGroupItem {
444            button_group_content: Box::new(move || {
445                let cb = cb2.clone();
446                let config = super::ToggleButtonConfig {
447                    shape_radius: 0.0,
448                    ..Default::default()
449                };
450                super::ToggleButton(
451                    checked,
452                    move |b| (cb)(b),
453                    config,
454                    move |_| {
455                        let label = label.clone();
456                        let t = Text(label).single_line();
457                        match icon.clone() {
458                            Some(ic) => Box(Modifier::new()).child((ic, t)),
459                            None => t,
460                        }
461                    },
462                )
463            }),
464            menu_content: None,
465        });
466    }
467
468    /// Add a custom item with a button group composable and an optional overflow menu content.
469    pub fn custom_item(
470        &mut self,
471        button_group_content: impl FnOnce() -> View + 'static,
472        menu_content: Option<impl FnOnce(&mut ButtonGroupMenuState) -> View + 'static>,
473    ) {
474        self.items.push(ButtonGroupItem {
475            button_group_content: Box::new(button_group_content),
476            menu_content: menu_content.map(|f| {
477                let b: Box<dyn FnOnce(&mut ButtonGroupMenuState) -> View> = Box::new(f);
478                b
479            }),
480        });
481    }
482}
483
484/// M3 ButtonGroup -> a horizontal sequence of related action items.
485///
486/// Items are added via [`ButtonGroupScope::clickable_item`] and
487/// [`ButtonGroupScope::toggleable_item`].
488pub fn ButtonGroup(
489    modifier: Modifier,
490    gap: f32,
491    content: impl FnOnce(&mut ButtonGroupScope),
492) -> View {
493    let mut scope = ButtonGroupScope::new();
494    content(&mut scope);
495    Row(modifier.gap(gap).align_items(AlignItems::CENTER)).with_children(
496        scope
497            .items
498            .into_iter()
499            .map(|item| (item.button_group_content)())
500            .collect::<Vec<View>>(),
501    )
502}
503
504fn resolve_button_colors(
505    config: &super::ButtonConfig,
506    def: super::ButtonColors,
507) -> (Color, Option<Color>, StateColors, Option<StateElevation>) {
508    if let Some(colors) = &config.colors {
509        let bg = if config.enabled {
510            colors.container_color
511        } else {
512            colors.disabled_container_color
513        };
514        let cc = if config.enabled {
515            colors.content_color
516        } else {
517            colors.disabled_content_color
518        };
519        let sc = StateColors {
520            default: Color::TRANSPARENT,
521            hovered: Color::TRANSPARENT,
522            focused: Color::TRANSPARENT,
523            pressed: Color::TRANSPARENT,
524            dragged: colors.content_color.with_alpha_f32(0.12),
525            disabled: Color::TRANSPARENT,
526        };
527        let se = config.elevation.map(|e| StateElevation {
528            default: e.default,
529            hovered: e.hovered,
530            focused: e.focused,
531            pressed: e.pressed,
532            dragged: e.pressed,
533            disabled: e.disabled,
534        });
535        (cc, Some(bg), sc, se)
536    } else {
537        let cc = config.content_color.unwrap_or(def.content_color);
538        let bg = Some(config.container_color.unwrap_or(def.container_color));
539        let sc = if config.enabled {
540            config.state_colors
541        } else {
542            StateColors {
543                default: Color::TRANSPARENT,
544                hovered: Color::TRANSPARENT,
545                focused: Color::TRANSPARENT,
546                pressed: Color::TRANSPARENT,
547                dragged: Color::TRANSPARENT,
548                disabled: config.state_colors.disabled,
549            }
550        };
551        let se = config.state_elevation;
552        (cc, bg, sc, se)
553    }
554}