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
11macro_rules! component_icon_names {
14 ($($name:ident => $path:literal,)*) => {
15 #[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 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
65pub 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
81pub 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
99pub 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 pub fn path(mut self, path: impl Into<SharedString>) -> Self {
149 self.source = IconSource::Path(path.into());
150 self
151 }
152
153 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 pub(crate) fn same_layout(&self, other: &Self) -> bool {
180 self.style == other.style && self.size == other.size
181 }
182
183 pub fn view(self, cx: &mut App) -> Entity<Icon> {
185 cx.new(|_| self)
186 }
187
188 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 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}