Skip to main content

gpui_kit_material/
icons.rs

1//! Material Symbols rendered through Longbridge Kit's Component Icon.
2//!
3//! The compatibility names below select Google symbols; the full catalog and
4//! variable axes are available through [`Symbol`], [`SymbolIcon`] and [`Axes`].
5use gpui::{
6    AbsoluteLength, App, AssetSource, Hsla, IntoElement, RenderOnce, SharedString, Styled, Window,
7};
8pub use gpui_material_symbols::{Asset, Axes, Icon as SymbolIcon, MaterialSymbols, Style, Symbol};
9use std::{borrow::Cow, collections::HashMap, sync::OnceLock};
10
11mod kit_mapping;
12
13macro_rules! icons {
14    ($($name:ident => $symbol:literal;)+) => {
15        #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
16        pub enum IconName { $($name),+ }
17        impl IconName {
18            pub const ALL: &'static [Self] = &[$(Self::$name),+];
19            pub fn symbol(self) -> Symbol {
20                Symbol::named(match self { $(Self::$name => $symbol),+ })
21                    .expect("Material compatibility symbol belongs to the pinned catalog")
22            }
23            /// Stable canonical path at Rounded, FILL=0, weight 400, optical size 24.
24            pub fn path(self) -> &'static str {
25                match self { $(Self::$name => {
26                    static PATH: OnceLock<String> = OnceLock::new();
27                    PATH.get_or_init(|| self.symbol().icon().style(Style::Rounded).asset_path()).as_str()
28                }),+ }
29            }
30            pub fn icon(self) -> Icon { self.into() }
31        }
32    };
33}
34icons! {
35    Check => "check";
36    CheckCircle => "check_circle";
37    ChevronDown => "keyboard_arrow_down";
38    ChevronRight => "chevron_right";
39    ChevronUp => "keyboard_arrow_up";
40    Close => "close";
41    Error => "cancel";
42    Info => "info";
43    LoaderCircle => "progress_activity";
44    Warning => "warning";
45}
46
47/// Register the precomputed mapping for Kit's default internal icon paths.
48/// User-provided raw SVG data deliberately remains under the caller's control.
49pub(crate) fn init(cx: &mut App) {
50    let paths: HashMap<&'static str, SharedString> = kit_mapping::mappings()
51        .map(|(path, icon)| (path, icon.asset_path().into()))
52        .collect();
53    // Material names are also initialized before the first frame.
54    for name in IconName::ALL {
55        let _ = name.path();
56    }
57    gpui_kit::component::set_icon_resolver(cx, move |path, _| paths.get(path).cloned());
58}
59
60/// Material Symbols first, followed by caller-owned application assets.
61/// With default features, symbols are embedded on both desktop and WASM.
62/// Without `embedded-symbols`, supply the sibling library assets before drawing.
63pub struct Assets<F = ()> {
64    inner: gpui_material_symbols::WithMaterialSymbols<F>,
65}
66impl Assets<()> {
67    pub fn new() -> Self {
68        Self {
69            inner: MaterialSymbols.with_fallback(()),
70        }
71    }
72    pub fn with<F: AssetSource>(fallback: F) -> Assets<F> {
73        Assets {
74            inner: MaterialSymbols.with_fallback(fallback),
75        }
76    }
77}
78impl Default for Assets<()> {
79    fn default() -> Self {
80        Self::new()
81    }
82}
83impl<F: AssetSource> AssetSource for Assets<F> {
84    fn load(&self, path: &str) -> gpui::Result<Option<Cow<'static, [u8]>>> {
85        self.inner.load(path)
86    }
87    fn list(&self, prefix: &str) -> gpui::Result<Vec<SharedString>> {
88        self.inner.list(prefix)
89    }
90}
91
92/// Material's 20px default slot and legacy builder API over Component Icon.
93#[derive(IntoElement, Clone)]
94pub struct Icon {
95    inner: gpui_kit::component::Icon,
96}
97impl Icon {
98    pub fn new(path: impl Into<SharedString>) -> Self {
99        Self {
100            inner: gpui_kit::component::Icon::default()
101                .path(path)
102                .size(crate::tokens::layout::ICON_SLOT),
103        }
104    }
105    pub fn size(mut self, size: impl Into<AbsoluteLength>) -> Self {
106        self.inner = self.inner.size(size.into());
107        self
108    }
109    pub fn color(mut self, color: impl Into<Hsla>) -> Self {
110        self.inner = self.inner.text_color(color);
111        self
112    }
113    /// Rotate by radians, using Kit's SVG transformation.
114    pub fn rotate(mut self, radians: f32) -> Self {
115        self.inner = self.inner.rotate(gpui::radians(radians));
116        self
117    }
118}
119impl From<IconName> for Icon {
120    fn from(name: IconName) -> Self {
121        Self::new(name.path())
122    }
123}
124impl From<Symbol> for Icon {
125    fn from(symbol: Symbol) -> Self {
126        Self::from(symbol.icon().style(Style::Rounded))
127    }
128}
129impl From<SymbolIcon> for Icon {
130    fn from(icon: SymbolIcon) -> Self {
131        Self {
132            inner: icon.component().size(crate::tokens::layout::ICON_SLOT),
133        }
134    }
135}
136impl RenderOnce for Icon {
137    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
138        self.inner
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    #[test]
146    fn compatibility_symbols_and_kit_paths_load_from_google_catalog() {
147        let assets = Assets::new();
148        for name in IconName::ALL {
149            let parsed = SymbolIcon::from_asset_path(name.path()).unwrap();
150            assert_eq!(parsed.symbol(), name.symbol());
151            assert_eq!(parsed.visual_style(), Style::Rounded);
152            assert!(
153                assets
154                    .load(name.path())
155                    .unwrap()
156                    .unwrap()
157                    .starts_with(b"<svg")
158            );
159        }
160        for (_, icon) in kit_mapping::mappings() {
161            assert!(assets.load(&icon.asset_path()).unwrap().is_some());
162        }
163        assert!(assets.load("caller/missing.svg").unwrap().is_none());
164    }
165    #[test]
166    fn kit_default_catalog_has_no_silent_mapping_gaps() {
167        use std::collections::BTreeSet;
168        let expected: BTreeSet<_> = include_str!("../../../conformance/kit-default-icons.txt")
169            .lines()
170            .filter(|line| !line.is_empty() && !line.starts_with('#'))
171            .collect();
172        let mapped: BTreeSet<_> = kit_mapping::mappings().map(|(path, _)| path).collect();
173        assert_eq!(mapped.len(), kit_mapping::mappings().count());
174        let unsupported: BTreeSet<_> = kit_mapping::UNSUPPORTED_PATHS.iter().copied().collect();
175        assert!(mapped.is_disjoint(&unsupported));
176        assert_eq!(
177            mapped.union(&unsupported).copied().collect::<BTreeSet<_>>(),
178            expected
179        );
180        assert!(
181            kit_mapping::APPROXIMATE_PATHS
182                .iter()
183                .all(|path| mapped.contains(path))
184        );
185        let filled = kit_mapping::mappings()
186            .find(|(path, _)| *path == "icons/star-fill.svg")
187            .unwrap()
188            .1;
189        assert_eq!(filled.variations().fill(), 1.);
190    }
191
192    #[test]
193    fn source_preserves_fallback_and_invalid_symbol_errors() {
194        struct Caller;
195        impl AssetSource for Caller {
196            fn load(&self, path: &str) -> gpui::Result<Option<Cow<'static, [u8]>>> {
197                match path {
198                    "caller/known" => Ok(Some(Cow::Borrowed(b"caller bytes"))),
199                    "caller/error" => Err(anyhow::anyhow!("caller error")),
200                    _ => Ok(None),
201                }
202            }
203            fn list(&self, _: &str) -> gpui::Result<Vec<SharedString>> {
204                Ok(vec![])
205            }
206        }
207        let assets = Assets::with(Caller);
208        assert_eq!(
209            assets.load("caller/known").unwrap().unwrap().as_ref(),
210            b"caller bytes"
211        );
212        assert!(assets.load("caller/missing").unwrap().is_none());
213        assert_eq!(
214            assets.load("caller/error").unwrap_err().to_string(),
215            "caller error"
216        );
217        assert!(assets.load("material-symbols/invalid.svg").is_err());
218    }
219
220    #[test]
221    fn filled_variants_have_distinct_cache_keys_and_geometry() {
222        let outline = Symbol::named("favorite").unwrap().icon();
223        let filled = outline.filled(true);
224        assert_ne!(outline.asset_path(), filled.asset_path());
225        assert_ne!(outline.to_svg().unwrap(), filled.to_svg().unwrap());
226    }
227}