Add new `DecoratedIcon` component (#20516)

Danilo Leal and Nate Butler created

This PR creates a new, revamped `DecoratedIcon` component that enables
using different SVGs, one for the knockout background and another for
the actual icon. That's different than what we were doing beforeβ€”copying
the SVG and using slightly different positioningβ€”because we wanted to
unlock an aligned knockout effect, which was particularly hard to do
with non-simple shapes such as an X.

Release Notes:

- N/A

---------

Co-authored-by: Nate Butler <1714999+iamnbutler@users.noreply.github.com>

Change summary

assets/icons/knockouts/dot_bg.svg         |   3 
assets/icons/knockouts/dot_fg.svg         |   3 
assets/icons/knockouts/triangle_bg.svg    |   3 
assets/icons/knockouts/triangle_fg.svg    |   3 
assets/icons/knockouts/x_bg.svg           |  10 
assets/icons/knockouts/x_fg.svg           |   3 
crates/ui/src/components/button/button.rs |   2 
crates/ui/src/components/checkbox.rs      |   4 
crates/ui/src/components/facepile.rs      |   2 
crates/ui/src/components/icon.rs          | 269 +++++++++++++++++++-----
crates/ui/src/components/indicator.rs     |   2 
crates/ui/src/components/stories/icon.rs  |  18 -
crates/ui/src/components/table.rs         |   2 
crates/ui/src/traits/component_preview.rs |   8 
crates/workspace/src/theme_preview.rs     |   5 
15 files changed, 247 insertions(+), 90 deletions(-)

Detailed changes

assets/icons/knockouts/dot_bg.svg πŸ”—

@@ -0,0 +1,3 @@
+<svg width="11" height="11" viewBox="0 0 11 11" fill="none" xmlns="http://www.w3.org/2000/svg">
+<rect x="2.1" y="2.1" width="6.8" height="6.8" rx="3.4" stroke="black" stroke-width="1.8"/>
+</svg>

assets/icons/knockouts/dot_fg.svg πŸ”—

@@ -0,0 +1,3 @@
+<svg width="11" height="11" viewBox="0 0 11 11" fill="none" xmlns="http://www.w3.org/2000/svg">
+<rect x="3" y="3" width="5" height="5" rx="2.5" fill="black"/>
+</svg>

assets/icons/knockouts/triangle_bg.svg πŸ”—

@@ -0,0 +1,3 @@
+<svg width="11" height="11" viewBox="0 0 11 11" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M2.364 7.6025L1.64681 8.75H3H8H9.35319L8.636 7.6025L6.136 3.6025L5.5 2.5849L4.864 3.6025L2.364 7.6025Z" stroke="black" stroke-width="1.5"/>
+</svg>

assets/icons/knockouts/x_bg.svg πŸ”—

@@ -0,0 +1,10 @@
+<svg width="11" height="11" viewBox="0 0 11 11" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g clip-path="url(#clip0_2050_903)">
+<path d="M1.83327 2.89393L1.19687 3.53033L1.83327 4.16672L3.16654 5.5L1.83327 6.83327L1.19687 7.46967L1.83327 8.10606L2.89393 9.16672L3.53033 9.80312L4.16672 9.16672L5.5 7.83345L6.83327 9.16672L7.46967 9.80312L8.10606 9.16672L9.16672 8.10606L9.80312 7.46967L9.16672 6.83327L7.83345 5.5L9.16672 4.16672L9.80312 3.53033L9.16672 2.89393L8.10606 1.83327L7.46967 1.19687L6.83327 1.83327L5.5 3.16654L4.16672 1.83327L3.53033 1.19687L2.89393 1.83327L1.83327 2.89393Z" stroke="black" stroke-width="1.8"/>
+</g>
+<defs>
+<clipPath id="clip0_2050_903">
+<rect width="11" height="11" fill="white"/>
+</clipPath>
+</defs>
+</svg>

assets/icons/knockouts/x_fg.svg πŸ”—

@@ -0,0 +1,3 @@
+<svg width="11" height="11" viewBox="0 0 11 11" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M3 3L5.5 5.5M8 8L5.5 5.5M5.5 5.5L3 8M5.5 5.5L8 3" stroke="black" stroke-width="1.5"/>
+</svg>

crates/ui/src/components/button/button.rs πŸ”—

@@ -445,7 +445,7 @@ impl ComponentPreview for Button {
         "A button allows users to take actions, and make choices, with a single tap."
     }
 
-    fn examples() -> Vec<ComponentExampleGroup<Self>> {
+    fn examples(_: &WindowContext) -> Vec<ComponentExampleGroup<Self>> {
         vec![
             example_group_with_title(
                 "Styles",

crates/ui/src/components/checkbox.rs πŸ”—

@@ -118,7 +118,7 @@ impl ComponentPreview for Checkbox {
         "A checkbox lets people choose between a pair of opposing states, like enabled and disabled, using a different appearance to indicate each state."
     }
 
-    fn examples() -> Vec<ComponentExampleGroup<Self>> {
+    fn examples(_: &WindowContext) -> Vec<ComponentExampleGroup<Self>> {
         vec![
             example_group_with_title(
                 "Default",
@@ -214,7 +214,7 @@ impl ComponentPreview for CheckboxWithLabel {
         "A checkbox with an associated label, allowing users to select an option while providing a descriptive text."
     }
 
-    fn examples() -> Vec<ComponentExampleGroup<Self>> {
+    fn examples(_: &WindowContext) -> Vec<ComponentExampleGroup<Self>> {
         vec![example_group(vec![
             single_example(
                 "Unselected",

crates/ui/src/components/facepile.rs πŸ”—

@@ -67,7 +67,7 @@ impl ComponentPreview for Facepile {
         \n\nFacepiles are used to display a group of people or things,\
         such as a list of participants in a collaboration session."
     }
-    fn examples() -> Vec<ComponentExampleGroup<Self>> {
+    fn examples(_: &WindowContext) -> Vec<ComponentExampleGroup<Self>> {
         let few_faces: [&'static str; 3] = [
             "https://avatars.githubusercontent.com/u/1714999?s=60&v=4",
             "https://avatars.githubusercontent.com/u/67129314?s=60&v=4",

crates/ui/src/components/icon.rs πŸ”—

@@ -1,7 +1,7 @@
 #![allow(missing_docs)]
-use gpui::{svg, AnimationElement, Hsla, IntoElement, Rems, Transformation};
+use gpui::{svg, AnimationElement, Hsla, IntoElement, Point, Rems, Transformation};
 use serde::{Deserialize, Serialize};
-use strum::{EnumIter, EnumString, IntoStaticStr};
+use strum::{EnumIter, EnumString, IntoEnumIterator, IntoStaticStr};
 use ui_macros::DerivePathStr;
 
 use crate::{
@@ -48,17 +48,6 @@ impl RenderOnce for AnyIcon {
     }
 }
 
-/// The decoration for an icon.
-///
-/// For example, this can show an indicator, an "x",
-/// or a diagonal strikethrough to indicate something is disabled.
-#[derive(Debug, PartialEq, Copy, Clone, EnumIter)]
-pub enum IconDecoration {
-    Strikethrough,
-    IndicatorDot,
-    X,
-}
-
 #[derive(Default, PartialEq, Copy, Clone)]
 pub enum IconSize {
     /// 10px
@@ -367,77 +356,233 @@ impl RenderOnce for Icon {
     }
 }
 
+const ICON_DECORATION_SIZE: f32 = 11.0;
+
+/// An icon silhouette used to knockout the background of an element
+/// for an icon to sit on top of it, emulating a stroke/border.
+#[derive(Debug, PartialEq, Eq, Copy, Clone, EnumIter, EnumString, IntoStaticStr, DerivePathStr)]
+#[strum(serialize_all = "snake_case")]
+#[path_str(prefix = "icons/knockouts", suffix = ".svg")]
+pub enum KnockoutIconName {
+    // /icons/knockouts/x1.svg
+    XFg,
+    XBg,
+    DotFg,
+    DotBg,
+    TriangleFg,
+    TriangleBg,
+}
+
+#[derive(Debug, PartialEq, Eq, Copy, Clone, EnumIter, EnumString)]
+pub enum IconDecorationKind {
+    // Slash,
+    X,
+    Dot,
+    Triangle,
+}
+
+impl IconDecorationKind {
+    fn fg(&self) -> KnockoutIconName {
+        match self {
+            Self::X => KnockoutIconName::XFg,
+            Self::Dot => KnockoutIconName::DotFg,
+            Self::Triangle => KnockoutIconName::TriangleFg,
+        }
+    }
+
+    fn bg(&self) -> KnockoutIconName {
+        match self {
+            Self::X => KnockoutIconName::XBg,
+            Self::Dot => KnockoutIconName::DotBg,
+            Self::Triangle => KnockoutIconName::TriangleBg,
+        }
+    }
+}
+
+/// The decoration for an icon.
+///
+/// For example, this can show an indicator, an "x",
+/// or a diagonal strikethrough to indicate something is disabled.
 #[derive(IntoElement)]
-pub struct DecoratedIcon {
-    icon: Icon,
-    decoration: IconDecoration,
-    decoration_color: Color,
-    parent_background: Option<Hsla>,
+pub struct IconDecoration {
+    kind: IconDecorationKind,
+    color: Hsla,
+    knockout_color: Hsla,
+    position: Point<Pixels>,
 }
 
-impl DecoratedIcon {
-    pub fn new(icon: Icon, decoration: IconDecoration) -> Self {
+impl IconDecoration {
+    /// Create a new icon decoration
+    pub fn new(kind: IconDecorationKind, knockout_color: Hsla, cx: &WindowContext) -> Self {
+        let color = cx.theme().colors().icon;
+        let position = Point::default();
+
         Self {
-            icon,
-            decoration,
-            decoration_color: Color::Default,
-            parent_background: None,
+            kind,
+            color,
+            knockout_color,
+            position,
         }
     }
 
-    pub fn decoration_color(mut self, color: Color) -> Self {
-        self.decoration_color = color;
+    /// Sets the kind of decoration
+    pub fn kind(mut self, kind: IconDecorationKind) -> Self {
+        self.kind = kind;
         self
     }
 
-    pub fn parent_background(mut self, background: Option<Hsla>) -> Self {
-        self.parent_background = background;
+    /// Sets the color of the decoration
+    pub fn color(mut self, color: Hsla) -> Self {
+        self.color = color;
+        self
+    }
+
+    /// Sets the color of the decoration's knockout
+    ///
+    /// Match this to the background of the element
+    /// the icon will be rendered on
+    pub fn knockout_color(mut self, color: Hsla) -> Self {
+        self.knockout_color = color;
+        self
+    }
+
+    /// Sets the position of the decoration
+    pub fn position(mut self, position: Point<Pixels>) -> Self {
+        self.position = position;
         self
     }
 }
 
-impl RenderOnce for DecoratedIcon {
-    fn render(self, cx: &mut WindowContext) -> impl IntoElement {
-        let background = self
-            .parent_background
-            .unwrap_or(cx.theme().colors().background);
+impl RenderOnce for IconDecoration {
+    fn render(self, _cx: &mut WindowContext) -> impl IntoElement {
+        div()
+            .size(px(ICON_DECORATION_SIZE))
+            .flex_none()
+            .absolute()
+            .bottom(self.position.y)
+            .right(self.position.x)
+            .child(
+                // foreground
+                svg()
+                    .absolute()
+                    .bottom_0()
+                    .right_0()
+                    .size(px(ICON_DECORATION_SIZE))
+                    .path(self.kind.fg().path())
+                    .text_color(self.color),
+            )
+            .child(
+                // background
+                svg()
+                    .absolute()
+                    .bottom_0()
+                    .right_0()
+                    .size(px(ICON_DECORATION_SIZE))
+                    .path(self.kind.bg().path())
+                    .text_color(self.knockout_color),
+            )
+    }
+}
 
-        let size = self.icon.size;
+impl ComponentPreview for IconDecoration {
+    fn examples(cx: &WindowContext) -> Vec<ComponentExampleGroup<Self>> {
+        let all_kinds = IconDecorationKind::iter().collect::<Vec<_>>();
 
-        let decoration_icon = match self.decoration {
-            IconDecoration::Strikethrough => IconName::Strikethrough,
-            IconDecoration::IndicatorDot => IconName::Indicator,
-            IconDecoration::X => IconName::IndicatorX,
-        };
+        let examples = all_kinds
+            .iter()
+            .map(|kind| {
+                let name = format!("{:?}", kind).to_string();
 
-        let decoration_svg = |icon: IconName| {
-            svg()
-                .absolute()
-                .top_0()
-                .left_0()
-                .path(icon.path())
-                .size(size)
-                .flex_none()
-                .text_color(self.decoration_color.color(cx))
-        };
+                single_example(
+                    name,
+                    IconDecoration::new(*kind, cx.theme().colors().surface_background, cx),
+                )
+            })
+            .collect();
 
-        let decoration_knockout = |icon: IconName| {
-            svg()
-                .absolute()
-                .top(-rems_from_px(2.))
-                .left(-rems_from_px(3.))
-                .path(icon.path())
-                .size(size + rems_from_px(2.))
-                .flex_none()
-                .text_color(background)
-        };
+        vec![example_group(examples)]
+    }
+}
 
+#[derive(IntoElement)]
+pub struct DecoratedIcon {
+    icon: Icon,
+    decoration: Option<IconDecoration>,
+}
+
+impl DecoratedIcon {
+    pub fn new(icon: Icon, decoration: Option<IconDecoration>) -> Self {
+        Self { icon, decoration }
+    }
+}
+
+impl RenderOnce for DecoratedIcon {
+    fn render(self, _cx: &mut WindowContext) -> impl IntoElement {
         div()
             .relative()
             .size(self.icon.size)
             .child(self.icon)
-            .child(decoration_knockout(decoration_icon))
-            .child(decoration_svg(decoration_icon))
+            .when_some(self.decoration, |this, decoration| this.child(decoration))
+    }
+}
+
+impl ComponentPreview for DecoratedIcon {
+    fn examples(cx: &WindowContext) -> Vec<ComponentExampleGroup<Self>> {
+        let icon_1 = Icon::new(IconName::FileDoc);
+        let icon_2 = Icon::new(IconName::FileDoc);
+        let icon_3 = Icon::new(IconName::FileDoc);
+        let icon_4 = Icon::new(IconName::FileDoc);
+
+        let decoration_x = IconDecoration::new(
+            IconDecorationKind::X,
+            cx.theme().colors().surface_background,
+            cx,
+        )
+        .color(cx.theme().status().error)
+        .position(Point {
+            x: px(-2.),
+            y: px(-2.),
+        });
+
+        let decoration_triangle = IconDecoration::new(
+            IconDecorationKind::Triangle,
+            cx.theme().colors().surface_background,
+            cx,
+        )
+        .color(cx.theme().status().error)
+        .position(Point {
+            x: px(-2.),
+            y: px(-2.),
+        });
+
+        let decoration_dot = IconDecoration::new(
+            IconDecorationKind::Dot,
+            cx.theme().colors().surface_background,
+            cx,
+        )
+        .color(cx.theme().status().error)
+        .position(Point {
+            x: px(-2.),
+            y: px(-2.),
+        });
+
+        let examples = vec![
+            single_example("no_decoration", DecoratedIcon::new(icon_1, None)),
+            single_example(
+                "with_decoration",
+                DecoratedIcon::new(icon_2, Some(decoration_x)),
+            ),
+            single_example(
+                "with_decoration",
+                DecoratedIcon::new(icon_3, Some(decoration_triangle)),
+            ),
+            single_example(
+                "with_decoration",
+                DecoratedIcon::new(icon_4, Some(decoration_dot)),
+            ),
+        ];
+
+        vec![example_group(examples)]
     }
 }
 
@@ -501,7 +646,7 @@ impl RenderOnce for IconWithIndicator {
 }
 
 impl ComponentPreview for Icon {
-    fn examples() -> Vec<ComponentExampleGroup<Icon>> {
+    fn examples(_cx: &WindowContext) -> Vec<ComponentExampleGroup<Icon>> {
         let arrow_icons = vec![
             IconName::ArrowDown,
             IconName::ArrowLeft,

crates/ui/src/components/indicator.rs πŸ”—

@@ -89,7 +89,7 @@ impl ComponentPreview for Indicator {
         "An indicator visually represents a status or state."
     }
 
-    fn examples() -> Vec<ComponentExampleGroup<Self>> {
+    fn examples(_: &WindowContext) -> Vec<ComponentExampleGroup<Self>> {
         vec![
             example_group_with_title(
                 "Types",

crates/ui/src/components/stories/icon.rs πŸ”—

@@ -2,7 +2,7 @@ use gpui::Render;
 use story::Story;
 use strum::IntoEnumIterator;
 
-use crate::{prelude::*, DecoratedIcon, IconDecoration};
+use crate::prelude::*;
 use crate::{Icon, IconName};
 
 pub struct IconStory;
@@ -14,22 +14,6 @@ impl Render for IconStory {
         Story::container()
             .child(Story::title_for::<Icon>())
             .child(Story::label("DecoratedIcon"))
-            .child(DecoratedIcon::new(
-                Icon::new(IconName::Bell).color(Color::Muted),
-                IconDecoration::IndicatorDot,
-            ))
-            .child(
-                DecoratedIcon::new(Icon::new(IconName::Bell), IconDecoration::IndicatorDot)
-                    .decoration_color(Color::Accent),
-            )
-            .child(DecoratedIcon::new(
-                Icon::new(IconName::Bell).color(Color::Muted),
-                IconDecoration::Strikethrough,
-            ))
-            .child(
-                DecoratedIcon::new(Icon::new(IconName::Bell), IconDecoration::X)
-                    .decoration_color(Color::Error),
-            )
             .child(Story::label("All Icons"))
             .child(div().flex().gap_3().children(icons.map(Icon::new)))
     }

crates/ui/src/components/table.rs πŸ”—

@@ -160,7 +160,7 @@ impl ComponentPreview for Table {
         ExampleLabelSide::Top
     }
 
-    fn examples() -> Vec<ComponentExampleGroup<Self>> {
+    fn examples(_: &WindowContext) -> Vec<ComponentExampleGroup<Self>> {
         vec![
             example_group(vec![
                 single_example(

crates/ui/src/traits/component_preview.rs πŸ”—

@@ -30,10 +30,10 @@ pub trait ComponentPreview: IntoElement {
         ExampleLabelSide::default()
     }
 
-    fn examples() -> Vec<ComponentExampleGroup<Self>>;
+    fn examples(_cx: &WindowContext) -> Vec<ComponentExampleGroup<Self>>;
 
-    fn component_previews() -> Vec<AnyElement> {
-        Self::examples()
+    fn component_previews(cx: &WindowContext) -> Vec<AnyElement> {
+        Self::examples(cx)
             .into_iter()
             .map(|example| Self::render_example_group(example))
             .collect()
@@ -73,7 +73,7 @@ pub trait ComponentPreview: IntoElement {
                         )
                     }),
             )
-            .children(Self::component_previews())
+            .children(Self::component_previews(cx))
             .into_any_element()
     }
 

crates/workspace/src/theme_preview.rs πŸ”—

@@ -5,7 +5,8 @@ use theme::all_theme_colors;
 use ui::{
     element_cell, prelude::*, string_cell, utils::calculate_contrast_ratio, AudioStatus,
     Availability, Avatar, AvatarAudioStatusIndicator, AvatarAvailabilityIndicator, ButtonLike,
-    Checkbox, CheckboxWithLabel, ElevationIndex, Facepile, Indicator, Table, TintColor, Tooltip,
+    Checkbox, CheckboxWithLabel, DecoratedIcon, ElevationIndex, Facepile, IconDecoration,
+    Indicator, Table, TintColor, Tooltip,
 };
 
 use crate::{Item, Workspace};
@@ -509,6 +510,8 @@ impl ThemePreview {
             .overflow_scroll()
             .size_full()
             .gap_2()
+            .child(IconDecoration::render_component_previews(cx))
+            .child(DecoratedIcon::render_component_previews(cx))
             .child(Checkbox::render_component_previews(cx))
             .child(CheckboxWithLabel::render_component_previews(cx))
             .child(Facepile::render_component_previews(cx))