gpui: Add support for rendering SVG from external files (#42024)

Danilo Leal and Mikayla Maki created

Release Notes:

- N/A

---------

Co-authored-by: Mikayla Maki <mikayla.c.maki@gmail.com>

Change summary

crates/agent_ui/src/agent_panel.rs       |  2 
crates/gpui/src/elements/svg.rs          | 60 +++++++++++++++++++++++++
crates/gpui/src/svg_renderer.rs          | 39 +++++++++------
crates/gpui/src/window.rs                |  4 +
crates/ui/src/components/context_menu.rs | 42 +++++++++++++++++
crates/ui/src/components/icon.rs         | 36 +++++++++++----
6 files changed, 152 insertions(+), 31 deletions(-)

Detailed changes

crates/agent_ui/src/agent_panel.rs 🔗

@@ -2150,7 +2150,7 @@ impl AgentPanel {
             .when_some(selected_agent_custom_icon, |this, icon_path| {
                 let label = selected_agent_label.clone();
                 this.px(DynamicSpacing::Base02.rems(cx))
-                    .child(Icon::from_path(icon_path).color(Color::Muted))
+                    .child(Icon::from_external_svg(icon_path).color(Color::Muted))
                     .tooltip(move |_window, cx| {
                         Tooltip::with_meta(label.clone(), None, "Selected Agent", cx)
                     })

crates/gpui/src/elements/svg.rs 🔗

@@ -1,5 +1,7 @@
+use std::{fs, path::Path, sync::Arc};
+
 use crate::{
-    App, Bounds, Element, GlobalElementId, Hitbox, InspectorElementId, InteractiveElement,
+    App, Asset, Bounds, Element, GlobalElementId, Hitbox, InspectorElementId, InteractiveElement,
     Interactivity, IntoElement, LayoutId, Pixels, Point, Radians, SharedString, Size,
     StyleRefinement, Styled, TransformationMatrix, Window, geometry::Negate as _, point, px,
     radians, size,
@@ -11,6 +13,7 @@ pub struct Svg {
     interactivity: Interactivity,
     transformation: Option<Transformation>,
     path: Option<SharedString>,
+    external_path: Option<SharedString>,
 }
 
 /// Create a new SVG element.
@@ -20,6 +23,7 @@ pub fn svg() -> Svg {
         interactivity: Interactivity::new(),
         transformation: None,
         path: None,
+        external_path: None,
     }
 }
 
@@ -30,6 +34,12 @@ impl Svg {
         self
     }
 
+    /// Set the path to the SVG file for this element.
+    pub fn external_path(mut self, path: impl Into<SharedString>) -> Self {
+        self.external_path = Some(path.into());
+        self
+    }
+
     /// Transform the SVG element with the given transformation.
     /// Note that this won't effect the hitbox or layout of the element, only the rendering.
     pub fn with_transformation(mut self, transformation: Transformation) -> Self {
@@ -117,7 +127,35 @@ impl Element for Svg {
                         .unwrap_or_default();
 
                     window
-                        .paint_svg(bounds, path.clone(), transformation, color, cx)
+                        .paint_svg(bounds, path.clone(), None, transformation, color, cx)
+                        .log_err();
+                } else if let Some((path, color)) =
+                    self.external_path.as_ref().zip(style.text.color)
+                {
+                    let Some(bytes) = window
+                        .use_asset::<SvgAsset>(path, cx)
+                        .and_then(|asset| asset.log_err())
+                    else {
+                        return;
+                    };
+
+                    let transformation = self
+                        .transformation
+                        .as_ref()
+                        .map(|transformation| {
+                            transformation.into_matrix(bounds.center(), window.scale_factor())
+                        })
+                        .unwrap_or_default();
+
+                    window
+                        .paint_svg(
+                            bounds,
+                            path.clone(),
+                            Some(&bytes),
+                            transformation,
+                            color,
+                            cx,
+                        )
                         .log_err();
                 }
             },
@@ -219,3 +257,21 @@ impl Transformation {
             .translate(center.scale(scale_factor).negate())
     }
 }
+
+enum SvgAsset {}
+
+impl Asset for SvgAsset {
+    type Source = SharedString;
+    type Output = Result<Arc<[u8]>, Arc<std::io::Error>>;
+
+    fn load(
+        source: Self::Source,
+        _cx: &mut App,
+    ) -> impl Future<Output = Self::Output> + Send + 'static {
+        async move {
+            let bytes = fs::read(Path::new(source.as_ref())).map_err(|e| Arc::new(e))?;
+            let bytes = Arc::from(bytes);
+            Ok(bytes)
+        }
+    }
+}

crates/gpui/src/svg_renderer.rs 🔗

@@ -95,27 +95,34 @@ impl SvgRenderer {
     pub(crate) fn render_alpha_mask(
         &self,
         params: &RenderSvgParams,
+        bytes: Option<&[u8]>,
     ) -> Result<Option<(Size<DevicePixels>, Vec<u8>)>> {
         anyhow::ensure!(!params.size.is_zero(), "can't render at a zero size");
 
-        // Load the tree.
-        let Some(bytes) = self.asset_source.load(&params.path)? else {
-            return Ok(None);
+        let render_pixmap = |bytes| {
+            let pixmap = self.render_pixmap(bytes, SvgSize::Size(params.size))?;
+
+            // Convert the pixmap's pixels into an alpha mask.
+            let size = Size::new(
+                DevicePixels(pixmap.width() as i32),
+                DevicePixels(pixmap.height() as i32),
+            );
+            let alpha_mask = pixmap
+                .pixels()
+                .iter()
+                .map(|p| p.alpha())
+                .collect::<Vec<_>>();
+
+            Ok(Some((size, alpha_mask)))
         };
 
-        let pixmap = self.render_pixmap(&bytes, SvgSize::Size(params.size))?;
-
-        // Convert the pixmap's pixels into an alpha mask.
-        let size = Size::new(
-            DevicePixels(pixmap.width() as i32),
-            DevicePixels(pixmap.height() as i32),
-        );
-        let alpha_mask = pixmap
-            .pixels()
-            .iter()
-            .map(|p| p.alpha())
-            .collect::<Vec<_>>();
-        Ok(Some((size, alpha_mask)))
+        if let Some(bytes) = bytes {
+            render_pixmap(bytes)
+        } else if let Some(bytes) = self.asset_source.load(&params.path)? {
+            render_pixmap(&bytes)
+        } else {
+            Ok(None)
+        }
     }
 
     fn render_pixmap(&self, bytes: &[u8], size: SvgSize) -> Result<Pixmap, usvg::Error> {

crates/gpui/src/window.rs 🔗

@@ -3084,6 +3084,7 @@ impl Window {
         &mut self,
         bounds: Bounds<Pixels>,
         path: SharedString,
+        mut data: Option<&[u8]>,
         transformation: TransformationMatrix,
         color: Hsla,
         cx: &App,
@@ -3104,7 +3105,8 @@ impl Window {
         let Some(tile) =
             self.sprite_atlas
                 .get_or_insert_with(&params.clone().into(), &mut || {
-                    let Some((size, bytes)) = cx.svg_renderer.render_alpha_mask(&params)? else {
+                    let Some((size, bytes)) = cx.svg_renderer.render_alpha_mask(&params, data)?
+                    else {
                         return Ok(None);
                     };
                     Ok(Some((size, Cow::Owned(bytes))))

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

@@ -48,6 +48,7 @@ pub struct ContextMenuEntry {
     label: SharedString,
     icon: Option<IconName>,
     custom_icon_path: Option<SharedString>,
+    custom_icon_svg: Option<SharedString>,
     icon_position: IconPosition,
     icon_size: IconSize,
     icon_color: Option<Color>,
@@ -68,6 +69,7 @@ impl ContextMenuEntry {
             label: label.into(),
             icon: None,
             custom_icon_path: None,
+            custom_icon_svg: None,
             icon_position: IconPosition::Start,
             icon_size: IconSize::Small,
             icon_color: None,
@@ -94,7 +96,15 @@ impl ContextMenuEntry {
 
     pub fn custom_icon_path(mut self, path: impl Into<SharedString>) -> Self {
         self.custom_icon_path = Some(path.into());
-        self.icon = None; // Clear IconName if custom path is set
+        self.custom_icon_svg = None; // Clear other icon sources if custom path is set
+        self.icon = None;
+        self
+    }
+
+    pub fn custom_icon_svg(mut self, svg: impl Into<SharedString>) -> Self {
+        self.custom_icon_svg = Some(svg.into());
+        self.custom_icon_path = None; // Clear other icon sources if custom path is set
+        self.icon = None;
         self
     }
 
@@ -396,6 +406,7 @@ impl ContextMenu {
             handler: Rc::new(move |_, window, cx| handler(window, cx)),
             icon: None,
             custom_icon_path: None,
+            custom_icon_svg: None,
             icon_position: IconPosition::End,
             icon_size: IconSize::Small,
             icon_color: None,
@@ -425,6 +436,7 @@ impl ContextMenu {
             handler: Rc::new(move |_, window, cx| handler(window, cx)),
             icon: None,
             custom_icon_path: None,
+            custom_icon_svg: None,
             icon_position: IconPosition::End,
             icon_size: IconSize::Small,
             icon_color: None,
@@ -454,6 +466,7 @@ impl ContextMenu {
             handler: Rc::new(move |_, window, cx| handler(window, cx)),
             icon: None,
             custom_icon_path: None,
+            custom_icon_svg: None,
             icon_position: IconPosition::End,
             icon_size: IconSize::Small,
             icon_color: None,
@@ -482,6 +495,7 @@ impl ContextMenu {
             handler: Rc::new(move |_, window, cx| handler(window, cx)),
             icon: None,
             custom_icon_path: None,
+            custom_icon_svg: None,
             icon_position: position,
             icon_size: IconSize::Small,
             icon_color: None,
@@ -541,6 +555,7 @@ impl ContextMenu {
             }),
             icon: None,
             custom_icon_path: None,
+            custom_icon_svg: None,
             icon_position: IconPosition::End,
             icon_size: IconSize::Small,
             icon_color: None,
@@ -572,6 +587,7 @@ impl ContextMenu {
             }),
             icon: None,
             custom_icon_path: None,
+            custom_icon_svg: None,
             icon_size: IconSize::Small,
             icon_position: IconPosition::End,
             icon_color: None,
@@ -593,6 +609,7 @@ impl ContextMenu {
             handler: Rc::new(move |_, window, cx| window.dispatch_action(action.boxed_clone(), cx)),
             icon: Some(IconName::ArrowUpRight),
             custom_icon_path: None,
+            custom_icon_svg: None,
             icon_size: IconSize::XSmall,
             icon_position: IconPosition::End,
             icon_color: None,
@@ -913,6 +930,7 @@ impl ContextMenu {
             handler,
             icon,
             custom_icon_path,
+            custom_icon_svg,
             icon_position,
             icon_size,
             icon_color,
@@ -965,6 +983,28 @@ impl ContextMenu {
                     )
                 })
                 .into_any_element()
+        } else if let Some(custom_icon_svg) = custom_icon_svg {
+            h_flex()
+                .gap_1p5()
+                .when(
+                    *icon_position == IconPosition::Start && toggle.is_none(),
+                    |flex| {
+                        flex.child(
+                            Icon::from_external_svg(custom_icon_svg.clone())
+                                .size(*icon_size)
+                                .color(icon_color),
+                        )
+                    },
+                )
+                .child(Label::new(label.clone()).color(label_color).truncate())
+                .when(*icon_position == IconPosition::End, |flex| {
+                    flex.child(
+                        Icon::from_external_svg(custom_icon_svg.clone())
+                            .size(*icon_size)
+                            .color(icon_color),
+                    )
+                })
+                .into_any_element()
         } else if let Some(icon_name) = icon {
             h_flex()
                 .gap_1p5()

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

@@ -115,24 +115,24 @@ impl From<IconName> for Icon {
 /// The source of an icon.
 enum IconSource {
     /// An SVG embedded in the Zed binary.
-    Svg(SharedString),
+    Embedded(SharedString),
     /// An image file located at the specified path.
     ///
-    /// Currently our SVG renderer is missing support for the following features:
-    /// 1. Loading SVGs from external files.
-    /// 2. Rendering polychrome SVGs.
+    /// Currently our SVG renderer is missing support for rendering polychrome SVGs.
     ///
     /// In order to support icon themes, we render the icons as images instead.
-    Image(Arc<Path>),
+    External(Arc<Path>),
+    /// An SVG not embedded in the Zed binary.
+    ExternalSvg(SharedString),
 }
 
 impl IconSource {
     fn from_path(path: impl Into<SharedString>) -> Self {
         let path = path.into();
         if path.starts_with("icons/") {
-            Self::Svg(path)
+            Self::Embedded(path)
         } else {
-            Self::Image(Arc::from(PathBuf::from(path.as_ref())))
+            Self::External(Arc::from(PathBuf::from(path.as_ref())))
         }
     }
 }
@@ -148,7 +148,7 @@ pub struct Icon {
 impl Icon {
     pub fn new(icon: IconName) -> Self {
         Self {
-            source: IconSource::Svg(icon.path().into()),
+            source: IconSource::Embedded(icon.path().into()),
             color: Color::default(),
             size: IconSize::default().rems(),
             transformation: Transformation::default(),
@@ -164,6 +164,15 @@ impl Icon {
         }
     }
 
+    pub fn from_external_svg(svg: SharedString) -> Self {
+        Self {
+            source: IconSource::ExternalSvg(svg),
+            color: Color::default(),
+            size: IconSize::default().rems(),
+            transformation: Transformation::default(),
+        }
+    }
+
     pub fn color(mut self, color: Color) -> Self {
         self.color = color;
         self
@@ -193,14 +202,21 @@ impl Transformable for Icon {
 impl RenderOnce for Icon {
     fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
         match self.source {
-            IconSource::Svg(path) => svg()
+            IconSource::Embedded(path) => svg()
                 .with_transformation(self.transformation)
                 .size(self.size)
                 .flex_none()
                 .path(path)
                 .text_color(self.color.color(cx))
                 .into_any_element(),
-            IconSource::Image(path) => img(path)
+            IconSource::ExternalSvg(path) => svg()
+                .external_path(path)
+                .with_transformation(self.transformation)
+                .size(self.size)
+                .flex_none()
+                .text_color(self.color.color(cx))
+                .into_any_element(),
+            IconSource::External(path) => img(path)
                 .size(self.size)
                 .flex_none()
                 .text_color(self.color.color(cx))