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, with_button_semantics};
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 = apply_m3_clickable(m, &source, content_color, enabled, on_click);
111    m = with_button_semantics(m, enabled);
112    m = m.then(outer_modifier);
113    with_content_color(content_color, || Box(m).child(content()))
114}
115
116pub fn SplitButtonLeadingButton(
117    modifier: Modifier,
118    on_click: impl Fn() + 'static,
119    config: super::ButtonConfig,
120    content: impl FnOnce() -> View,
121) -> View {
122    let def = super::ButtonColors {
123        container_color: super::ButtonDefaults::container_color(),
124        content_color: super::ButtonDefaults::content_color(),
125        disabled_container_color: super::ButtonDefaults::container_color()
126            .with_alpha_f32(0.12)
127            .composite_over(theme().surface_container_low),
128        disabled_content_color: super::ButtonDefaults::content_color().with_alpha_f32(0.38),
129    };
130    let (cc, bg, sc, se) = resolve_button_colors(&config, def);
131    let pad = config
132        .content_padding
133        .unwrap_or(SplitButtonDefaults::small_leading_content_padding());
134    split_button_impl(
135        modifier.clip_rounded_radii(split_leading_shape_radii()),
136        on_click,
137        content,
138        cc,
139        bg,
140        sc,
141        se.or(Some(super::ButtonDefaults::state_elevation_default())),
142        config.border,
143        pad.left,
144        pad.right,
145        config.height,
146        config.enabled,
147        config.interaction_source.clone(),
148    )
149}
150
151pub fn SplitButtonTrailingButton(
152    modifier: Modifier,
153    on_click: impl Fn() + 'static,
154    config: super::ButtonConfig,
155    content: impl FnOnce() -> View,
156) -> View {
157    let def = super::ButtonColors {
158        container_color: super::ButtonDefaults::container_color(),
159        content_color: super::ButtonDefaults::content_color(),
160        disabled_container_color: super::ButtonDefaults::container_color()
161            .with_alpha_f32(0.12)
162            .composite_over(theme().surface_container_low),
163        disabled_content_color: super::ButtonDefaults::content_color().with_alpha_f32(0.38),
164    };
165    let (cc, bg, sc, se) = resolve_button_colors(&config, def);
166    let pad = config
167        .content_padding
168        .unwrap_or(SplitButtonDefaults::small_trailing_content_padding());
169    split_button_impl(
170        modifier.clip_rounded_radii(split_trailing_shape_radii()),
171        on_click,
172        content,
173        cc,
174        bg,
175        sc,
176        se.or(Some(super::ButtonDefaults::state_elevation_default())),
177        config.border,
178        pad.left,
179        pad.right,
180        config.height,
181        config.enabled,
182        config.interaction_source.clone(),
183    )
184}
185
186pub fn SplitButtonTrailingToggleButton(
187    checked: bool,
188    on_checked_change: impl Fn(bool) + 'static,
189    modifier: Modifier,
190    config: super::ToggleButtonConfig,
191    content: impl FnOnce(bool) -> View,
192) -> View {
193    let _th = theme();
194    let cc = config
195        .content_color
196        .unwrap_or_else(super::ToggleButtonDefaults::content_color);
197    let checked_cc = config
198        .checked_content_color
199        .unwrap_or_else(super::ToggleButtonDefaults::checked_content_color);
200    let checked_bg = config
201        .checked_container_color
202        .unwrap_or_else(super::ToggleButtonDefaults::checked_container_color);
203    let bg = if checked {
204        checked_bg
205    } else {
206        config.container_color.unwrap_or(Color::TRANSPARENT)
207    };
208    let fg = if checked { checked_cc } else { cc };
209    let se = config
210        .state_elevation
211        .unwrap_or_else(super::ToggleButtonDefaults::state_elevation_default);
212    let pad_l = config
213        .content_padding
214        .map(|p| p.left)
215        .unwrap_or(super::ToggleButtonDefaults::HORIZONTAL_PADDING);
216    let pad_r = config
217        .content_padding
218        .map(|p| p.right)
219        .unwrap_or(super::ToggleButtonDefaults::HORIZONTAL_PADDING);
220    let mut m = Modifier::new()
221        .height(config.height)
222        .min_width(48.0)
223        .padding_values(PaddingValues {
224            left: pad_l,
225            right: pad_r,
226            top: 0.0,
227            bottom: 0.0,
228        })
229        .background(bg)
230        .align_items(AlignItems::CENTER)
231        .justify_content(JustifyContent::CENTER)
232        .state_colors(config.state_colors)
233        .state_elevation(se);
234    let tg_source: Rc<MutableInteractionSource> = config
235        .interaction_source
236        .clone()
237        .map(Rc::new)
238        .unwrap_or_else(|| remember(MutableInteractionSource::new));
239    if let Some((w, c, r)) = config.border {
240        m = m.border(w, c, r);
241    }
242    let cb = on_checked_change;
243    m = apply_m3_clickable(m, &tg_source, fg, config.enabled, move || cb(!checked));
244    if !config.enabled {
245        m = m.alpha(0.38);
246    }
247    m = m.then(modifier.clip_rounded_radii(split_trailing_shape_radii()));
248    with_content_color(fg, || Box(m).child(content(checked)))
249}
250
251pub fn SplitButtonTonalLeadingButton(
252    modifier: Modifier,
253    on_click: impl Fn() + 'static,
254    config: super::ButtonConfig,
255    content: impl FnOnce() -> View,
256) -> View {
257    let def = super::ButtonColors {
258        container_color: super::ButtonDefaults::tonal_container_color(),
259        content_color: super::ButtonDefaults::tonal_content_color(),
260        disabled_container_color: theme()
261            .on_surface
262            .with_alpha_f32(0.12)
263            .composite_over(theme().surface_container_low),
264        disabled_content_color: theme().on_surface.with_alpha_f32(0.38),
265    };
266    let (cc, bg, sc, se) = resolve_button_colors(&config, def);
267    let pad = config
268        .content_padding
269        .unwrap_or(SplitButtonDefaults::small_leading_content_padding());
270    split_button_impl(
271        modifier.clip_rounded_radii(split_leading_shape_radii()),
272        on_click,
273        content,
274        cc,
275        bg,
276        sc,
277        se.or(Some(super::ButtonDefaults::elevated_state_elevation())),
278        config.border,
279        pad.left,
280        pad.right,
281        config.height,
282        config.enabled,
283        config.interaction_source.clone(),
284    )
285}
286
287pub fn SplitButtonTonalTrailingToggleButton(
288    checked: bool,
289    on_checked_change: impl Fn(bool) + 'static,
290    modifier: Modifier,
291    config: super::ToggleButtonConfig,
292    content: impl FnOnce(bool) -> View,
293) -> View {
294    let _th = theme();
295    let cc = config
296        .content_color
297        .unwrap_or_else(super::ToggleButtonDefaults::tonal_content_color);
298    let checked_cc = config
299        .checked_content_color
300        .unwrap_or_else(super::ToggleButtonDefaults::tonal_checked_content_color);
301    let checked_bg = config
302        .checked_container_color
303        .unwrap_or_else(super::ToggleButtonDefaults::tonal_checked_container_color);
304    let bg = if checked {
305        checked_bg
306    } else {
307        config.container_color.unwrap_or(Color::TRANSPARENT)
308    };
309    let fg = if checked { checked_cc } else { cc };
310    let se = config
311        .state_elevation
312        .unwrap_or_else(super::ToggleButtonDefaults::state_elevation_default);
313    let pad_l = config
314        .content_padding
315        .map(|p| p.left)
316        .unwrap_or(super::ToggleButtonDefaults::HORIZONTAL_PADDING);
317    let pad_r = config
318        .content_padding
319        .map(|p| p.right)
320        .unwrap_or(super::ToggleButtonDefaults::HORIZONTAL_PADDING);
321    let mut m = Modifier::new()
322        .height(config.height)
323        .min_width(48.0)
324        .padding_values(PaddingValues {
325            left: pad_l,
326            right: pad_r,
327            top: 0.0,
328            bottom: 0.0,
329        })
330        .background(bg)
331        .align_items(AlignItems::CENTER)
332        .justify_content(JustifyContent::CENTER)
333        .state_colors(config.state_colors)
334        .state_elevation(se);
335    let tg_source: Rc<MutableInteractionSource> = config
336        .interaction_source
337        .clone()
338        .map(Rc::new)
339        .unwrap_or_else(|| remember(MutableInteractionSource::new));
340    if let Some((w, c, r)) = config.border {
341        m = m.border(w, c, r);
342    }
343    let cb = on_checked_change;
344    m = apply_m3_clickable(m, &tg_source, fg, config.enabled, move || cb(!checked));
345    if !config.enabled {
346        m = m.alpha(0.38);
347    }
348    m = m.then(modifier.clip_rounded_radii(split_trailing_shape_radii()));
349    with_content_color(fg, || Box(m).child(content(checked)))
350}
351
352/// State for the overflow menu in [`ButtonGroup`].
353pub struct ButtonGroupMenuState {
354    pub is_showing: bool,
355}
356
357impl ButtonGroupMenuState {
358    pub fn dismiss(&mut self) {
359        self.is_showing = false;
360    }
361    pub fn show(&mut self) {
362        self.is_showing = true;
363    }
364}
365
366/// Scope passed to [`ButtonGroup`]'s content closure.
367pub struct ButtonGroupScope {
368    items: Vec<ButtonGroupItem>,
369}
370
371/// Internal item held by `ButtonGroupScope`.
372#[allow(dead_code)] // `menu_content` is populated via the public API (WIP overflow menus).
373struct ButtonGroupItem {
374    button_group_content: Box<dyn FnOnce() -> View>,
375    menu_content: Option<Box<dyn FnOnce(&mut ButtonGroupMenuState) -> View>>,
376}
377
378impl ButtonGroupScope {
379    fn new() -> Self {
380        Self { items: Vec::new() }
381    }
382
383    /// Add a clickable item (rendered as a [`Button`] internally).
384    pub fn clickable_item(
385        &mut self,
386        on_click: impl Fn() + 'static,
387        label: String,
388        icon: Option<View>,
389    ) {
390        let cb = Rc::new(on_click);
391        let cb2 = cb.clone();
392        self.items.push(ButtonGroupItem {
393            button_group_content: Box::new(move || {
394                let cb = cb2.clone();
395                let config = super::ButtonConfig {
396                    shape_radius: 0.0,
397                    ..Default::default()
398                };
399                super::Button(
400                    Modifier::new().flex_grow(1.0),
401                    move || (cb)(),
402                    config,
403                    move || {
404                        let label = label.clone();
405                        let t = Text(label).single_line();
406                        match icon.clone() {
407                            Some(ic) => Box(Modifier::new()).child((ic, t)),
408                            None => t,
409                        }
410                    },
411                )
412            }),
413            menu_content: None,
414        });
415    }
416
417    /// Add a toggleable item (rendered as a [`ToggleButton`] internally).
418    pub fn toggleable_item(
419        &mut self,
420        checked: bool,
421        on_checked_change: impl Fn(bool) + 'static,
422        label: String,
423        icon: Option<View>,
424    ) {
425        let cb = Rc::new(on_checked_change);
426        let cb2 = cb.clone();
427        self.items.push(ButtonGroupItem {
428            button_group_content: Box::new(move || {
429                let cb = cb2.clone();
430                let config = super::ToggleButtonConfig {
431                    shape_radius: 0.0,
432                    ..Default::default()
433                };
434                super::ToggleButton(
435                    checked,
436                    move |b| (cb)(b),
437                    config,
438                    move |_| {
439                        let label = label.clone();
440                        let t = Text(label).single_line();
441                        match icon.clone() {
442                            Some(ic) => Box(Modifier::new()).child((ic, t)),
443                            None => t,
444                        }
445                    },
446                )
447            }),
448            menu_content: None,
449        });
450    }
451
452    /// Add a custom item with a button group composable and an optional overflow menu content.
453    pub fn custom_item(
454        &mut self,
455        button_group_content: impl FnOnce() -> View + 'static,
456        menu_content: Option<impl FnOnce(&mut ButtonGroupMenuState) -> View + 'static>,
457    ) {
458        self.items.push(ButtonGroupItem {
459            button_group_content: Box::new(button_group_content),
460            menu_content: menu_content.map(|f| {
461                let b: Box<dyn FnOnce(&mut ButtonGroupMenuState) -> View> = Box::new(f);
462                b
463            }),
464        });
465    }
466}
467
468/// M3 ButtonGroup -> a horizontal sequence of related action items.
469///
470/// Items are added via [`ButtonGroupScope::clickable_item`] and
471/// [`ButtonGroupScope::toggleable_item`].
472pub fn ButtonGroup(
473    modifier: Modifier,
474    gap: f32,
475    content: impl FnOnce(&mut ButtonGroupScope),
476) -> View {
477    let mut scope = ButtonGroupScope::new();
478    content(&mut scope);
479    Row(modifier.gap(gap).align_items(AlignItems::CENTER)).with_children(
480        scope
481            .items
482            .into_iter()
483            .map(|item| (item.button_group_content)())
484            .collect::<Vec<View>>(),
485    )
486}
487
488fn resolve_button_colors(
489    config: &super::ButtonConfig,
490    def: super::ButtonColors,
491) -> (Color, Option<Color>, StateColors, Option<StateElevation>) {
492    if let Some(colors) = &config.colors {
493        let bg = if config.enabled {
494            colors.container_color
495        } else {
496            colors.disabled_container_color
497        };
498        let cc = if config.enabled {
499            colors.content_color
500        } else {
501            colors.disabled_content_color
502        };
503        let sc = StateColors {
504            default: Color::TRANSPARENT,
505            hovered: Color::TRANSPARENT,
506            focused: Color::TRANSPARENT,
507            pressed: Color::TRANSPARENT,
508            dragged: colors.content_color.with_alpha_f32(0.12),
509            disabled: Color::TRANSPARENT,
510        };
511        let se = config.elevation.map(|e| StateElevation {
512            default: e.default,
513            hovered: e.hovered,
514            focused: e.focused,
515            pressed: e.pressed,
516            dragged: e.pressed,
517            disabled: e.disabled,
518        });
519        (cc, Some(bg), sc, se)
520    } else {
521        let cc = config.content_color.unwrap_or(def.content_color);
522        let bg = Some(config.container_color.unwrap_or(def.container_color));
523        let sc = if config.enabled {
524            config.state_colors
525        } else {
526            StateColors {
527                default: Color::TRANSPARENT,
528                hovered: Color::TRANSPARENT,
529                focused: Color::TRANSPARENT,
530                pressed: Color::TRANSPARENT,
531                dragged: Color::TRANSPARENT,
532                disabled: config.state_colors.disabled,
533            }
534        };
535        let se = config.state_elevation;
536        (cc, bg, sc, se)
537    }
538}