Skip to main content

gpui_kit_material/components/button/
form.rs

1//! Button forms other than the labelled button, sharing [`MaterialButton`]'s
2//! Base activation, press/hover/focus tracking, state layer, ripple, focus
3//! ring and motion: icon buttons, FABs, extended FABs and split-button
4//! segments. Each form supplies only its geometry and, where its colors are
5//! not a [`ButtonVariant`]'s, a [`Palette`].
6//!
7//! Dimensions are the M3 Expressive tokens (m3e.kyralabs.dev; Figma
8//! "Material 3 Design Kit", Buttons page), in design px.
9use super::*;
10
11/// The width of an icon button relative to its height.
12#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
13pub enum IconButtonWidth {
14    Narrow,
15    #[default]
16    Default,
17    Wide,
18}
19
20impl IconButtonWidth {
21    pub const ALL: [Self; 3] = [Self::Narrow, Self::Default, Self::Wide];
22    pub const fn name(self) -> &'static str {
23        match self {
24            Self::Narrow => "narrow",
25            Self::Default => "default",
26            Self::Wide => "wide",
27        }
28    }
29}
30
31/// FAB container sizes. Extended FABs use the same three heights from
32/// `Regular` up; `Small` extends like `Regular`.
33#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
34pub enum FabSize {
35    /// 40dp (baseline small FAB).
36    Small,
37    /// 56dp.
38    #[default]
39    Regular,
40    /// 80dp (M3 Expressive medium FAB).
41    Medium,
42    /// 96dp.
43    Large,
44}
45
46impl FabSize {
47    pub const ALL: [Self; 4] = [Self::Small, Self::Regular, Self::Medium, Self::Large];
48    pub const fn name(self) -> &'static str {
49        match self {
50            Self::Small => "small",
51            Self::Regular => "regular",
52            Self::Medium => "medium",
53            Self::Large => "large",
54        }
55    }
56    /// Container side (and extended height).
57    pub(crate) const fn container(self) -> f32 {
58        match self {
59            Self::Small => 40.,
60            Self::Regular => 56.,
61            Self::Medium => 80.,
62            Self::Large => 96.,
63        }
64    }
65    pub(crate) const fn radius(self) -> f32 {
66        match self {
67            Self::Small => 12.,
68            Self::Regular => 16.,
69            Self::Medium => 20.,
70            Self::Large => 28.,
71        }
72    }
73    pub(crate) const fn icon(self) -> f32 {
74        match self {
75            Self::Small | Self::Regular => 24.,
76            Self::Medium => 28.,
77            Self::Large => 36.,
78        }
79    }
80    /// Extended: (height, horizontal padding, icon-label gap, label role).
81    pub(crate) const fn extended(self) -> (f32, f32, f32, TypeRole) {
82        match self {
83            Self::Small | Self::Regular => (56., 16., 8., TypeRole::TitleMedium),
84            Self::Medium => (80., 26., 12., TypeRole::TitleLarge),
85            Self::Large => (96., 28., 16., TypeRole::HeadlineSmall),
86        }
87    }
88    /// The extended form's height: `Small` extends like `Regular`.
89    pub(crate) const fn extended_size(self) -> Self {
90        match self {
91            Self::Small => Self::Regular,
92            other => other,
93        }
94    }
95}
96
97/// Which segment of a split button.
98#[derive(Clone, Copy, Debug, PartialEq, Eq)]
99pub(crate) enum Segment {
100    Leading,
101    Trailing,
102}
103
104#[derive(Clone, Copy, Debug, Default, PartialEq)]
105pub(crate) enum Form {
106    #[default]
107    Label,
108    Icon(IconButtonWidth),
109    Fab(FabSize),
110    /// `expansion` is the animated 0 (collapsed FAB) … 1 (extended) amount.
111    ExtendedFab {
112        size: FabSize,
113        expansion: f32,
114    },
115    Segment(Segment),
116    /// The FAB menu's toggle: a FAB of `size` that becomes the round close
117    /// button of the same size while `open`.
118    FabMenuToggle {
119        open: bool,
120        size: FabSize,
121    },
122    /// A FAB menu item at its animated 0 … 1 `expansion`.
123    FabMenuItem {
124        expansion: f32,
125    },
126}
127
128/// FAB menu item: height, horizontal padding, icon-label gap.
129pub(crate) const FAB_MENU_ITEM: (f32, f32, f32) = (56., 24., 8.);
130
131/// Container and content roles of one selection state.
132#[derive(Clone, Copy, Debug, PartialEq)]
133pub(crate) struct Roles {
134    pub container: Option<ColorRole>,
135    pub content: ColorRole,
136}
137
138/// Colors and elevation for forms whose colors are not a [`ButtonVariant`]'s
139/// (the standard icon button, FAB color styles, FAB menu parts).
140#[derive(Clone, Copy, Debug, PartialEq)]
141pub(crate) struct Palette {
142    pub rest: Roles,
143    /// Toggle-selected roles; `None` keeps `rest`.
144    pub selected: Option<Roles>,
145    /// Disabled specimens keep a translucent container.
146    pub disabled_container: bool,
147    /// (resting, hovered) elevation levels.
148    pub elevation: (u8, u8),
149}
150
151impl Palette {
152    pub(crate) fn targets(
153        &self,
154        disabled: bool,
155        toggle: bool,
156        selected: bool,
157    ) -> (CssColor, CssColor, CssColor) {
158        use ColorRole as C;
159        if disabled {
160            return (
161                if self.disabled_container {
162                    CssColor::alpha_role(C::ON_SURFACE, 0.10)
163                } else {
164                    CssColor::transparent()
165                },
166                CssColor::alpha_role(C::ON_SURFACE, state::DISABLED_CONTENT),
167                CssColor::transparent(),
168            );
169        }
170        let roles = match self.selected {
171            Some(roles) if toggle && selected => roles,
172            _ => self.rest,
173        };
174        (
175            roles
176                .container
177                .map_or_else(CssColor::transparent, CssColor::role),
178            CssColor::role(roles.content),
179            CssColor::transparent(),
180        )
181    }
182
183    pub(crate) fn shadow_level(&self, disabled: bool, hovered: bool, active: bool) -> u8 {
184        if disabled {
185            0
186        } else if hovered && !active {
187            self.elevation.1
188        } else {
189            self.elevation.0
190        }
191    }
192}
193
194/// Icon button tokens per size: (icon, narrow, default, wide widths).
195pub(crate) const fn icon_button_metrics(size: ButtonSize) -> (f32, [f32; 3]) {
196    match size {
197        ButtonSize::ExtraSmall => (20., [28., 32., 40.]),
198        ButtonSize::Small => (24., [32., 40., 52.]),
199        ButtonSize::Medium => (24., [48., 56., 72.]),
200        ButtonSize::Large => (32., [64., 96., 128.]),
201        ButtonSize::ExtraLarge => (40., [104., 136., 184.]),
202    }
203}
204
205pub(crate) const fn icon_button_width(size: ButtonSize, width: IconButtonWidth) -> f32 {
206    let (_, widths) = icon_button_metrics(size);
207    widths[match width {
208        IconButtonWidth::Narrow => 0,
209        IconButtonWidth::Default => 1,
210        IconButtonWidth::Wide => 2,
211    }]
212}
213
214/// Split-button segment tokens per size.
215#[derive(Clone, Copy, Debug, PartialEq)]
216pub(crate) struct SegmentMetrics {
217    /// Inner corners at rest (and focused) and hovered or pressed.
218    pub inner: f32,
219    pub inner_active: f32,
220    /// The leading segment's outer and inner padding.
221    pub leading_padding: (f32, f32),
222    /// The trailing segment's width, chevron size and (outer-side first)
223    /// padding while closed; open, the chevron is centered.
224    pub trailing_width: f32,
225    pub trailing_icon: f32,
226    pub trailing_padding: (f32, f32),
227}
228
229/// Figma "Material 3 Design Kit" split button tokens.
230pub(crate) const fn segment_metrics(size: ButtonSize) -> SegmentMetrics {
231    let (inner, inner_active, leading_padding, trailing_width, trailing_icon, trailing_padding) =
232        match size {
233            ButtonSize::ExtraSmall => (4., 8., (12., 10.), 48., 22., (14., 12.)),
234            ButtonSize::Small => (4., 12., (16., 12.), 48., 22., (14., 12.)),
235            ButtonSize::Medium => (4., 12., (24., 24.), 56., 26., (17., 13.)),
236            ButtonSize::Large => (8., 20., (48., 48.), 96., 38., (32., 26.)),
237            ButtonSize::ExtraLarge => (12., 20., (64., 64.), 136., 50., (49., 37.)),
238        };
239    SegmentMetrics {
240        inner,
241        inner_active,
242        leading_padding,
243        trailing_width,
244        trailing_icon,
245        trailing_padding,
246    }
247}
248
249/// Resolved box geometry of one render, in design px.
250#[derive(Clone, Copy, Debug, PartialEq)]
251pub(crate) struct FormGeometry {
252    pub height: f32,
253    pub width: Option<f32>,
254    pub min_width: f32,
255    pub padding: (f32, f32),
256    pub gap: f32,
257    pub icon: f32,
258    pub typography: TypeRole,
259    pub outline: f32,
260}
261
262impl Form {
263    /// Whether the resting/pressed radius comes from the button size table
264    /// (labelled and icon buttons) rather than the form.
265    pub(crate) fn sized_shape(self) -> bool {
266        matches!(self, Form::Label | Form::Icon(_))
267    }
268
269    pub(crate) fn geometry(self, size: ButtonSize, direction: ButtonDirection) -> FormGeometry {
270        let g = size.geometry();
271        let label = FormGeometry {
272            height: g.height,
273            width: None,
274            min_width: 58.,
275            padding: horizontal_padding(size),
276            gap: g.gap,
277            icon: size.icon_size(),
278            typography: g.typography,
279            outline: g.outline,
280        };
281        match self {
282            Form::Label => label,
283            Form::Icon(width) => FormGeometry {
284                width: Some(icon_button_width(size, width)),
285                min_width: 0.,
286                padding: (0., 0.),
287                gap: 0.,
288                icon: icon_button_metrics(size).0,
289                ..label
290            },
291            Form::Fab(fab) => FormGeometry {
292                height: fab.container(),
293                width: Some(fab.container()),
294                min_width: 0.,
295                padding: (0., 0.),
296                gap: 0.,
297                icon: fab.icon(),
298                typography: TypeRole::LabelLarge,
299                outline: 0.,
300            },
301            Form::ExtendedFab {
302                size: fab,
303                expansion,
304            } => {
305                let fab = fab.extended_size();
306                let (height, padding, gap, typography) = fab.extended();
307                let t = expansion.clamp(0., 1.);
308                // Collapsed, the icon sits centered in a square FAB.
309                let collapsed = (height - fab.icon()) / 2.;
310                let pad = collapsed + (padding - collapsed) * t;
311                FormGeometry {
312                    height,
313                    width: None,
314                    min_width: height,
315                    padding: (pad, pad),
316                    gap: gap * t,
317                    icon: fab.icon(),
318                    typography,
319                    outline: 0.,
320                }
321            }
322            Form::Segment(Segment::Leading) => {
323                let (start, inner) = segment_metrics(size).leading_padding;
324                FormGeometry {
325                    min_width: 0.,
326                    padding: if direction == ButtonDirection::Rtl {
327                        (inner, start)
328                    } else {
329                        (start, inner)
330                    },
331                    outline: 1.,
332                    ..label
333                }
334            }
335            Form::FabMenuToggle { size: fab, .. } => Form::Fab(fab).geometry(size, direction),
336            Form::FabMenuItem { expansion } => {
337                let (height, padding, gap) = FAB_MENU_ITEM;
338                let t = expansion.clamp(0., 1.);
339                let collapsed = (height - 24.) / 2.;
340                let pad = collapsed + (padding - collapsed) * t;
341                FormGeometry {
342                    height,
343                    width: None,
344                    min_width: height,
345                    padding: (pad, pad),
346                    gap: gap * t,
347                    icon: 24.,
348                    typography: TypeRole::TitleMedium,
349                    outline: 0.,
350                }
351            }
352            Form::Segment(Segment::Trailing) => {
353                let metrics = segment_metrics(size);
354                let (outer, inner) = metrics.trailing_padding;
355                FormGeometry {
356                    width: Some(metrics.trailing_width),
357                    min_width: 0.,
358                    // The closed chevron sits slightly toward the leading
359                    // segment; `MaterialButton` centers it while open.
360                    padding: if direction == ButtonDirection::Rtl {
361                        (outer, inner)
362                    } else {
363                        (inner, outer)
364                    },
365                    outline: 1.,
366                    gap: 0.,
367                    icon: metrics.trailing_icon,
368                    ..label
369                }
370            }
371        }
372    }
373
374    /// The resting radius of forms with their own shape.
375    pub(crate) fn fixed_radius(self) -> Option<f32> {
376        match self {
377            Form::Fab(fab) => Some(fab.radius()),
378            Form::FabMenuToggle { open: false, size } => Some(size.radius()),
379            Form::FabMenuToggle { open: true, size } => Some(size.container() / 2.),
380            Form::FabMenuItem { .. } => Some(FAB_MENU_ITEM.0 / 2.),
381            Form::ExtendedFab { size, .. } => Some(size.extended_size().radius()),
382            _ => None,
383        }
384    }
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390
391    #[core::prelude::v1::test]
392    fn icon_button_widths_follow_the_expressive_tokens() {
393        assert_eq!(
394            ButtonSize::ALL.map(|size| icon_button_width(size, IconButtonWidth::Default)),
395            [32., 40., 56., 96., 136.],
396            "default icon buttons are square"
397        );
398        assert_eq!(
399            icon_button_width(ButtonSize::Small, IconButtonWidth::Narrow),
400            32.
401        );
402        assert_eq!(
403            icon_button_width(ButtonSize::ExtraLarge, IconButtonWidth::Wide),
404            184.
405        );
406        let g =
407            Form::Icon(IconButtonWidth::Wide).geometry(ButtonSize::Medium, ButtonDirection::Ltr);
408        assert_eq!(
409            (g.height, g.width, g.padding, g.icon),
410            (56., Some(72.), (0., 0.), 24.)
411        );
412    }
413
414    #[core::prelude::v1::test]
415    fn fab_sizes_and_extended_expansion() {
416        assert_eq!(FabSize::ALL.map(FabSize::container), [40., 56., 80., 96.]);
417        assert_eq!(FabSize::ALL.map(FabSize::radius), [12., 16., 20., 28.]);
418        let geometry = |expansion| {
419            Form::ExtendedFab {
420                size: FabSize::Medium,
421                expansion,
422            }
423            .geometry(ButtonSize::Small, ButtonDirection::Ltr)
424        };
425        let collapsed = geometry(0.);
426        assert_eq!(
427            collapsed.padding,
428            (26., 26.),
429            "a centered 28dp icon in 80dp"
430        );
431        assert_eq!(collapsed.gap, 0.);
432        assert_eq!(collapsed.padding.0 * 2. + collapsed.icon, 80.);
433        let extended = geometry(1.);
434        assert_eq!((extended.padding, extended.gap), ((26., 26.), 12.));
435        assert_eq!(geometry(0.5).gap, 6.);
436    }
437
438    #[core::prelude::v1::test]
439    fn standard_palette_selects_primary_and_drops_container_when_disabled() {
440        let palette = Palette {
441            rest: Roles {
442                container: None,
443                content: ColorRole::ON_SURFACE_VARIANT,
444            },
445            selected: Some(Roles {
446                container: None,
447                content: ColorRole::PRIMARY,
448            }),
449            disabled_container: false,
450            elevation: (0, 0),
451        };
452        let resolve = |(bg, fg, _): (CssColor, CssColor, CssColor)| (bg.resolve(), fg.resolve());
453        let (bg, fg) = resolve(palette.targets(false, true, true));
454        assert_eq!(bg.a, 0.);
455        assert_eq!(fg, crate::theme::color(ColorRole::PRIMARY));
456        let (_, fg) = resolve(palette.targets(false, false, true));
457        assert_eq!(
458            fg,
459            crate::theme::color(ColorRole::ON_SURFACE_VARIANT),
460            "selection needs toggle semantics"
461        );
462        let (bg, fg) = resolve(palette.targets(true, true, true));
463        assert_eq!(bg.a, 0.);
464        let ink = crate::theme::color(ColorRole::ON_SURFACE);
465        assert!((fg.a - ink.a * state::DISABLED_CONTENT).abs() < 1e-4);
466    }
467
468    #[core::prelude::v1::test]
469    fn fab_elevation_rises_on_hover_only() {
470        let palette = Palette {
471            rest: Roles {
472                container: Some(ColorRole::PRIMARY_CONTAINER),
473                content: ColorRole::ON_PRIMARY_CONTAINER,
474            },
475            selected: None,
476            disabled_container: true,
477            elevation: (3, 4),
478        };
479        assert_eq!(palette.shadow_level(false, false, false), 3);
480        assert_eq!(palette.shadow_level(false, true, false), 4);
481        assert_eq!(palette.shadow_level(false, true, true), 3);
482        assert_eq!(palette.shadow_level(true, true, false), 0);
483    }
484
485    #[core::prelude::v1::test]
486    fn split_segments_share_the_button_height() {
487        for size in ButtonSize::ALL {
488            let leading = Form::Segment(Segment::Leading).geometry(size, ButtonDirection::Ltr);
489            let trailing = Form::Segment(Segment::Trailing).geometry(size, ButtonDirection::Ltr);
490            assert_eq!(leading.height, trailing.height);
491            assert_eq!(leading.padding, segment_metrics(size).leading_padding);
492            assert_eq!((leading.outline, trailing.outline), (1., 1.));
493            let m = segment_metrics(size);
494            assert!(m.inner < m.inner_active);
495            assert_eq!(
496                trailing.padding.0 + trailing.padding.1 + trailing.icon,
497                m.trailing_width,
498                "the chevron fills its token box"
499            );
500        }
501    }
502}