Add support for optional icon to `Button` (#3479)

Marshall Bowers created

This PR extends `Button` with support for an optional icon to be
displayed next to the label.

As part of this, the functionality for displaying an icon within a
button has been factored out into an internal `ButtonIcon` component.
`ButtonIcon` is now used by both `IconButton` and `Button` to
encapsulate the concerns of an icon that is rendered within a button.

Release Notes:

- N/A

Change summary

crates/ui2/src/components/button.rs             |  1 
crates/ui2/src/components/button/button.rs      | 62 +++++++++++--
crates/ui2/src/components/button/button_icon.rs | 84 ++++++++++++++++++
crates/ui2/src/components/button/icon_button.rs | 25 ++---
crates/ui2/src/components/stories/button.rs     | 10 ++
5 files changed, 157 insertions(+), 25 deletions(-)

Detailed changes

crates/ui2/src/components/button/button.rs 🔗

@@ -1,7 +1,11 @@
 use gpui::AnyView;
 
 use crate::prelude::*;
-use crate::{ButtonCommon, ButtonLike, ButtonSize, ButtonStyle, Label, LineHeightStyle};
+use crate::{
+    ButtonCommon, ButtonLike, ButtonSize, ButtonStyle, Icon, IconSize, Label, LineHeightStyle,
+};
+
+use super::button_icon::ButtonIcon;
 
 #[derive(IntoElement)]
 pub struct Button {
@@ -9,6 +13,10 @@ pub struct Button {
     label: SharedString,
     label_color: Option<Color>,
     selected_label: Option<SharedString>,
+    icon: Option<Icon>,
+    icon_size: Option<IconSize>,
+    icon_color: Option<Color>,
+    selected_icon: Option<Icon>,
 }
 
 impl Button {
@@ -18,6 +26,10 @@ impl Button {
             label: label.into(),
             label_color: None,
             selected_label: None,
+            icon: None,
+            icon_size: None,
+            icon_color: None,
+            selected_icon: None,
         }
     }
 
@@ -30,6 +42,26 @@ impl Button {
         self.selected_label = label.into().map(Into::into);
         self
     }
+
+    pub fn icon(mut self, icon: impl Into<Option<Icon>>) -> Self {
+        self.icon = icon.into();
+        self
+    }
+
+    pub fn icon_size(mut self, icon_size: impl Into<Option<IconSize>>) -> Self {
+        self.icon_size = icon_size.into();
+        self
+    }
+
+    pub fn icon_color(mut self, icon_color: impl Into<Option<Color>>) -> Self {
+        self.icon_color = icon_color.into();
+        self
+    }
+
+    pub fn selected_icon(mut self, icon: impl Into<Option<Icon>>) -> Self {
+        self.selected_icon = icon.into();
+        self
+    }
 }
 
 impl Selectable for Button {
@@ -81,23 +113,35 @@ impl RenderOnce for Button {
     type Rendered = ButtonLike;
 
     fn render(self, _cx: &mut WindowContext) -> Self::Rendered {
+        let is_disabled = self.base.disabled;
+        let is_selected = self.base.selected;
+
         let label = self
             .selected_label
-            .filter(|_| self.base.selected)
+            .filter(|_| is_selected)
             .unwrap_or(self.label);
 
-        let label_color = if self.base.disabled {
+        let label_color = if is_disabled {
             Color::Disabled
-        } else if self.base.selected {
+        } else if is_selected {
             Color::Selected
         } else {
             self.label_color.unwrap_or_default()
         };
 
-        self.base.child(
-            Label::new(label)
-                .color(label_color)
-                .line_height_style(LineHeightStyle::UILabel),
-        )
+        self.base
+            .children(self.icon.map(|icon| {
+                ButtonIcon::new(icon)
+                    .disabled(is_disabled)
+                    .selected(is_selected)
+                    .selected_icon(self.selected_icon)
+                    .size(self.icon_size)
+                    .color(self.icon_color)
+            }))
+            .child(
+                Label::new(label)
+                    .color(label_color)
+                    .line_height_style(LineHeightStyle::UILabel),
+            )
     }
 }

crates/ui2/src/components/button/button_icon.rs 🔗

@@ -0,0 +1,84 @@
+use crate::{prelude::*, Icon, IconElement, IconSize};
+
+/// An icon that appears within a button.
+///
+/// Can be used as either an icon alongside a label, like in [`Button`](crate::Button),
+/// or as a standalone icon, like in [`IconButton`](crate::IconButton).
+#[derive(IntoElement)]
+pub(super) struct ButtonIcon {
+    icon: Icon,
+    size: IconSize,
+    color: Color,
+    disabled: bool,
+    selected: bool,
+    selected_icon: Option<Icon>,
+}
+
+impl ButtonIcon {
+    pub fn new(icon: Icon) -> Self {
+        Self {
+            icon,
+            size: IconSize::default(),
+            color: Color::default(),
+            disabled: false,
+            selected: false,
+            selected_icon: None,
+        }
+    }
+
+    pub fn size(mut self, size: impl Into<Option<IconSize>>) -> Self {
+        if let Some(size) = size.into() {
+            self.size = size;
+        }
+
+        self
+    }
+
+    pub fn color(mut self, color: impl Into<Option<Color>>) -> Self {
+        if let Some(color) = color.into() {
+            self.color = color;
+        }
+
+        self
+    }
+
+    pub fn selected_icon(mut self, icon: impl Into<Option<Icon>>) -> Self {
+        self.selected_icon = icon.into();
+        self
+    }
+}
+
+impl Disableable for ButtonIcon {
+    fn disabled(mut self, disabled: bool) -> Self {
+        self.disabled = disabled;
+        self
+    }
+}
+
+impl Selectable for ButtonIcon {
+    fn selected(mut self, selected: bool) -> Self {
+        self.selected = selected;
+        self
+    }
+}
+
+impl RenderOnce for ButtonIcon {
+    type Rendered = IconElement;
+
+    fn render(self, _cx: &mut WindowContext) -> Self::Rendered {
+        let icon = self
+            .selected_icon
+            .filter(|_| self.selected)
+            .unwrap_or(self.icon);
+
+        let icon_color = if self.disabled {
+            Color::Disabled
+        } else if self.selected {
+            Color::Selected
+        } else {
+            self.color
+        };
+
+        IconElement::new(icon).size(self.size).color(icon_color)
+    }
+}

crates/ui2/src/components/button/icon_button.rs 🔗

@@ -1,7 +1,9 @@
 use gpui::{Action, AnyView};
 
 use crate::prelude::*;
-use crate::{ButtonCommon, ButtonLike, ButtonSize, ButtonStyle, Icon, IconElement, IconSize};
+use crate::{ButtonCommon, ButtonLike, ButtonSize, ButtonStyle, Icon, IconSize};
+
+use super::button_icon::ButtonIcon;
 
 #[derive(IntoElement)]
 pub struct IconButton {
@@ -92,23 +94,16 @@ impl RenderOnce for IconButton {
     type Rendered = ButtonLike;
 
     fn render(self, _cx: &mut WindowContext) -> Self::Rendered {
-        let icon = self
-            .selected_icon
-            .filter(|_| self.base.selected)
-            .unwrap_or(self.icon);
-
-        let icon_color = if self.base.disabled {
-            Color::Disabled
-        } else if self.base.selected {
-            Color::Selected
-        } else {
-            self.icon_color
-        };
+        let is_disabled = self.base.disabled;
+        let is_selected = self.base.selected;
 
         self.base.child(
-            IconElement::new(icon)
+            ButtonIcon::new(self.icon)
+                .disabled(is_disabled)
+                .selected(is_selected)
+                .selected_icon(self.selected_icon)
                 .size(self.icon_size)
-                .color(icon_color),
+                .color(self.icon_color),
         )
     }
 }

crates/ui2/src/components/stories/button.rs 🔗

@@ -1,7 +1,7 @@
 use gpui::{Div, Render};
 use story::Story;
 
-use crate::prelude::*;
+use crate::{prelude::*, Icon};
 use crate::{Button, ButtonStyle};
 
 pub struct ButtonStory;
@@ -24,6 +24,14 @@ impl Render for ButtonStory {
             )
             .child(Story::label("With `label_color`"))
             .child(Button::new("filled_with_label_color", "Click me").color(Color::Created))
+            .child(Story::label("With `icon`"))
+            .child(Button::new("filled_with_icon", "Click me").icon(Icon::FileGit))
+            .child(Story::label("Selected with `icon`"))
+            .child(
+                Button::new("filled_and_selected_with_icon", "Click me")
+                    .selected(true)
+                    .icon(Icon::FileGit),
+            )
             .child(Story::label("Default (Subtle)"))
             .child(Button::new("default_subtle", "Click me").style(ButtonStyle::Subtle))
             .child(Story::label("Default (Transparent)"))