Skip to main content

gpui_component/
icon.rs

1use std::sync::Arc;
2
3use crate::{ActiveTheme, Sizable, Size};
4use gpui::{
5    AnyElement, App, AppContext, Context, Entity, Hsla, IntoElement, Pixels, Radians, Render,
6    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
77#[derive(Clone)]
78pub(crate) enum IconSource {
79    Path(SharedString),
80    Data(Arc<[u8]>),
81}
82
83#[derive(Clone, IntoElement)]
84pub struct Icon {
85    style: StyleRefinement,
86    source: IconSource,
87    text_color: Option<Hsla>,
88    size: Option<Size>,
89    transformation: Option<Transformation>,
90}
91
92impl Default for Icon {
93    fn default() -> Self {
94        Self {
95            style: StyleRefinement::default(),
96            source: IconSource::Path("".into()),
97            text_color: None,
98            size: None,
99            transformation: None,
100        }
101    }
102}
103
104impl Icon {
105    pub fn new(icon: impl Into<Icon>) -> Self {
106        icon.into()
107    }
108
109    fn build(name: impl IconNamed) -> Self {
110        Self::default().path(name.path())
111    }
112
113    /// Set the icon path of the Assets bundle
114    ///
115    /// For example: `icons/foo.svg`
116    /// Replaces any previously set path or SVG data.
117    pub fn path(mut self, path: impl Into<SharedString>) -> Self {
118        self.source = IconSource::Path(path.into());
119        self
120    }
121
122    /// Set raw SVG bytes without registering an asset path.
123    ///
124    /// Copies the bytes into shared storage; the input need not be static.
125    /// Cloning the icon shares those bytes. Replaces any previously set path or data.
126    /// Parsing and rendering follow GPUI's SVG behavior.
127    ///
128    /// ```
129    /// use gpui_component::Icon;
130    ///
131    /// let bytes = br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
132    ///     <path d="M4 12h16" stroke="currentColor"/>
133    /// </svg>"#;
134    /// let icon = Icon::default().data(bytes);
135    /// ```
136    pub fn data(mut self, data: &[u8]) -> Self {
137        self.source = IconSource::Data(Arc::from(data));
138        self
139    }
140
141    #[cfg(any(target_os = "macos", target_os = "windows", test))]
142    pub(crate) fn source_ref(&self) -> &IconSource {
143        &self.source
144    }
145
146    /// Whether `other` takes up exactly the same space as this icon. The
147    /// source, color and transformation change only what it paints.
148    pub(crate) fn same_layout(&self, other: &Self) -> bool {
149        self.style == other.style && self.size == other.size
150    }
151
152    /// Create a new view for the icon
153    pub fn view(self, cx: &mut App) -> Entity<Icon> {
154        cx.new(|_| self)
155    }
156
157    /// Set the SVG transformation, replacing any previous transformation or rotation.
158    pub fn transform(mut self, transformation: gpui::Transformation) -> Self {
159        self.transformation = Some(transformation);
160        self
161    }
162
163    pub fn empty() -> Self {
164        Self::default()
165    }
166
167    /// Rotate the icon by the given angle
168    ///
169    /// Replaces any previous transformation or rotation.
170    pub fn rotate(mut self, radians: impl Into<Radians>) -> Self {
171        self.transformation = Some(Transformation::rotate(radians));
172        self
173    }
174
175    fn into_svg(self, text_size: Pixels, fallback_color: Hsla) -> Svg {
176        let text_color = self.text_color.unwrap_or(fallback_color);
177        let has_base_size = self.style.size.width.is_some() || self.style.size.height.is_some();
178
179        svg()
180            .map(|mut this| {
181                *this.style() = self.style;
182                this
183            })
184            .flex_shrink_0()
185            .text_color(text_color)
186            .when(!has_base_size, |this| this.size(text_size))
187            .when_some(self.size, |this, size| match size {
188                Size::Size(px) => this.size(px),
189                Size::XSmall => this.size_3(),
190                Size::Small => this.size_3p5(),
191                Size::Medium => this.size_4(),
192                Size::Large => this.size_6(),
193            })
194            .map(|this| match self.source {
195                IconSource::Path(path) => this.path(path),
196                IconSource::Data(data) => this.data(&data),
197            })
198            .when_some(self.transformation, |this, transformation| {
199                this.with_transformation(transformation)
200            })
201    }
202}
203
204impl Styled for Icon {
205    fn style(&mut self) -> &mut StyleRefinement {
206        &mut self.style
207    }
208
209    fn text_color(mut self, color: impl Into<Hsla>) -> Self {
210        self.text_color = Some(color.into());
211        self
212    }
213}
214
215impl Sizable for Icon {
216    fn with_size(mut self, size: impl Into<Size>) -> Self {
217        self.size = Some(size.into());
218        self
219    }
220}
221
222impl RenderOnce for Icon {
223    fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement {
224        let text_size = window.text_style().font_size.to_pixels(window.rem_size());
225        self.into_svg(text_size, window.text_style().color)
226    }
227}
228
229impl From<Icon> for AnyElement {
230    fn from(val: Icon) -> Self {
231        val.into_any_element()
232    }
233}
234
235impl Render for Icon {
236    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
237        let text_size = window.text_style().font_size.to_pixels(window.rem_size());
238        self.clone().into_svg(text_size, cx.theme().foreground)
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use gpui::{px, size};
246
247    const SVG: &[u8] = include_bytes!("../../assets/assets/icons/arrow-up.svg");
248
249    #[test]
250    fn test_icon_builder_preserves_owned_data_and_transform_on_clone() {
251        let transformation = Transformation::scale(size(0.5, 0.5))
252            .with_rotation(gpui::radians(std::f32::consts::FRAC_PI_2));
253        let icon = {
254            let bytes = SVG.to_vec();
255            Icon::default()
256                .data(&bytes)
257                .large()
258                .text_color(gpui::red())
259                .transform(transformation)
260        };
261        let cloned = icon.clone();
262        let (IconSource::Data(original), IconSource::Data(copy)) = (&icon.source, &cloned.source)
263        else {
264            panic!("cloning must preserve the data source");
265        };
266        assert_eq!(copy.as_ref(), SVG);
267        assert!(Arc::ptr_eq(original, copy));
268        assert_eq!(cloned.transformation, Some(transformation));
269        assert_eq!(cloned.size, icon.size);
270        assert_eq!(cloned.text_color, icon.text_color);
271
272        let mut svg = cloned.into_svg(px(12.), gpui::blue());
273        assert_eq!(svg.style().text.color, Some(gpui::red()));
274        assert_eq!(svg.style().size.width, Some(gpui::rems(1.5).into()));
275
276        let rotated = icon.rotate(gpui::radians(std::f32::consts::PI)).clone();
277        assert_eq!(
278            rotated.transformation,
279            Some(Transformation::rotate(gpui::radians(std::f32::consts::PI)))
280        );
281    }
282
283    #[test]
284    fn test_icon_source_builders_replace_previous_source() {
285        let icon = Icon::new(IconName::Search).data(SVG);
286        assert!(matches!(icon.source_ref(), IconSource::Data(bytes) if bytes.as_ref() == SVG));
287
288        let icon = icon.path("icons/replacement.svg");
289        assert!(
290            matches!(icon.source_ref(), IconSource::Path(path) if path == "icons/replacement.svg")
291        );
292
293        let icon = icon.data(SVG).data(b"replacement");
294        assert!(
295            matches!(icon.source_ref(), IconSource::Data(bytes) if bytes.as_ref() == b"replacement")
296        );
297
298        let icon = icon.path("");
299        assert!(matches!(icon.source_ref(), IconSource::Path(path) if path.is_empty()));
300    }
301}