Skip to main content

gpui_kit_material/components/
icon_button.rs

1//! M3 Expressive icon button: standard, filled, tonal and outlined; XS–XL;
2//! narrow, default and wide; round or square; optional toggle selection.
3//!
4//! The button is a [`MaterialButton`] in its icon form, so activation,
5//! focus, keyboard, toggle semantics, the state layer, ripple, focus ring,
6//! pressed-shape morph and selected-shape morph (round ⇄ square on the fast
7//! spatial spring) are the labelled button's. Filled, tonal and outlined use
8//! the button variants' toggle colors; the standard icon button has no
9//! container and turns primary when selected.
10use super::{
11    ButtonShape, ButtonSize, ButtonVariant, IconButtonWidth, InteractionPreview, MaterialButton,
12    button::{Form, Palette, Roles},
13};
14use crate::{color::ColorRole, icons::Icon};
15use gpui::{prelude::*, *};
16
17/// Icon button color styles.
18#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
19pub enum IconButtonVariant {
20    /// No container: on-surface-variant, primary when selected.
21    #[default]
22    Standard,
23    Filled,
24    Tonal,
25    Outlined,
26}
27
28impl IconButtonVariant {
29    pub const ALL: [Self; 4] = [Self::Standard, Self::Filled, Self::Tonal, Self::Outlined];
30    pub const fn name(self) -> &'static str {
31        match self {
32            Self::Standard => "standard",
33            Self::Filled => "filled",
34            Self::Tonal => "tonal",
35            Self::Outlined => "outlined",
36        }
37    }
38    /// The button variant whose colors this style shares; the standard
39    /// style paints its own palette.
40    fn button_variant(self) -> ButtonVariant {
41        match self {
42            Self::Standard => ButtonVariant::Text,
43            Self::Filled => ButtonVariant::Filled,
44            Self::Tonal => ButtonVariant::Tonal,
45            Self::Outlined => ButtonVariant::Outlined,
46        }
47    }
48}
49
50const STANDARD: Palette = Palette {
51    rest: Roles {
52        container: None,
53        content: ColorRole::ON_SURFACE_VARIANT,
54    },
55    selected: Some(Roles {
56        container: None,
57        content: ColorRole::PRIMARY,
58    }),
59    disabled_container: false,
60    elevation: (0, 0),
61};
62
63type ChangeHandler = Box<dyn Fn(bool, &ClickEvent, &mut Window, &mut App)>;
64type ClickHandler = Box<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
65
66/// An icon-only button. `label` names it for assistive technology.
67#[derive(gpui::IntoElement)]
68pub struct IconButton {
69    id: ElementId,
70    icon: Icon,
71    selected_icon: Option<Icon>,
72    label: SharedString,
73    variant: IconButtonVariant,
74    size: ButtonSize,
75    width: IconButtonWidth,
76    shape: ButtonShape,
77    toggle: bool,
78    selected: bool,
79    disabled: bool,
80    preview: Option<InteractionPreview>,
81    focus: Option<FocusHandle>,
82    tab_stop: Option<bool>,
83    on_click: Option<ClickHandler>,
84    on_change: Option<ChangeHandler>,
85    style: StyleRefinement,
86}
87
88impl IconButton {
89    pub fn new(
90        id: impl Into<ElementId>,
91        icon: impl Into<Icon>,
92        label: impl Into<SharedString>,
93    ) -> Self {
94        Self {
95            id: id.into(),
96            icon: icon.into(),
97            selected_icon: None,
98            label: label.into(),
99            variant: IconButtonVariant::Standard,
100            size: ButtonSize::Small,
101            width: IconButtonWidth::Default,
102            shape: ButtonShape::Round,
103            toggle: false,
104            selected: false,
105            disabled: false,
106            preview: None,
107            focus: None,
108            tab_stop: None,
109            on_click: None,
110            on_change: None,
111            style: StyleRefinement::default(),
112        }
113    }
114    pub fn variant(mut self, variant: IconButtonVariant) -> Self {
115        self.variant = variant;
116        self
117    }
118    pub fn button_size(mut self, size: ButtonSize) -> Self {
119        self.size = size;
120        self
121    }
122    pub fn width(mut self, width: IconButtonWidth) -> Self {
123        self.width = width;
124        self
125    }
126    pub fn shape(mut self, shape: ButtonShape) -> Self {
127        self.shape = shape;
128        self
129    }
130    /// Controlled toggle semantics (a pressed/unpressed toggle button).
131    pub fn toggle(mut self, toggle: bool) -> Self {
132        self.toggle = toggle;
133        self
134    }
135    pub fn selected(mut self, selected: bool) -> Self {
136        self.selected = selected;
137        self
138    }
139    /// The icon shown while selected, typically the filled symbol.
140    pub fn selected_icon(mut self, icon: impl Into<Icon>) -> Self {
141        self.selected_icon = Some(icon.into());
142        self
143    }
144    pub fn disabled(mut self, disabled: bool) -> Self {
145        self.disabled = disabled;
146        self
147    }
148    /// Render as a static specimen in one interaction state.
149    pub fn preview_state(mut self, state: InteractionPreview) -> Self {
150        self.preview = Some(state);
151        self
152    }
153    pub fn track_focus(mut self, focus: &FocusHandle) -> Self {
154        self.focus = Some(focus.clone());
155        self
156    }
157    pub fn tab_stop(mut self, stop: bool) -> Self {
158        self.tab_stop = Some(stop);
159        self
160    }
161    pub fn on_click(
162        mut self,
163        callback: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
164    ) -> Self {
165        self.on_click = Some(Box::new(callback));
166        self
167    }
168    /// The requested selection of a toggle icon button.
169    pub fn on_change(
170        mut self,
171        callback: impl Fn(bool, &ClickEvent, &mut Window, &mut App) + 'static,
172    ) -> Self {
173        self.on_change = Some(Box::new(callback));
174        self
175    }
176
177    pub(crate) fn into_button(self) -> MaterialButton {
178        let selected = self.toggle && self.selected;
179        let icon = match self.selected_icon {
180            Some(icon) if selected => icon,
181            _ => self.icon,
182        };
183        let mut button = MaterialButton::new(self.id)
184            .variant(self.variant.button_variant())
185            .form(Form::Icon(self.width))
186            .button_size(self.size)
187            .shape(self.shape)
188            .icon(icon)
189            .accessibility_label(self.label)
190            .toggle(self.toggle)
191            .selected(self.selected)
192            .disabled(self.disabled);
193        if self.variant == IconButtonVariant::Standard {
194            button = button.palette(STANDARD);
195        }
196        if let Some(preview) = self.preview {
197            button = button.preview_state(preview);
198        }
199        if let Some(focus) = &self.focus {
200            button = button.track_focus(focus);
201        }
202        if let Some(stop) = self.tab_stop {
203            button = button.tab_stop(stop);
204        }
205        if let Some(callback) = self.on_click {
206            button = button.on_click(callback);
207        }
208        if let Some(callback) = self.on_change {
209            button = button.on_change(callback);
210        }
211        *button.style() = self.style;
212        button
213    }
214}
215
216impl From<IconButton> for MaterialButton {
217    fn from(button: IconButton) -> Self {
218        button.into_button()
219    }
220}
221
222impl Styled for IconButton {
223    fn style(&mut self) -> &mut StyleRefinement {
224        &mut self.style
225    }
226}
227
228impl RenderOnce for IconButton {
229    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
230        self.into_button()
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::{
237        ButtonSize, ButtonVariant, IconButton, IconButtonVariant, IconButtonWidth, STANDARD,
238    };
239    use crate::{color::ColorRole, icons::IconName};
240    use gpui::{
241        Context, InteractiveElement as _, IntoElement, ParentElement as _, Render, Styled as _,
242        TestAppContext, Window, div, px,
243    };
244
245    struct Harness {
246        variant: IconButtonVariant,
247        size: ButtonSize,
248        width: IconButtonWidth,
249    }
250    impl Render for Harness {
251        fn render(&mut self, window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
252            window.set_rem_size(px(14.));
253            div().flex().items_start().p(px(40.)).child(
254                div()
255                    .flex()
256                    .debug_selector(|| "icon-button-slot".into())
257                    .child(
258                        IconButton::new("icon", IconName::Check, "Check")
259                            .variant(self.variant)
260                            .button_size(self.size)
261                            .width(self.width),
262                    ),
263            )
264        }
265    }
266
267    #[gpui::test]
268    fn icon_buttons_take_their_token_box(cx: &mut TestAppContext) {
269        cx.update(|cx| {
270            gpui_base::init(cx);
271            cx.set_reduce_motion(true);
272        });
273        for (size, width, expected) in [
274            (ButtonSize::ExtraSmall, IconButtonWidth::Narrow, (28., 32.)),
275            (ButtonSize::Small, IconButtonWidth::Default, (40., 40.)),
276            (ButtonSize::Medium, IconButtonWidth::Wide, (72., 56.)),
277            (ButtonSize::ExtraLarge, IconButtonWidth::Wide, (184., 136.)),
278        ] {
279            let (_, cx) = cx.add_window_view(|_, _| Harness {
280                variant: IconButtonVariant::Outlined,
281                size,
282                width,
283            });
284            cx.run_until_parked();
285            let bounds = cx
286                .debug_bounds("icon-button-slot")
287                .expect("the icon button renders");
288            assert_eq!(
289                (
290                    f32::from(bounds.size.width).round(),
291                    f32::from(bounds.size.height).round()
292                ),
293                expected,
294                "{size:?} {width:?}"
295            );
296        }
297    }
298
299    #[core::prelude::v1::test]
300    fn standard_style_has_no_container_and_selects_primary() {
301        assert_eq!(STANDARD.rest.container, None);
302        assert_eq!(STANDARD.selected.unwrap().content, ColorRole::PRIMARY);
303        assert_eq!(
304            IconButtonVariant::Filled.button_variant(),
305            ButtonVariant::Filled
306        );
307    }
308}