add `ui::Vector` and separate images from icons (#17815)

Nate Butler created

This PR pulls non-icon assets out of `ui::components::icon` in
preparation for icon standardization.

In the future icons will have standard names and sizes, and these image
assets won't conform to those constraints.

We can also add a `ui::components::image::Image` wrapper around the
`gpui::img` element in the future for any Zed-specific image styling we
want to enforce.

Of note:

```rust
#[derive(Debug, PartialEq, Eq, Copy, Clone, EnumIter, EnumString, IntoStaticStr, Serialize, Deserialize, DerivePathStr)]
#[strum(serialize_all = "snake_case")]
#[path_str(prefix = "images", suffix = ".svg")]
pub enum VectorName {
    ZedLogo,
    ZedXCopilot,
}
```

You can see in the above code we no longer need to manually specify
paths for image/icon enums like we currently do in
`ui::components::icon`.

The icon component will get this same treatment in the future, once we:

- do the design work needed to standardize the icons
- remove unused icons
- update icon names

Release Notes:

- N/A

Change summary

assets/images/zed_logo.svg                       |  10 +
assets/images/zed_x_copilot.svg                  |   3 
crates/assets/src/assets.rs                      |   1 
crates/copilot/src/sign_in.rs                    |  12 -
crates/gpui_macros/src/derive_path_static_str.rs |  73 +++++++++++
crates/gpui_macros/src/gpui_macros.rs            |   7 +
crates/storybook/src/assets.rs                   |   1 
crates/storybook/src/story_selector.rs           |   2 
crates/ui/src/components.rs                      |   4 
crates/ui/src/components/icon.rs                 |   2 
crates/ui/src/components/image.rs                | 115 ++++++++++++++++++
11 files changed, 220 insertions(+), 10 deletions(-)

Detailed changes

assets/images/zed_logo.svg 🔗

@@ -0,0 +1,10 @@
+<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g clip-path="url(#clip0_1957_1318)">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M9 6C7.34315 6 6 7.34315 6 9V75H0V9C0 4.02944 4.02944 0 9 0H89.3787C93.3878 0 95.3955 4.84715 92.5607 7.68198L43.0551 57.1875H57V51H63V58.6875C63 61.1728 60.9853 63.1875 58.5 63.1875H37.0551L26.7426 73.5H73.5V36H79.5V73.5C79.5 76.8137 76.8137 79.5 73.5 79.5H20.7426L10.2426 90H87C88.6569 90 90 88.6569 90 87V21H96V87C96 91.9706 91.9706 96 87 96H6.62132C2.61224 96 0.604504 91.1529 3.43934 88.318L52.7574 39H39V45H33V37.5C33 35.0147 35.0147 33 37.5 33H58.7574L69.2574 22.5H22.5V60H16.5V22.5C16.5 19.1863 19.1863 16.5 22.5 16.5H75.2574L85.7574 6H9Z" fill="black"/>
+</g>
+<defs>
+<clipPath id="clip0_1957_1318">
+<rect width="96" height="96" fill="white"/>
+</clipPath>
+</defs>
+</svg>

assets/images/zed_x_copilot.svg 🔗

@@ -0,0 +1,14 @@
+<svg width="93" height="32" viewBox="0 0 93 32" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M9.03994 7.04962C8.00934 7.67635 7.30394 8.63219 7.30394 10.0149C7.30394 11.6908 7.72423 12.5893 8.20468 13.0744C8.68379 13.5581 9.40524 13.8149 10.4054 13.8149C11.815 13.8149 13.0291 13.5336 13.8802 12.9464C14.6756 12.3977 15.2708 11.5042 15.3438 9.96182C15.3991 8.79382 15.3678 8.01341 15.0568 7.45711C14.8094 7.01449 14.2326 6.47436 12.4901 6.27416C11.4684 6.15678 10.1114 6.39804 9.03994 7.04962ZM7.8731 5.13084C9.39145 4.2075 11.2531 3.87155 12.7464 4.04312C14.8843 4.28874 16.2844 5.05049 17.0171 6.36142C17.6863 7.55867 17.6384 8.98348 17.587 10.068C17.484 12.2439 16.5804 13.8118 15.1554 14.7949C13.7861 15.7396 12.0582 16.0606 10.4054 16.0606C9.04199 16.0606 7.65126 15.7069 6.60911 14.6547C5.5683 13.6038 5.05823 12.0408 5.05823 10.0149C5.05823 7.6958 6.31388 6.07903 7.8731 5.13084Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M13.983 18.2811C14.6595 18.2811 15.2079 18.8295 15.2079 19.506V22.16C15.2079 22.8365 14.6595 23.385 13.983 23.385C13.3065 23.385 12.758 22.8365 12.758 22.16V19.506C12.758 18.8295 13.3065 18.2811 13.983 18.2811Z" fill="black"/>

crates/assets/src/assets.rs 🔗

@@ -8,6 +8,7 @@ use rust_embed::RustEmbed;
 #[folder = "../../assets"]
 #[include = "fonts/**/*"]
 #[include = "icons/**/*"]
+#[include = "images/**/*"]
 #[include = "themes/**/*"]
 #[exclude = "themes/src/*"]
 #[include = "sounds/**/*"]

crates/copilot/src/sign_in.rs 🔗

@@ -1,10 +1,10 @@
 use crate::{request::PromptUserDeviceFlow, Copilot, Status};
 use gpui::{
-    div, svg, AppContext, ClipboardItem, DismissEvent, Element, EventEmitter, FocusHandle,
+    div, AppContext, ClipboardItem, DismissEvent, Element, EventEmitter, FocusHandle,
     FocusableView, InteractiveElement, IntoElement, Model, MouseDownEvent, ParentElement, Render,
     Styled, Subscription, ViewContext,
 };
-use ui::{prelude::*, Button, IconName, Label};
+use ui::{prelude::*, Button, Label, Vector, VectorName};
 use workspace::ModalView;
 
 const COPILOT_SIGN_UP_URL: &str = "https://github.com/features/copilot";
@@ -198,12 +198,8 @@ impl Render for CopilotCodeVerification {
                 cx.focus(&this.focus_handle);
             }))
             .child(
-                svg()
-                    .w_32()
-                    .h_16()
-                    .flex_none()
-                    .path(IconName::ZedXCopilot.path())
-                    .text_color(cx.theme().colors().icon),
+                Vector::new(VectorName::ZedXCopilot, rems(8.), rems(4.))
+                    .color(Color::Custom(cx.theme().colors().icon)),
             )
             .child(prompt)
     }

