From feed34cafeae0b16e7a18cae25452b60c6387ef2 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Wed, 5 Nov 2025 16:12:30 -0300 Subject: [PATCH] gpui: Add support for rendering SVG from external files (#42024) Release Notes: - N/A --------- Co-authored-by: Mikayla Maki --- 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(-) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index c43a48e3538c22a03c444a6b3500ed621bbb5cf0..9f7c6caaa8a0be8836f650ba880a863ff7b33059 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/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) }) diff --git a/crates/gpui/src/elements/svg.rs b/crates/gpui/src/elements/svg.rs index a55245dcdfbf42898e519b6d06f03e3f6c33158c..57b2d712e54c501cb7eaf59f6433748ddf36d3fc 100644 --- a/crates/gpui/src/elements/svg.rs +++ b/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, path: Option, + external_path: Option, } /// 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) -> 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::(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>; + + fn load( + source: Self::Source, + _cx: &mut App, + ) -> impl Future + 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) + } + } +} diff --git a/crates/gpui/src/svg_renderer.rs b/crates/gpui/src/svg_renderer.rs index 1e2e34897af0b550542f9af148bb7c19f8f8ed18..cae1b5d423b9a01e30f19c049dc37bb377e4e887 100644 --- a/crates/gpui/src/svg_renderer.rs +++ b/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, Vec)>> { anyhow::ensure!(!params.size.is_zero(), "can't render at a zero size"); - // Load the tree. - let Some(bytes) = self.asset_source.load(¶ms.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::>(); + + 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::>(); - Ok(Some((size, alpha_mask))) + if let Some(bytes) = bytes { + render_pixmap(bytes) + } else if let Some(bytes) = self.asset_source.load(¶ms.path)? { + render_pixmap(&bytes) + } else { + Ok(None) + } } fn render_pixmap(&self, bytes: &[u8], size: SvgSize) -> Result { diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index e5b0ae4929cec3728047ee008db87278b99d790f..f6d9be68ab0773ca14e99a2f42bde3cb88d031c2 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -3084,6 +3084,7 @@ impl Window { &mut self, bounds: Bounds, 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(¶ms.clone().into(), &mut || { - let Some((size, bytes)) = cx.svg_renderer.render_alpha_mask(¶ms)? else { + let Some((size, bytes)) = cx.svg_renderer.render_alpha_mask(¶ms, data)? + else { return Ok(None); }; Ok(Some((size, Cow::Owned(bytes)))) diff --git a/crates/ui/src/components/context_menu.rs b/crates/ui/src/components/context_menu.rs index 8db7a9da07992ae6ba6a3a9f4fcec5ff4f9d5344..e02578d186774310f403840d32f70f6c32399893 100644 --- a/crates/ui/src/components/context_menu.rs +++ b/crates/ui/src/components/context_menu.rs @@ -48,6 +48,7 @@ pub struct ContextMenuEntry { label: SharedString, icon: Option, custom_icon_path: Option, + custom_icon_svg: Option, icon_position: IconPosition, icon_size: IconSize, icon_color: Option, @@ -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) -> 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) -> 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() diff --git a/crates/ui/src/components/icon.rs b/crates/ui/src/components/icon.rs index d7fadbd962a97c83d31438f43e800f9a4ff8c777..cc43db7904e4d9c6328c44c275d96cccbce7ec8c 100644 --- a/crates/ui/src/components/icon.rs +++ b/crates/ui/src/components/icon.rs @@ -115,24 +115,24 @@ impl From 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), + External(Arc), + /// An SVG not embedded in the Zed binary. + ExternalSvg(SharedString), } impl IconSource { fn from_path(path: impl Into) -> 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))