Skip to main content

ragrig_gpui/
icon.rs

1//! Icons supplied by the host for chat chrome.
2//!
3//! The transcript and the empty state are reusable, so they cannot assume a
4//! particular application icon. A [`ChatIcon`] is either a catalog name, a path
5//! in the application's asset source, or SVG bytes compiled into the binary.
6
7use std::{
8    collections::hash_map::DefaultHasher,
9    fmt,
10    hash::{Hash as _, Hasher as _},
11    sync::Arc,
12};
13
14use gpui_kit::{
15    AnyElement, Image, ImageFormat, ImageSource, IntoElement, Pixels, SharedString, Styled as _,
16    assets::IconName, component::Icon, img, px,
17};
18
19/// An icon the host can plug into [`crate::EmptyState`] or
20/// [`crate::ChatIdentity`].
21#[derive(Clone)]
22pub enum ChatIcon {
23    /// A named icon resolved through the application's asset source.
24    Named(IconName),
25    /// A path in the application's asset source, e.g. `logos/scico.svg`.
26    Path(SharedString),
27    /// SVG bytes carried directly by the element.
28    ///
29    /// This needs no asset registration, which suits a logo that lives in the
30    /// binary: `ChatIcon::data(include_bytes!("../assets/logo.svg"))`.
31    Data(Arc<[u8]>),
32    /// Full-colour PNG bytes.
33    ///
34    /// GPUI paints SVG icons as a monochrome alpha mask. A multi-colour mark
35    /// must therefore be a raster image; [`Self::png`] builds one.
36    Png(Arc<Image>),
37}
38
39impl ChatIcon {
40    /// Wrap SVG bytes. The bytes are copied once into shared storage and then
41    /// reused across renders.
42    pub fn data(bytes: impl AsRef<[u8]>) -> Self {
43        Self::Data(Arc::from(bytes.as_ref()))
44    }
45
46    /// Wrap a path in the application's asset source.
47    pub fn path(path: impl Into<SharedString>) -> Self {
48        Self::Path(path.into())
49    }
50
51    /// Wrap full-colour PNG bytes for an image element.
52    pub fn png(bytes: impl AsRef<[u8]>) -> Self {
53        let bytes = bytes.as_ref();
54        let mut hasher = DefaultHasher::new();
55        bytes.hash(&mut hasher);
56        Self::Png(Arc::new(Image {
57            format: ImageFormat::Png,
58            bytes: bytes.to_vec(),
59            id: hasher.finish(),
60        }))
61    }
62
63    /// The monochrome icon, when this mark is an SVG icon.
64    ///
65    /// Buttons need an [`Icon`]; a raster mark has no monochrome form, so the
66    /// caller should fall back to a catalog icon.
67    pub(crate) fn as_icon(&self) -> Option<Icon> {
68        match self {
69            Self::Named(name) => Some(Icon::new(*name)),
70            Self::Path(path) => Some(Icon::default().path(path.clone())),
71            Self::Data(bytes) => Some(Icon::default().data(bytes)),
72            Self::Png(_) => None,
73        }
74    }
75
76    /// Render the mark at an explicit size, or at the surrounding text size
77    /// for SVG icons. Raster marks default to 24 px when no size is given.
78    pub(crate) fn element(&self, size: Option<Pixels>) -> AnyElement {
79        if let Self::Png(image) = self {
80            return img(ImageSource::Image(image.clone()))
81                .size(size.unwrap_or(px(24.)))
82                .into_any_element();
83        }
84
85        let icon = self
86            .as_icon()
87            .unwrap_or_else(|| Icon::new(IconName::Sparkles));
88        match size {
89            Some(size) => icon.size(size).into_any_element(),
90            None => icon.into_any_element(),
91        }
92    }
93}
94
95impl From<IconName> for ChatIcon {
96    fn from(name: IconName) -> Self {
97        Self::Named(name)
98    }
99}
100
101impl PartialEq for ChatIcon {
102    fn eq(&self, other: &Self) -> bool {
103        match (self, other) {
104            (Self::Named(a), Self::Named(b)) => a == b,
105            (Self::Path(a), Self::Path(b)) => a == b,
106            (Self::Data(a), Self::Data(b)) => a == b,
107            (Self::Png(a), Self::Png(b)) => a == b,
108            _ => false,
109        }
110    }
111}
112
113impl Default for ChatIcon {
114    fn default() -> Self {
115        Self::Named(IconName::Sparkles)
116    }
117}
118
119impl fmt::Debug for ChatIcon {
120    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121        match self {
122            Self::Named(name) => f.debug_tuple("Named").field(name).finish(),
123            Self::Path(path) => f.debug_tuple("Path").field(path).finish(),
124            Self::Data(bytes) => f
125                .debug_tuple("Data")
126                .field(&format_args!("{} bytes", bytes.len()))
127                .finish(),
128            Self::Png(image) => f
129                .debug_tuple("Png")
130                .field(&format_args!("{} bytes", image.bytes.len()))
131                .finish(),
132        }
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use gpui_kit::SvgRenderer;
140
141    const SVG: &[u8] = br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
142        <circle cx="8" cy="8" r="7" fill="currentColor"/>
143    </svg>"#;
144
145    #[test]
146    fn inline_svg_icons_render_visible_pixels() {
147        let icon = ChatIcon::data(SVG);
148        let ChatIcon::Data(bytes) = &icon else {
149            panic!("expected inline data, got {icon:?}");
150        };
151        let renderer = SvgRenderer::new(Arc::new(gpui_kit::assets::AllAssets));
152        let parsed = renderer.parse_svg(bytes).expect("parse inline svg");
153        let image = renderer.render_parsed(&parsed, 2.0).expect("render svg");
154        let pixels = image.as_bytes(0).expect("frame");
155        assert!(
156            pixels.as_chunks::<4>().0.iter().any(|pixel| pixel[3] != 0),
157            "inline SVG rasterised to a blank image"
158        );
159    }
160
161    #[test]
162    fn a_catalog_icon_converts() {
163        let icon: ChatIcon = IconName::Bot.into();
164        assert!(matches!(icon, ChatIcon::Named(IconName::Bot)));
165    }
166
167    #[test]
168    fn a_raster_mark_has_no_monochrome_form() {
169        let icon = ChatIcon::png(b"not decoded here");
170        assert!(matches!(icon, ChatIcon::Png(_)));
171        assert!(icon.as_icon().is_none());
172        assert_eq!(icon, ChatIcon::png(b"not decoded here"));
173    }
174}