crates/gpui_macros/src/derive_path_static_str.rs 🔗

@@ -0,0 +1,73 @@
+use proc_macro::TokenStream;
+use quote::quote;
+use syn::{parse_macro_input, Attribute, Data, DeriveInput, Lit, Meta, NestedMeta};
+
+pub fn derive_path_static_str(input: TokenStream) -> TokenStream {
+    let input = parse_macro_input!(input as DeriveInput);
+    let name = &input.ident;
+
+    let prefix = get_attr_value(&input.attrs, "prefix").unwrap_or_else(|| "".to_string());
+    let suffix = get_attr_value(&input.attrs, "suffix").unwrap_or_else(|| "".to_string());
+    let delimiter = get_attr_value(&input.attrs, "delimiter").unwrap_or_else(|| "/".to_string());
+
+    let path_str_impl = impl_path_str(name, &input.data, &prefix, &suffix, &delimiter);
+
+    let expanded = quote! {
+        impl #name {
+            pub fn path_str(&self) -> &'static str {
+                #path_str_impl
+            }
+        }
+    };
+
+    TokenStream::from(expanded)
+}
+
+fn impl_path_str(
+    name: &syn::Ident,
+    data: &Data,
+    prefix: &str,
+    suffix: &str,
+    delimiter: &str,
+) -> proc_macro2::TokenStream {
+    match *data {
+        Data::Enum(ref data) => {
+            let match_arms = data.variants.iter().map(|variant| {
+                let ident = &variant.ident;
+                let path = format!("{}{}{}{}{}", prefix, delimiter, ident, delimiter, suffix);
+                quote! {
+                    #name::#ident => #path,
+                }
+            });
+
+            quote! {
+                match self {
+                    #(#match_arms)*
+                }
+            }
+        }
+        _ => panic!("DerivePathStr only supports enums"),
+    }
+}
+
+fn get_attr_value(attrs: &[Attribute], key: &str) -> Option<String> {
+    attrs
+        .iter()
+        .filter(|attr| attr.path.is_ident("derive_path_static_str"))
+        .find_map(|attr| {
+            if let Ok(Meta::List(meta_list)) = attr.parse_meta() {
+                meta_list.nested.iter().find_map(|nested_meta| {
+                    if let NestedMeta::Meta(Meta::NameValue(name_value)) = nested_meta {
+                        if name_value.path.is_ident(key) {
+                            if let Lit::Str(lit_str) = &name_value.lit {
+                                return Some(lit_str.value());
+                            }
+                        }
+                    }
+                    None
+                })
+            } else {
+                None
+            }
+        })
+}

