Skip to main content

gpui_component/
icon.rs

1use std::{rc::Rc, sync::Arc};
2
3use crate::{ActiveTheme, Sizable, Size};
4use gpui::{
5    AnyElement, App, AppContext, Context, Entity, Global, Hsla, IntoElement, Pixels, Radians,
6    Render, RenderOnce, SharedString, StyleRefinement, Styled, Svg, Transformation, Window,
7    prelude::FluentBuilder as _, svg,
8};
9pub use gpui_kit_assets::IconNamed;
10
11// Preserve the original enum (including exhaustive matches and inherent view)
12// while the complete, shared catalog is owned by gpui-kit-assets.
13macro_rules! component_icon_names {
14    ($($name:ident => $path:literal,)*) => {
15        /// Default component icon names, retained for source compatibility.
16        /// For the complete Lucide catalog, use `gpui_kit_assets::IconName`.
17        #[derive(Clone, IntoElement)]
18        pub enum IconName {
19            $($name,)*
20        }
21
22        impl From<IconName> for gpui_kit_assets::IconName {
23            fn from(name: IconName) -> Self {
24                match name {
25                    $(IconName::$name => Self::$name,)*
26                }
27            }
28        }
29
30        impl IconNamed for IconName {
31            fn path(self) -> SharedString {
32                match self { $(Self::$name => $path,)* }.into()
33            }
34        }
35    };
36}
37
38gpui_kit_assets::__component_icon_names!(component_icon_names);
39
40impl IconName {
41    /// Return the icon as an Entity<Icon>.
42    pub fn view(self, cx: &mut App) -> Entity<Icon> {
43        Icon::build(self).view(cx)
44    }
45}
46
47impl From<IconName> for AnyElement {
48    fn from(name: IconName) -> Self {
49        Icon::build(name).into_any_element()
50    }
51}
52
53impl RenderOnce for IconName {
54    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
55        Icon::build(self)
56    }
57}
58
59impl<T: IconNamed> From<T> for Icon {
60    fn from(value: T) -> Self {
61        Icon::build(value)
62    }
63}
64
65/// Component view construction for the shared `gpui_kit_assets::IconName`.
66/// The legacy component `IconName` retains its inherent `view` method.
67pub trait IconNameExt {
68    fn view(self, cx: &mut App) -> Entity<Icon>;
69}
70
71impl IconNameExt for gpui_kit_assets::IconName {
72    fn view(self, cx: &mut App) -> Entity<Icon> {
73        Icon::build(self).view(cx)
74    }
75}
76
77struct IconResolver(Rc<dyn Fn(&str, &App) -> Option<SharedString>>);
78
79impl Global for IconResolver {}
80
81/// Set the hook every [`Icon`] drawn from an asset path asks for the path to draw,
82/// including the icons components draw inside their own controls.
83///
84/// The resolver receives the icon's path (for example `icons/check.svg`) and returns
85/// the path to draw in its place, or `None` to keep it. GPUI caches a rendered SVG by
86/// its path, so returning a new path is how an application switches icon styles at
87/// runtime; the application's [`gpui::AssetSource`] must load the returned path.
88/// Icons created with [`Icon::data`] are not resolved.
89///
90/// The resolver runs every time an icon is drawn and should be cheap. Call
91/// [`App::refresh_windows`] after changing what it returns.
92pub fn set_icon_resolver(
93    cx: &mut App,
94    resolver: impl Fn(&str, &App) -> Option<SharedString> + 'static,
95) {
96    cx.set_global(IconResolver(Rc::new(resolver)));
97}
98
99/// Return the path to draw for the icon at `path`: the answer of the resolver set with
100/// [`set_icon_resolver`], or `path` itself.
101pub fn resolve_icon_path(path: SharedString, cx: &App) -> SharedString {
102    match cx.try_global::<IconResolver>() {
103        Some(resolver) if !path.is_empty() => (resolver.0)(&path, cx).unwrap_or(path),
104        _ => path,
105    }
106}
107
108#[derive(Clone)]
109pub(crate) enum IconSource {
110    Path(SharedString),
111    Data(Arc<[u8]>),
112}
113
114#[derive(Clone, IntoElement)]
115pub struct Icon {
116    style: StyleRefinement,
117    source: IconSource,
118    text_color: Option<Hsla>,
119    size: Option<Size>,
120    transformation: Option<Transformation>,
121}
122
123impl Default for Icon {
124    fn default() -> Self {
125        Self {
126            style: StyleRefinement::default(),
127            source: IconSource::Path("".into()),
128            text_color: None,
129            size: None,
130            transformation: None,
131        }
132    }
133}
134
135impl Icon {
136    pub fn new(icon: impl Into<Icon>) -> Self {
137        icon.into()
138    }
139
140    fn build(name: impl IconNamed) -> Self {
141        Self::default().path(name.path())
142    }
143
144    /// Set the icon path of the Assets bundle
145    ///
146    /// For example: `icons/foo.svg`
147    /// Replaces any previously set path or SVG data.
148    pub fn path(mut self, path: impl Into<SharedString>) -> Self {
149        self.source = IconSource::Path(path.into());
150        self
151    }
152
153    /// Set raw SVG bytes without registering an asset path.
154    ///
155    /// Copies the bytes into shared storage; the input need not be static.
156    /// Cloning the icon shares those bytes. Replaces any previously set path or data.
157    /// Parsing and rendering follow GPUI's SVG behavior.
158    ///
159    /// ```
160    /// use gpui_component::Icon;
161    ///
162    /// let bytes = br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
163    ///     <path d="M4 12h16" stroke="currentColor"/>
164    /// </svg>"#;
165    /// let icon = Icon::default().data(bytes);
166    /// ```
167    pub fn data(mut self, data: &[u8]) -> Self {
168        self.source = IconSource::Data(Arc::from(data));
169        self
170    }
171
172    #[cfg(any(target_os = "macos", target_os = "windows", test))]
173    pub(crate) fn source_ref(&self) -> &IconSource {
174        &self.source
175    }
176
177    /// Whether `other` takes up exactly the same space as this icon. The
178    /// source, color and transformation change only what it paints.
179    pub(crate) fn same_layout(&self, other: &Self) -> bool {
180        self.style == other.style && self.size == other.size
181    }
182
183    /// Create a new view for the icon
184    pub fn view(self, cx: &mut App) -> Entity<Icon> {
185        cx.new(|_| self)
186    }
187
188    /// Set the SVG transformation, replacing any previous transformation or rotation.
189    pub fn transform(mut self, transformation: gpui::Transformation) -> Self {
190        self.transformation = Some(transformation);
191        self
192    }
193
194    pub fn empty() -> Self {
195        Self::default()
196    }
197
198    /// Rotate the icon by the given angle
199    ///
200    /// Replaces any previous transformation or rotation.
201    pub fn rotate(mut self, radians: impl Into<Radians>) -> Self {
202        self.transformation = Some(Transformation::rotate(radians));
203        self
204    }
205
206    fn resolved(mut self, cx: &App) -> Self {
207        if let IconSource::Path(path) = self.source {
208            self.source = IconSource::Path(resolve_icon_path(path, cx));
209        }
210        self
211    }
212
213    fn into_svg(self, text_size: Pixels, fallback_color: Hsla) -> Svg {
214        let text_color = self.text_color.unwrap_or(fallback_color);
215        let has_base_size = self.style.size.width.is_some() || self.style.size.height.is_some();
216
217        svg()
218            .map(|mut this| {
219                *this.style() = self.style;
220                this
221            })
222            .flex_shrink_0()
223            .text_color(text_color)
224            .when(!has_base_size, |this| this.size(text_size))
225            .when_some(self.size, |this, size| match size {
226                Size::Size(px) => this.size(px),
227                Size::XSmall => this.size_3(),
228                Size::Small => this.size_3p5(),
229                Size::Medium => this.size_4(),
230                Size::Large => this.size_6(),
231            })
232            .map(|this| match self.source {
233                IconSource::Path(path) => this.path(path),
234                IconSource::Data(data) => this.data(&data),
235            })
236            .when_some(self.transformation, |this, transformation| {
237                this.with_transformation(transformation)
238            })
239    }
240}
241
242impl Styled for Icon {
243    fn style(&mut self) -> &mut StyleRefinement {
244        &mut self.style
245    }
246
247    fn text_color(mut self, color: impl Into<Hsla>) -> Self {
248        self.text_color = Some(color.into());
249        self
250    }
251}
252
253impl Sizable for Icon {
254    fn with_size(mut self, size: impl Into<Size>) -> Self {
255        self.size = Some(size.into());
256        self
257    }
258}
259
260impl RenderOnce for Icon {
261    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
262        let text_size = window.text_style().font_size.to_pixels(window.rem_size());
263        self.resolved(cx)
264            .into_svg(text_size, window.text_style().color)
265    }
266}
267
268impl From<Icon> for AnyElement {
269    fn from(val: Icon) -> Self {
270        val.into_any_element()
271    }
272}
273
274impl Render for Icon {
275    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
276        let text_size = window.text_style().font_size.to_pixels(window.rem_size());
277        self.clone()
278            .resolved(cx)
279            .into_svg(text_size, cx.theme().foreground)
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286    use gpui::{px, size};
287
288    const SVG: &[u8] = include_bytes!("../../assets/assets/icons/arrow-up.svg");
289
290    #[test]
291    fn test_icon_builder_preserves_owned_data_and_transform_on_clone() {
292        let transformation = Transformation::scale(size(0.5, 0.5))
293            .with_rotation(gpui::radians(std::f32::consts::FRAC_PI_2));
294        let icon = {
295            let bytes = SVG.to_vec();
296            Icon::default()
297                .data(&bytes)
298                .large()
299                .text_color(gpui::red())
300                .transform(transformation)
301        };
302        let cloned = icon.clone();
303        let (IconSource::Data(original), IconSource::Data(copy)) = (&icon.source, &cloned.source)
304        else {
305            panic!("cloning must preserve the data source");
306        };
307        assert_eq!(copy.as_ref(), SVG);
308        assert!(Arc::ptr_eq(original, copy));
309        assert_eq!(cloned.transformation, Some(transformation));
310        assert_eq!(cloned.size, icon.size);
311        assert_eq!(cloned.text_color, icon.text_color);
312
313        let mut svg = cloned.into_svg(px(12.), gpui::blue());
314        assert_eq!(svg.style().text.color, Some(gpui::red()));
315        assert_eq!(svg.style().size.width, Some(gpui::rems(1.5).into()));
316
317        let rotated = icon.rotate(gpui::radians(std::f32::consts::PI)).clone();
318        assert_eq!(
319            rotated.transformation,
320            Some(Transformation::rotate(gpui::radians(std::f32::consts::PI)))
321        );
322    }
323
324    #[test]
325    fn test_icon_source_builders_replace_previous_source() {
326        let icon = Icon::new(IconName::Search).data(SVG);
327        assert!(matches!(icon.source_ref(), IconSource::Data(bytes) if bytes.as_ref() == SVG));
328
329        let icon = icon.path("icons/replacement.svg");
330        assert!(
331            matches!(icon.source_ref(), IconSource::Path(path) if path == "icons/replacement.svg")
332        );
333
334        let icon = icon.data(SVG).data(b"replacement");
335        assert!(
336            matches!(icon.source_ref(), IconSource::Data(bytes) if bytes.as_ref() == b"replacement")
337        );
338
339        let icon = icon.path("");
340        assert!(matches!(icon.source_ref(), IconSource::Path(path) if path.is_empty()));
341    }
342
343    #[gpui::test]
344    fn test_icon_resolver_replaces_the_path_to_draw(cx: &mut gpui::TestAppContext) {
345        fn drawn_path(icon: Icon, cx: &App) -> SharedString {
346            match icon.resolved(cx).source {
347                IconSource::Path(path) => path,
348                IconSource::Data(_) => panic!("resolving must keep the source type"),
349            }
350        }
351
352        cx.update(|cx| {
353            let search = IconName::Search.path();
354            assert_eq!(drawn_path(Icon::new(IconName::Search), cx), search);
355
356            let asked = Rc::new(std::cell::RefCell::new(Vec::<String>::new()));
357            set_icon_resolver(cx, {
358                let asked = asked.clone();
359                let search = search.clone();
360                move |path, _| {
361                    asked.borrow_mut().push(path.to_string());
362                    (path == search.as_ref()).then(|| "icons/alt/search.svg".into())
363                }
364            });
365
366            assert_eq!(
367                drawn_path(Icon::new(IconName::Search), cx),
368                "icons/alt/search.svg"
369            );
370            assert_eq!(
371                drawn_path(Icon::new(IconName::Check), cx),
372                IconName::Check.path()
373            );
374            assert_eq!(drawn_path(Icon::empty(), cx), "");
375            assert!(matches!(
376                Icon::default().data(SVG).resolved(cx).source,
377                IconSource::Data(_)
378            ));
379            assert_eq!(
380                *asked.borrow(),
381                [search.to_string(), IconName::Check.path().to_string()]
382            );
383        });
384    }
385}