gpui_kit_material/
icons.rs1use 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 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
47pub(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 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
60pub 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#[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 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}