crates/gpui_macros/src/gpui_macros.rs 🔗

@@ -1,4 +1,5 @@
 mod derive_into_element;
+mod derive_path_static_str;
 mod derive_render;
 mod register_action;
 mod styles;
@@ -27,6 +28,12 @@ pub fn derive_render(input: TokenStream) -> TokenStream {
     derive_render::derive_render(input)
 }
 
+#[proc_macro_derive(PathStaticStr)]
+#[doc(hidden)]
+pub fn derive_path_static_str(input: TokenStream) -> TokenStream {
+    derive_path_static_str::derive_path_static_str(input)
+}
+
 /// Used by GPUI to generate the style helpers.
 #[proc_macro]
 #[doc(hidden)]

crates/storybook/src/assets.rs 🔗

@@ -8,6 +8,7 @@ use rust_embed::RustEmbed;
 #[folder = "../../assets"]
 #[include = "fonts/**/*"]
 #[include = "icons/**/*"]
+#[include = "images/**/*"]
 #[include = "themes/**/*"]
 #[include = "sounds/**/*"]
 #[include = "*.md"]

crates/storybook/src/story_selector.rs 🔗

@@ -40,6 +40,7 @@ pub enum ComponentStory {
     ToolStrip,
     ViewportUnits,
     WithRemSize,
+    Vector,
 }
 
 impl ComponentStory {
@@ -75,6 +76,7 @@ impl ComponentStory {
             Self::ToolStrip => cx.new_view(|_| ui::ToolStripStory).into(),
             Self::ViewportUnits => cx.new_view(|_| crate::stories::ViewportUnitsStory).into(),
             Self::WithRemSize => cx.new_view(|_| crate::stories::WithRemSizeStory).into(),
+            Self::Vector => cx.new_view(|_| ui::VectorStory).into(),
         }
     }
 }

crates/ui/src/components.rs 🔗

@@ -7,6 +7,7 @@ mod divider;
 mod dropdown_menu;
 mod facepile;
 mod icon;
+mod image;
 mod indicator;
 mod keybinding;
 mod label;
@@ -37,6 +38,7 @@ pub use divider::*;
 pub use dropdown_menu::*;
 pub use facepile::*;
 pub use icon::*;
+pub use image::*;
 pub use indicator::*;
 pub use keybinding::*;
 pub use label::*;
@@ -55,5 +57,7 @@ pub use tab_bar::*;
 pub use tool_strip::*;
 pub use tooltip::*;
 
