Skip to main content

repose_material/material3/
advbuttons.rs

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