Skip to main content

repose_material/material3/
advbuttons.rs

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