+#[cfg(feature = "stories")]
+pub use image::story::*;
 #[cfg(feature = "stories")]
 pub use stories::*;

crates/ui/src/components/icon.rs 🔗

@@ -271,7 +271,6 @@ pub enum IconName {
     XCircle,
     ZedAssistant,
     ZedAssistantFilled,
-    ZedXCopilot,
     Visible,
 }
 
@@ -443,7 +442,6 @@ impl IconName {
             IconName::XCircle => "icons/error.svg",
             IconName::ZedAssistant => "icons/zed_assistant.svg",
             IconName::ZedAssistantFilled => "icons/zed_assistant_filled.svg",
-            IconName::ZedXCopilot => "icons/zed_x_copilot.svg",
             IconName::Visible => "icons/visible.svg",
         }
     }

crates/ui/src/components/image.rs 🔗

@@ -0,0 +1,115 @@
+use gpui::{svg, IntoElement, Rems, RenderOnce, Size, Styled, WindowContext};
+use serde::{Deserialize, Serialize};
+use strum::{EnumIter, EnumString, IntoStaticStr};
+use ui_macros::{path_str, DerivePathStr};
+
+use crate::Color;
+
+#[derive(
+    Debug,
+    PartialEq,
+    Eq,
+    Copy,
+    Clone,
+    EnumIter,
+    EnumString,
+    IntoStaticStr,
+    Serialize,
+    Deserialize,
+    DerivePathStr,
+)]
+#[strum(serialize_all = "snake_case")]
+#[path_str(prefix = "images", suffix = ".svg")]
+pub enum VectorName {
+    ZedLogo,
+    ZedXCopilot,
+}
+
+/// A vector image, such as an SVG.
+///
+/// A [Vector] is different from an [Icon] in that it is intended
+/// to be displayed at a specific size, or series of sizes, rather
+/// than conforming to the standard size of an icons.
+#[derive(IntoElement)]
+pub struct Vector {
+    path: &'static str,
+    color: Color,
+    size: Size<Rems>,
+}
+
+impl Vector {
+    /// Create a new [Vector] image with the given [VectorName] and size.
+    pub fn new(vector: VectorName, width: Rems, height: Rems) -> Self {
+        Self {
+            path: vector.path(),
+            color: Color::default(),
+            size: Size { width, height },
+        }
+    }
+
+    /// Create a new [Vector] image where the width and height are the same.
+    pub fn square(vector: VectorName, size: Rems) -> Self {
+        Self::new(vector, size, size)
+    }
+
+    /// Set the image color
+    pub fn color(mut self, color: Color) -> Self {
+        self.color = color;
+        self
+    }
+
+    /// Set the image size
+    pub fn size(mut self, size: impl Into<Size<Rems>>) -> Self {
+        let size = size.into();
+
+        self.size = size;
+        self
+    }
+}
+
+impl RenderOnce for Vector {
+    fn render(self, cx: &mut WindowContext) -> impl IntoElement {
+        let width = self.size.width;
+        let height = self.size.height;
+
+        svg()
+            // By default, prevent the SVG from stretching
+            // to fill its container.
+            .flex_none()
+            .w(width)
+            .h(height)
+            .path(self.path)
+            .text_color(self.color.color(cx))
+    }
+}
+
+#[cfg(feature = "stories")]
+pub mod story {
+    use gpui::Render;
+    use story::{Story, StoryItem, StorySection};
+    use strum::IntoEnumIterator;
+
+    use crate::prelude::*;
+
+    use super::{Vector, VectorName};
+
+    pub struct VectorStory;
+
+    impl Render for VectorStory {
+        fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
+            Story::container().child(StorySection::new().children(VectorName::iter().map(
+                |vector| StoryItem::new(format!("{:?}", vector), Vector::square(vector, rems(8.))),
+            )))
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn vector_path() {
+        assert_eq!(VectorName::ZedLogo.path(), "images/zed_logo.svg");
+    }
+}