1use gpui::{
19 Animation, AnimationExt, Hsla, Image, ImageFormat, ImageSource, Svg, Transformation, percentage,
20};
21use gpui_component::IconName;
22use native_theme::{AnimatedIcon, IconData, IconProvider, IconRole, load_custom_icon};
23use std::sync::Arc;
24use std::time::Duration;
25
26#[derive(Clone)]
31pub struct AnimatedImageSources {
32 pub sources: Vec<ImageSource>,
34 pub frame_duration_ms: u32,
36}
37
38impl std::fmt::Debug for AnimatedImageSources {
39 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40 f.debug_struct("AnimatedImageSources")
41 .field("frame_count", &self.sources.len())
42 .field("frame_duration_ms", &self.frame_duration_ms)
43 .finish()
44 }
45}
46
47#[must_use]
73pub fn icon_name(role: IconRole) -> Option<IconName> {
74 Some(match role {
75 IconRole::DialogWarning => IconName::TriangleAlert,
77 IconRole::DialogError => IconName::CircleX,
78 IconRole::DialogInfo => IconName::Info,
79 IconRole::DialogSuccess => IconName::CircleCheck,
80
81 IconRole::WindowClose => IconName::WindowClose,
83 IconRole::WindowMinimize => IconName::WindowMinimize,
84 IconRole::WindowMaximize => IconName::WindowMaximize,
85 IconRole::WindowRestore => IconName::WindowRestore,
86
87 IconRole::ActionDelete => IconName::Delete,
89 IconRole::ActionCopy => IconName::Copy,
90 IconRole::ActionUndo => IconName::Undo2,
91 IconRole::ActionRedo => IconName::Redo2,
92 IconRole::ActionSearch => IconName::Search,
93 IconRole::ActionSettings => IconName::Settings,
94 IconRole::ActionAdd => IconName::Plus,
95 IconRole::ActionRemove => IconName::Minus,
96
97 IconRole::NavBack => IconName::ChevronLeft,
99 IconRole::NavForward => IconName::ChevronRight,
100 IconRole::NavUp => IconName::ChevronUp,
101 IconRole::NavDown => IconName::ChevronDown,
102 IconRole::NavMenu => IconName::Menu,
103
104 IconRole::FileGeneric => IconName::File,
106 IconRole::FolderClosed => IconName::FolderClosed,
107 IconRole::FolderOpen => IconName::FolderOpen,
108 IconRole::TrashEmpty => IconName::Delete,
109
110 IconRole::StatusBusy => IconName::Loader,
112 IconRole::StatusCheck => IconName::Check,
113 IconRole::StatusError => IconName::CircleX,
114
115 IconRole::UserAccount => IconName::User,
117 IconRole::Notification => IconName::Bell,
118
119 _ => return None,
121 })
122}
123
124#[must_use]
131pub fn lucide_name_for_gpui_icon(icon: IconName) -> &'static str {
132 match icon {
133 IconName::ALargeSmall => "a-large-small",
134 IconName::ArrowDown => "arrow-down",
135 IconName::ArrowLeft => "arrow-left",
136 IconName::ArrowRight => "arrow-right",
137 IconName::ArrowUp => "arrow-up",
138 IconName::Asterisk => "asterisk",
139 IconName::Bell => "bell",
140 IconName::BookOpen => "book-open",
141 IconName::Bot => "bot",
142 IconName::Building2 => "building-2",
143 IconName::Calendar => "calendar",
144 IconName::CaseSensitive => "case-sensitive",
145 IconName::ChartPie => "chart-pie",
146 IconName::Check => "check",
147 IconName::ChevronDown => "chevron-down",
148 IconName::ChevronLeft => "chevron-left",
149 IconName::ChevronRight => "chevron-right",
150 IconName::ChevronsUpDown => "chevrons-up-down",
151 IconName::ChevronUp => "chevron-up",
152 IconName::CircleCheck => "circle-check",
153 IconName::CircleUser => "circle-user",
154 IconName::CircleX => "circle-x",
155 IconName::Close => "close",
156 IconName::Copy => "copy",
157 IconName::Dash => "dash",
158 IconName::Delete => "delete",
159 IconName::Ellipsis => "ellipsis",
160 IconName::EllipsisVertical => "ellipsis-vertical",
161 IconName::ExternalLink => "external-link",
162 IconName::Eye => "eye",
163 IconName::EyeOff => "eye-off",
164 IconName::File => "file",
165 IconName::Folder => "folder",
166 IconName::FolderClosed => "folder-closed",
167 IconName::FolderOpen => "folder-open",
168 IconName::Frame => "frame",
169 IconName::GalleryVerticalEnd => "gallery-vertical-end",
170 IconName::GitHub => "github",
171 IconName::Globe => "globe",
172 IconName::Heart => "heart",
173 IconName::HeartOff => "heart-off",
174 IconName::Inbox => "inbox",
175 IconName::Info => "info",
176 IconName::Inspector => "inspect",
177 IconName::LayoutDashboard => "layout-dashboard",
178 IconName::Loader => "loader",
179 IconName::LoaderCircle => "loader-circle",
180 IconName::Map => "map",
181 IconName::Maximize => "maximize",
182 IconName::Menu => "menu",
183 IconName::Minimize => "minimize",
184 IconName::Minus => "minus",
185 IconName::Moon => "moon",
186 IconName::Palette => "palette",
187 IconName::PanelBottom => "panel-bottom",
188 IconName::PanelBottomOpen => "panel-bottom-open",
189 IconName::PanelLeft => "panel-left",
190 IconName::PanelLeftClose => "panel-left-close",
191 IconName::PanelLeftOpen => "panel-left-open",
192 IconName::PanelRight => "panel-right",
193 IconName::PanelRightClose => "panel-right-close",
194 IconName::PanelRightOpen => "panel-right-open",
195 IconName::Plus => "plus",
196 IconName::Redo => "redo",
197 IconName::Redo2 => "redo-2",
198 IconName::Replace => "replace",
199 IconName::ResizeCorner => "resize-corner",
200 IconName::Search => "search",
201 IconName::Settings => "settings",
202 IconName::Settings2 => "settings-2",
203 IconName::SortAscending => "sort-ascending",
204 IconName::SortDescending => "sort-descending",
205 IconName::SquareTerminal => "square-terminal",
206 IconName::Star => "star",
207 IconName::StarOff => "star-off",
208 IconName::Sun => "sun",
209 IconName::ThumbsDown => "thumbs-down",
210 IconName::ThumbsUp => "thumbs-up",
211 IconName::TriangleAlert => "triangle-alert",
212 IconName::Undo => "undo",
213 IconName::Undo2 => "undo-2",
214 IconName::User => "user",
215 IconName::WindowClose => "window-close",
216 IconName::WindowMaximize => "window-maximize",
217 IconName::WindowMinimize => "window-minimize",
218 IconName::WindowRestore => "window-restore",
219 }
220}
221
222#[must_use]
229pub fn material_name_for_gpui_icon(icon: IconName) -> &'static str {
230 match icon {
231 IconName::ALargeSmall => "font_size",
232 IconName::ArrowDown => "arrow_downward",
233 IconName::ArrowLeft => "arrow_back",
234 IconName::ArrowRight => "arrow_forward",
235 IconName::ArrowUp => "arrow_upward",
236 IconName::Asterisk => "emergency",
237 IconName::Bell => "notifications",
238 IconName::BookOpen => "menu_book",
239 IconName::Bot => "smart_toy",
240 IconName::Building2 => "apartment",
241 IconName::Calendar => "calendar_today",
242 IconName::CaseSensitive => "match_case",
243 IconName::ChartPie => "pie_chart",
244 IconName::Check => "check",
245 IconName::ChevronDown => "expand_more",
246 IconName::ChevronLeft => "chevron_left",
247 IconName::ChevronRight => "chevron_right",
248 IconName::ChevronsUpDown => "unfold_more",
249 IconName::ChevronUp => "expand_less",
250 IconName::CircleCheck => "check_circle",
251 IconName::CircleUser => "account_circle",
252 IconName::CircleX => "cancel",
253 IconName::Close => "close",
254 IconName::Copy => "content_copy",
255 IconName::Dash => "remove",
256 IconName::Delete => "delete",
257 IconName::Ellipsis => "more_horiz",
258 IconName::EllipsisVertical => "more_vert",
259 IconName::ExternalLink => "open_in_new",
260 IconName::Eye => "visibility",
261 IconName::EyeOff => "visibility_off",
262 IconName::File => "description",
263 IconName::Folder => "folder",
264 IconName::FolderClosed => "folder",
265 IconName::FolderOpen => "folder_open",
266 IconName::Frame => "crop_free",
267 IconName::GalleryVerticalEnd => "view_carousel",
268 IconName::GitHub => "code",
269 IconName::Globe => "language",
270 IconName::Heart => "favorite",
271 IconName::HeartOff => "heart_broken",
272 IconName::Inbox => "inbox",
273 IconName::Info => "info",
274 IconName::Inspector => "developer_mode",
275 IconName::LayoutDashboard => "dashboard",
276 IconName::Loader => "progress_activity",
277 IconName::LoaderCircle => "autorenew",
278 IconName::Map => "map",
279 IconName::Maximize => "open_in_full",
280 IconName::Menu => "menu",
281 IconName::Minimize => "minimize",
282 IconName::Minus => "remove",
283 IconName::Moon => "dark_mode",
284 IconName::Palette => "palette",
285 IconName::PanelBottom => "dock_to_bottom",
286 IconName::PanelBottomOpen => "web_asset",
287 IconName::PanelLeft => "side_navigation",
288 IconName::PanelLeftClose => "left_panel_close",
289 IconName::PanelLeftOpen => "left_panel_open",
290 IconName::PanelRight => "right_panel_close",
291 IconName::PanelRightClose => "right_panel_close",
292 IconName::PanelRightOpen => "right_panel_open",
293 IconName::Plus => "add",
294 IconName::Redo => "redo",
295 IconName::Redo2 => "redo",
296 IconName::Replace => "find_replace",
297 IconName::ResizeCorner => "drag_indicator",
298 IconName::Search => "search",
299 IconName::Settings => "settings",
300 IconName::Settings2 => "tune",
301 IconName::SortAscending => "arrow_upward",
302 IconName::SortDescending => "arrow_downward",
303 IconName::SquareTerminal => "terminal",
304 IconName::Star => "star",
305 IconName::StarOff => "star_border",
306 IconName::Sun => "light_mode",
307 IconName::ThumbsDown => "thumb_down",
308 IconName::ThumbsUp => "thumb_up",
309 IconName::TriangleAlert => "warning",
310 IconName::Undo => "undo",
311 IconName::Undo2 => "undo",
312 IconName::User => "person",
313 IconName::WindowClose => "close",
314 IconName::WindowMaximize => "open_in_full",
315 IconName::WindowMinimize => "minimize",
316 IconName::WindowRestore => "close_fullscreen",
317 }
318}
319
320#[cfg(target_os = "linux")]
341#[must_use]
342pub fn freedesktop_name_for_gpui_icon(
343 icon: IconName,
344 de: native_theme::LinuxDesktop,
345) -> &'static str {
346 use native_theme::LinuxDesktop;
347
348 let is_gtk = matches!(
350 de,
351 LinuxDesktop::Gnome
352 | LinuxDesktop::Budgie
353 | LinuxDesktop::Cinnamon
354 | LinuxDesktop::Mate
355 | LinuxDesktop::Xfce
356 );
357
358 match icon {
359 IconName::BookOpen => "help-contents", IconName::Bot => "face-smile", IconName::ChevronDown => "go-down", IconName::ChevronLeft => "go-previous", IconName::ChevronRight => "go-next", IconName::ChevronUp => "go-up", IconName::CircleX => "dialog-error", IconName::Copy => "edit-copy", IconName::Dash => "list-remove", IconName::Delete => "edit-delete", IconName::File => "text-x-generic", IconName::Folder => "folder", IconName::FolderClosed => "folder", IconName::FolderOpen => "folder-open", IconName::HeartOff => "non-starred", IconName::Info => "dialog-information", IconName::LayoutDashboard => "view-grid", IconName::Map => "find-location", IconName::Maximize => "view-fullscreen", IconName::Menu => "open-menu", IconName::Minimize => "window-minimize", IconName::Minus => "list-remove", IconName::Moon => "weather-clear-night", IconName::Plus => "list-add", IconName::Redo => "edit-redo", IconName::Redo2 => "edit-redo", IconName::Replace => "edit-find-replace", IconName::Search => "edit-find", IconName::Settings => "preferences-system", IconName::SortAscending => "view-sort-ascending", IconName::SortDescending => "view-sort-descending", IconName::SquareTerminal => "utilities-terminal", IconName::Star => "starred", IconName::StarOff => "non-starred", IconName::Sun => "weather-clear", IconName::TriangleAlert => "dialog-warning", IconName::Undo => "edit-undo", IconName::Undo2 => "edit-undo", IconName::User => "system-users", IconName::WindowClose => "window-close", IconName::WindowMaximize => "window-maximize", IconName::WindowMinimize => "window-minimize", IconName::WindowRestore => "window-restore", IconName::ArrowDown => {
406 if is_gtk {
407 "go-bottom"
408 } else {
409 "go-down-skip"
410 }
411 } IconName::ArrowLeft => {
413 if is_gtk {
414 "go-first"
415 } else {
416 "go-previous-skip"
417 }
418 } IconName::ArrowRight => {
420 if is_gtk {
421 "go-last"
422 } else {
423 "go-next-skip"
424 }
425 } IconName::ArrowUp => {
427 if is_gtk {
428 "go-top"
429 } else {
430 "go-up-skip"
431 }
432 } IconName::Calendar => {
434 if is_gtk {
435 "x-office-calendar"
436 } else {
437 "view-calendar"
438 }
439 } IconName::Check => {
441 if is_gtk {
442 "object-select"
443 } else {
444 "dialog-ok"
445 }
446 } IconName::CircleCheck => {
448 if is_gtk {
449 "object-select"
450 } else {
451 "emblem-ok-symbolic"
452 }
453 } IconName::CircleUser => {
455 if is_gtk {
456 "avatar-default"
457 } else {
458 "user-identity"
459 }
460 } IconName::Close => {
462 if is_gtk {
463 "window-close"
464 } else {
465 "tab-close"
466 }
467 } IconName::Ellipsis => {
469 if is_gtk {
470 "view-more-horizontal"
471 } else {
472 "overflow-menu"
473 }
474 } IconName::EllipsisVertical => {
476 if is_gtk {
477 "view-more"
478 } else {
479 "overflow-menu"
480 }
481 } IconName::Eye => {
483 if is_gtk {
484 "view-reveal"
485 } else {
486 "view-visible"
487 }
488 } IconName::EyeOff => {
490 if is_gtk {
491 "view-conceal"
492 } else {
493 "view-hidden"
494 }
495 } IconName::Frame => {
497 if is_gtk {
498 "selection-mode"
499 } else {
500 "select-rectangular"
501 }
502 } IconName::Heart => {
504 if is_gtk {
505 "starred"
506 } else {
507 "emblem-favorite"
508 }
509 } IconName::Loader => {
511 if is_gtk {
512 "content-loading"
513 } else {
514 "process-working"
515 }
516 } IconName::LoaderCircle => {
518 if is_gtk {
519 "content-loading"
520 } else {
521 "process-working"
522 }
523 } IconName::Palette => {
525 if is_gtk {
526 "color-select"
527 } else {
528 "palette"
529 }
530 } IconName::PanelLeft => {
532 if is_gtk {
533 "sidebar-show"
534 } else {
535 "sidebar-expand-left"
536 }
537 } IconName::PanelLeftClose => {
539 if is_gtk {
540 "sidebar-show"
541 } else {
542 "view-left-close"
543 }
544 } IconName::PanelLeftOpen => {
546 if is_gtk {
547 "sidebar-show"
548 } else {
549 "view-left-new"
550 }
551 } IconName::PanelRight => {
553 if is_gtk {
554 "sidebar-show-right"
555 } else {
556 "view-right-new"
557 }
558 } IconName::PanelRightClose => {
560 if is_gtk {
561 "sidebar-show-right"
562 } else {
563 "view-right-close"
564 }
565 } IconName::PanelRightOpen => {
567 if is_gtk {
568 "sidebar-show-right"
569 } else {
570 "view-right-new"
571 }
572 } IconName::ResizeCorner => {
574 if is_gtk {
575 "list-drag-handle"
576 } else {
577 "drag-handle"
578 }
579 } IconName::Settings2 => {
581 if is_gtk {
582 "preferences-other"
583 } else {
584 "configure"
585 }
586 } IconName::ALargeSmall => {
590 if is_gtk {
591 "zoom-in"
592 } else {
593 "format-font-size-more"
594 }
595 } IconName::Asterisk => {
597 if is_gtk {
598 "starred"
599 } else {
600 "rating"
601 }
602 } IconName::Bell => {
604 if is_gtk {
605 "alarm"
606 } else {
607 "notification-active"
608 }
609 } IconName::Building2 => {
611 if is_gtk {
612 "network-workgroup"
613 } else {
614 "applications-office"
615 }
616 } IconName::CaseSensitive => {
618 if is_gtk {
619 "format-text-rich"
620 } else {
621 "format-text-uppercase"
622 }
623 } IconName::ChartPie => {
625 if is_gtk {
626 "x-office-spreadsheet"
627 } else {
628 "office-chart-pie"
629 }
630 } IconName::ChevronsUpDown => {
632 if is_gtk {
633 "list-drag-handle"
634 } else {
635 "handle-sort"
636 }
637 } IconName::ExternalLink => {
639 if is_gtk {
640 "insert-link"
641 } else {
642 "external-link"
643 }
644 } IconName::GalleryVerticalEnd => {
646 if is_gtk {
647 "view-paged"
648 } else {
649 "view-list-icons"
650 }
651 } IconName::GitHub => {
653 if is_gtk {
654 "applications-engineering"
655 } else {
656 "vcs-branch"
657 }
658 } IconName::Globe => {
660 if is_gtk {
661 "web-browser"
662 } else {
663 "globe"
664 }
665 } IconName::Inbox => {
667 if is_gtk {
668 "mail-send-receive"
669 } else {
670 "mail-folder-inbox"
671 }
672 } IconName::Inspector => {
674 if is_gtk {
675 "preferences-system-details"
676 } else {
677 "code-context"
678 }
679 } IconName::PanelBottom => {
681 if is_gtk {
682 "view-dual"
683 } else {
684 "view-split-top-bottom"
685 }
686 } IconName::PanelBottomOpen => {
688 if is_gtk {
689 "view-dual"
690 } else {
691 "view-split-top-bottom"
692 }
693 } IconName::ThumbsDown => {
695 if is_gtk {
696 "process-stop"
697 } else {
698 "rating-unrated"
699 }
700 } IconName::ThumbsUp => {
702 if is_gtk {
703 "checkbox-checked"
704 } else {
705 "approved"
706 }
707 } }
709}
710
711const SVG_RASTERIZE_SIZE: u32 = 48;
716
717#[must_use]
745pub fn to_image_source(
746 data: &IconData,
747 color: Option<Hsla>,
748 size: Option<u32>,
749) -> Option<ImageSource> {
750 let raster_size = size.unwrap_or(SVG_RASTERIZE_SIZE);
751 match data {
752 IconData::Svg(bytes) => {
753 if let Some(c) = color {
754 let colored = colorize_svg(bytes, c);
755 svg_to_bmp_source(&colored, raster_size)
756 } else {
757 svg_to_bmp_source(bytes, raster_size)
758 }
759 }
760 IconData::Rgba {
761 width,
762 height,
763 data,
764 } => {
765 let bmp = encode_rgba_as_bmp(*width, *height, data)?;
766 let image = Image::from_bytes(ImageFormat::Bmp, bmp);
767 Some(ImageSource::Image(Arc::new(image)))
768 }
769 _ => None,
770 }
771}
772
773#[must_use]
784pub fn into_image_source(
785 data: IconData,
786 color: Option<Hsla>,
787 size: Option<u32>,
788) -> Option<ImageSource> {
789 to_image_source(&data, color, size)
790}
791
792#[must_use]
802pub fn custom_icon_to_image_source(
803 provider: &(impl IconProvider + ?Sized),
804 icon_set: native_theme::IconSet,
805 color: Option<Hsla>,
806 size: Option<u32>,
807) -> Option<ImageSource> {
808 let data = load_custom_icon(provider, icon_set)?;
809 to_image_source(&data, color, size)
810}
811
812#[must_use]
831pub fn bundled_icon_to_image_source(
832 icon: IconName,
833 icon_set: native_theme::IconSet,
834 color: Option<Hsla>,
835 size: Option<u32>,
836) -> Option<ImageSource> {
837 let name = match icon_set {
838 native_theme::IconSet::Lucide => lucide_name_for_gpui_icon(icon),
839 native_theme::IconSet::Material => material_name_for_gpui_icon(icon),
840 _ => return None,
841 };
842 let svg = native_theme::bundled_icon_by_name(name, icon_set)?;
843 let data = IconData::Svg(svg.to_vec());
844 to_image_source(&data, color, size)
845}
846
847#[must_use]
880pub fn animated_frames_to_image_sources(
881 anim: &AnimatedIcon,
882 color: Option<Hsla>,
883 size: Option<u32>,
884) -> Option<AnimatedImageSources> {
885 match anim {
886 AnimatedIcon::Frames {
887 frames,
888 frame_duration_ms,
889 } => {
890 let sources: Vec<ImageSource> = frames
891 .iter()
892 .filter_map(|f| to_image_source(f, color, size))
893 .collect();
894 if sources.is_empty() {
895 None
896 } else {
897 Some(AnimatedImageSources {
898 sources,
899 frame_duration_ms: *frame_duration_ms,
900 })
901 }
902 }
903 _ => None,
904 }
905}
906
907#[must_use]
933pub fn with_spin_animation(
934 element: Svg,
935 animation_id: impl Into<gpui::ElementId>,
936 duration_ms: u32,
937) -> impl gpui::IntoElement {
938 element.with_animation(
939 animation_id,
940 Animation::new(Duration::from_millis(duration_ms as u64)).repeat(),
941 |el, delta| el.with_transformation(Transformation::rotate(percentage(delta))),
942 )
943}
944
945fn svg_to_bmp_source(svg_bytes: &[u8], size: u32) -> Option<ImageSource> {
953 let Ok(IconData::Rgba {
954 width,
955 height,
956 data,
957 }) = native_theme::rasterize::rasterize_svg(svg_bytes, size)
958 else {
959 return None;
960 };
961 let bmp = encode_rgba_as_bmp(width, height, &data)?;
962 let image = Image::from_bytes(ImageFormat::Bmp, bmp);
963 Some(ImageSource::Image(Arc::new(image)))
964}
965
966fn colorize_svg(svg_bytes: &[u8], color: Hsla) -> Vec<u8> {
980 let rgba: gpui::Rgba = color.into();
981 let r = (rgba.r.clamp(0.0, 1.0) * 255.0).round() as u8;
982 let g = (rgba.g.clamp(0.0, 1.0) * 255.0).round() as u8;
983 let b = (rgba.b.clamp(0.0, 1.0) * 255.0).round() as u8;
984 let hex = format!("#{:02x}{:02x}{:02x}", r, g, b);
985
986 let Ok(svg_str) = std::str::from_utf8(svg_bytes) else {
987 return svg_bytes.to_vec();
990 };
991
992 if svg_str.contains("currentColor") {
994 return svg_str.replace("currentColor", &hex).into_bytes();
995 }
996
997 let fill_hex = format!("fill=\"{}\"", hex);
999 let replaced = svg_str
1000 .replace("fill=\"black\"", &fill_hex)
1001 .replace("fill=\"#000000\"", &fill_hex)
1002 .replace("fill=\"#000\"", &fill_hex);
1003 if replaced != svg_str {
1004 return replaced.into_bytes();
1005 }
1006
1007 if let Some(pos) = svg_str.find("<svg")
1010 && let Some(close) = svg_str[pos..].find('>')
1011 {
1012 let tag_end = pos + close;
1013 let tag = &svg_str[pos..tag_end];
1014 if !tag.contains("fill=") {
1015 let inject_pos = if tag_end > 0 && svg_str.as_bytes()[tag_end - 1] == b'/' {
1017 tag_end - 1
1018 } else {
1019 tag_end
1020 };
1021 let mut result = String::with_capacity(svg_str.len() + 20);
1022 result.push_str(&svg_str[..inject_pos]);
1023 result.push_str(&format!(" fill=\"{}\"", hex));
1024 result.push_str(&svg_str[inject_pos..]);
1025 return result.into_bytes();
1026 }
1027 }
1028
1029 svg_bytes.to_vec()
1031}
1032
1033fn encode_rgba_as_bmp(width: u32, height: u32, rgba: &[u8]) -> Option<Vec<u8>> {
1042 if width == 0 || height == 0 {
1043 return None;
1044 }
1045 let pixel_data_size = (width as usize)
1046 .checked_mul(height as usize)?
1047 .checked_mul(4)?;
1048 if rgba.len() != pixel_data_size {
1049 return None;
1050 }
1051 let header_size: usize = 14; let dib_header_size: usize = 108; let file_size = u32::try_from(header_size + dib_header_size + pixel_data_size).ok()?;
1054
1055 let mut buf = Vec::with_capacity(file_size as usize);
1056
1057 let dib_header_u32 = dib_header_size as u32;
1058 let pixel_data_u32 = pixel_data_size as u32;
1059
1060 buf.extend_from_slice(b"BM"); buf.extend_from_slice(&file_size.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&(header_size as u32 + dib_header_u32).to_le_bytes()); buf.extend_from_slice(&dib_header_u32.to_le_bytes()); buf.extend_from_slice(&(width as i32).to_le_bytes()); buf.extend_from_slice(&(-(height as i32)).to_le_bytes()); buf.extend_from_slice(&1u16.to_le_bytes()); buf.extend_from_slice(&32u16.to_le_bytes()); buf.extend_from_slice(&3u32.to_le_bytes()); buf.extend_from_slice(&pixel_data_u32.to_le_bytes()); buf.extend_from_slice(&2835u32.to_le_bytes()); buf.extend_from_slice(&2835u32.to_le_bytes()); buf.extend_from_slice(&0u32.to_le_bytes()); buf.extend_from_slice(&0u32.to_le_bytes()); buf.extend_from_slice(&0x00FF0000u32.to_le_bytes()); buf.extend_from_slice(&0x0000FF00u32.to_le_bytes()); buf.extend_from_slice(&0x000000FFu32.to_le_bytes()); buf.extend_from_slice(&0xFF000000u32.to_le_bytes()); buf.extend_from_slice(&0x73524742u32.to_le_bytes()); buf.extend_from_slice(&[0u8; 36]);
1092
1093 buf.extend_from_slice(&0u32.to_le_bytes());
1095 buf.extend_from_slice(&0u32.to_le_bytes());
1096 buf.extend_from_slice(&0u32.to_le_bytes());
1097
1098 for pixel in rgba.chunks_exact(4) {
1100 buf.push(pixel[2]); buf.push(pixel[1]); buf.push(pixel[0]); buf.push(pixel[3]); }
1105
1106 Some(buf)
1107}
1108
1109#[cfg(test)]
1110#[allow(clippy::unwrap_used, clippy::expect_used)]
1111mod tests {
1112 use super::*;
1113
1114 pub(super) const ALL_ICON_NAMES: &[IconName] = &[
1115 IconName::ALargeSmall,
1116 IconName::ArrowDown,
1117 IconName::ArrowLeft,
1118 IconName::ArrowRight,
1119 IconName::ArrowUp,
1120 IconName::Asterisk,
1121 IconName::Bell,
1122 IconName::BookOpen,
1123 IconName::Bot,
1124 IconName::Building2,
1125 IconName::Calendar,
1126 IconName::CaseSensitive,
1127 IconName::ChartPie,
1128 IconName::Check,
1129 IconName::ChevronDown,
1130 IconName::ChevronLeft,
1131 IconName::ChevronRight,
1132 IconName::ChevronsUpDown,
1133 IconName::ChevronUp,
1134 IconName::CircleCheck,
1135 IconName::CircleUser,
1136 IconName::CircleX,
1137 IconName::Close,
1138 IconName::Copy,
1139 IconName::Dash,
1140 IconName::Delete,
1141 IconName::Ellipsis,
1142 IconName::EllipsisVertical,
1143 IconName::ExternalLink,
1144 IconName::Eye,
1145 IconName::EyeOff,
1146 IconName::File,
1147 IconName::Folder,
1148 IconName::FolderClosed,
1149 IconName::FolderOpen,
1150 IconName::Frame,
1151 IconName::GalleryVerticalEnd,
1152 IconName::GitHub,
1153 IconName::Globe,
1154 IconName::Heart,
1155 IconName::HeartOff,
1156 IconName::Inbox,
1157 IconName::Info,
1158 IconName::Inspector,
1159 IconName::LayoutDashboard,
1160 IconName::Loader,
1161 IconName::LoaderCircle,
1162 IconName::Map,
1163 IconName::Maximize,
1164 IconName::Menu,
1165 IconName::Minimize,
1166 IconName::Minus,
1167 IconName::Moon,
1168 IconName::Palette,
1169 IconName::PanelBottom,
1170 IconName::PanelBottomOpen,
1171 IconName::PanelLeft,
1172 IconName::PanelLeftClose,
1173 IconName::PanelLeftOpen,
1174 IconName::PanelRight,
1175 IconName::PanelRightClose,
1176 IconName::PanelRightOpen,
1177 IconName::Plus,
1178 IconName::Redo,
1179 IconName::Redo2,
1180 IconName::Replace,
1181 IconName::ResizeCorner,
1182 IconName::Search,
1183 IconName::Settings,
1184 IconName::Settings2,
1185 IconName::SortAscending,
1186 IconName::SortDescending,
1187 IconName::SquareTerminal,
1188 IconName::Star,
1189 IconName::StarOff,
1190 IconName::Sun,
1191 IconName::ThumbsDown,
1192 IconName::ThumbsUp,
1193 IconName::TriangleAlert,
1194 IconName::Undo,
1195 IconName::Undo2,
1196 IconName::User,
1197 IconName::WindowClose,
1198 IconName::WindowMaximize,
1199 IconName::WindowMinimize,
1200 IconName::WindowRestore,
1201 ];
1202
1203 #[test]
1204 fn all_icons_have_lucide_mapping() {
1205 for icon in ALL_ICON_NAMES {
1206 let name = lucide_name_for_gpui_icon(icon.clone());
1207 assert!(
1208 !name.is_empty(),
1209 "Empty Lucide mapping for an IconName variant",
1210 );
1211 }
1212 }
1213
1214 #[test]
1215 fn all_icons_have_material_mapping() {
1216 for icon in ALL_ICON_NAMES {
1217 let name = material_name_for_gpui_icon(icon.clone());
1218 assert!(
1219 !name.is_empty(),
1220 "Empty Material mapping for an IconName variant",
1221 );
1222 }
1223 }
1224
1225 #[test]
1228 fn icon_name_dialog_warning_maps_to_triangle_alert() {
1229 assert!(matches!(
1230 icon_name(IconRole::DialogWarning),
1231 Some(IconName::TriangleAlert)
1232 ));
1233 }
1234
1235 #[test]
1236 fn icon_name_dialog_error_maps_to_circle_x() {
1237 assert!(matches!(
1238 icon_name(IconRole::DialogError),
1239 Some(IconName::CircleX)
1240 ));
1241 }
1242
1243 #[test]
1244 fn icon_name_dialog_info_maps_to_info() {
1245 assert!(matches!(
1246 icon_name(IconRole::DialogInfo),
1247 Some(IconName::Info)
1248 ));
1249 }
1250
1251 #[test]
1252 fn icon_name_dialog_success_maps_to_circle_check() {
1253 assert!(matches!(
1254 icon_name(IconRole::DialogSuccess),
1255 Some(IconName::CircleCheck)
1256 ));
1257 }
1258
1259 #[test]
1260 fn icon_name_window_close_maps() {
1261 assert!(matches!(
1262 icon_name(IconRole::WindowClose),
1263 Some(IconName::WindowClose)
1264 ));
1265 }
1266
1267 #[test]
1268 fn icon_name_action_copy_maps_to_copy() {
1269 assert!(matches!(
1270 icon_name(IconRole::ActionCopy),
1271 Some(IconName::Copy)
1272 ));
1273 }
1274
1275 #[test]
1276 fn icon_name_nav_back_maps_to_chevron_left() {
1277 assert!(matches!(
1278 icon_name(IconRole::NavBack),
1279 Some(IconName::ChevronLeft)
1280 ));
1281 }
1282
1283 #[test]
1284 fn icon_name_file_generic_maps_to_file() {
1285 assert!(matches!(
1286 icon_name(IconRole::FileGeneric),
1287 Some(IconName::File)
1288 ));
1289 }
1290
1291 #[test]
1292 fn icon_name_status_check_maps_to_check() {
1293 assert!(matches!(
1294 icon_name(IconRole::StatusCheck),
1295 Some(IconName::Check)
1296 ));
1297 }
1298
1299 #[test]
1300 fn icon_name_user_account_maps_to_user() {
1301 assert!(matches!(
1302 icon_name(IconRole::UserAccount),
1303 Some(IconName::User)
1304 ));
1305 }
1306
1307 #[test]
1308 fn icon_name_notification_maps_to_bell() {
1309 assert!(matches!(
1310 icon_name(IconRole::Notification),
1311 Some(IconName::Bell)
1312 ));
1313 }
1314
1315 #[test]
1317 fn icon_name_shield_returns_none() {
1318 assert!(icon_name(IconRole::Shield).is_none());
1319 }
1320
1321 #[test]
1322 fn icon_name_lock_returns_none() {
1323 assert!(icon_name(IconRole::Lock).is_none());
1324 }
1325
1326 #[test]
1327 fn icon_name_action_save_returns_none() {
1328 assert!(icon_name(IconRole::ActionSave).is_none());
1329 }
1330
1331 #[test]
1332 fn icon_name_help_returns_none() {
1333 assert!(icon_name(IconRole::Help).is_none());
1334 }
1335
1336 #[test]
1337 fn icon_name_dialog_question_returns_none() {
1338 assert!(icon_name(IconRole::DialogQuestion).is_none());
1339 }
1340
1341 #[test]
1343 fn icon_name_maps_at_least_28_roles() {
1344 let some_count = IconRole::ALL
1345 .iter()
1346 .filter(|r| icon_name(**r).is_some())
1347 .count();
1348 assert!(
1349 some_count >= 28,
1350 "Expected at least 28 mappings, got {}",
1351 some_count
1352 );
1353 }
1354
1355 #[test]
1356 fn icon_name_maps_exactly_30_roles() {
1357 let some_count = IconRole::ALL
1358 .iter()
1359 .filter(|r| icon_name(**r).is_some())
1360 .count();
1361 assert_eq!(
1362 some_count, 30,
1363 "Expected exactly 30 mappings, got {some_count}"
1364 );
1365 }
1366
1367 #[test]
1370 fn to_image_source_svg_returns_bmp_rasterized() {
1371 let svg = IconData::Svg(
1373 b"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><circle cx='12' cy='12' r='10' fill='red'/></svg>".to_vec(),
1374 );
1375 let source = to_image_source(&svg, None, None).expect("valid SVG should convert");
1376 match source {
1378 ImageSource::Image(arc) => {
1379 assert_eq!(arc.format, ImageFormat::Bmp);
1380 assert!(arc.bytes.starts_with(b"BM"), "BMP should start with 'BM'");
1381 }
1382 _ => panic!("Expected ImageSource::Image for SVG data"),
1383 }
1384 }
1385
1386 #[test]
1387 fn to_image_source_rgba_returns_bmp_image_source() {
1388 let rgba = IconData::Rgba {
1389 width: 2,
1390 height: 2,
1391 data: vec![
1392 255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 0, 255, ],
1397 };
1398 let source = to_image_source(&rgba, None, None).expect("RGBA should convert");
1399 match source {
1400 ImageSource::Image(arc) => {
1401 assert_eq!(arc.format, ImageFormat::Bmp);
1402 assert_eq!(&arc.bytes[0..2], b"BM");
1404 }
1405 _ => panic!("Expected ImageSource::Image for RGBA data"),
1406 }
1407 }
1408
1409 #[test]
1410 fn to_image_source_with_color() {
1411 let svg = IconData::Svg(
1412 b"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M0 0' stroke='currentColor'/></svg>".to_vec(),
1413 );
1414 let color = gpui::hsla(0.0, 1.0, 0.5, 1.0);
1415 let result = to_image_source(&svg, Some(color), None);
1416 assert!(result.is_some(), "colorized SVG should convert");
1417 }
1418
1419 #[test]
1420 fn to_image_source_with_custom_size() {
1421 let svg = IconData::Svg(
1422 b"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><circle cx='12' cy='12' r='10' fill='red'/></svg>".to_vec(),
1423 );
1424 let result = to_image_source(&svg, None, Some(32));
1425 assert!(result.is_some(), "custom size SVG should convert");
1426 }
1427
1428 #[test]
1431 fn encode_rgba_as_bmp_correct_file_size() {
1432 let rgba = vec![0u8; 4 * 4 * 4]; let bmp = encode_rgba_as_bmp(4, 4, &rgba).expect("valid input");
1434 let expected_size = 14 + 108 + (4 * 4 * 4); assert_eq!(bmp.len(), expected_size);
1436 }
1437
1438 #[test]
1439 fn encode_rgba_as_bmp_starts_with_bm() {
1440 let rgba = vec![0u8; 4]; let bmp = encode_rgba_as_bmp(1, 1, &rgba).expect("valid input");
1442 assert_eq!(&bmp[0..2], b"BM");
1443 }
1444
1445 #[test]
1446 fn encode_rgba_as_bmp_pixel_order_is_bgra() {
1447 let rgba = vec![0xAA, 0xBB, 0xCC, 0xDD];
1449 let bmp = encode_rgba_as_bmp(1, 1, &rgba).expect("valid input");
1450 let pixel_offset = (14 + 108) as usize;
1451 assert_eq!(bmp[pixel_offset], 0xCC); assert_eq!(bmp[pixel_offset + 1], 0xBB); assert_eq!(bmp[pixel_offset + 2], 0xAA); assert_eq!(bmp[pixel_offset + 3], 0xDD); }
1457
1458 #[test]
1459 fn encode_rgba_as_bmp_zero_width_returns_none() {
1460 let rgba = vec![0u8; 4];
1461 assert!(encode_rgba_as_bmp(0, 1, &rgba).is_none());
1462 }
1463
1464 #[test]
1465 fn encode_rgba_as_bmp_zero_height_returns_none() {
1466 let rgba = vec![0u8; 4];
1467 assert!(encode_rgba_as_bmp(1, 0, &rgba).is_none());
1468 }
1469
1470 #[test]
1471 fn encode_rgba_as_bmp_mismatched_length_returns_none() {
1472 let rgba = vec![0u8; 12];
1474 assert!(encode_rgba_as_bmp(2, 2, &rgba).is_none());
1475 }
1476
1477 #[test]
1478 fn encode_rgba_as_bmp_oversized_length_returns_none() {
1479 let rgba = vec![0u8; 20];
1481 assert!(encode_rgba_as_bmp(2, 2, &rgba).is_none());
1482 }
1483 #[test]
1486 fn colorize_svg_replaces_fill_black() {
1487 let svg = b"<svg><path fill=\"black\" d=\"M0 0h24v24H0z\"/></svg>";
1488 let color = gpui::hsla(0.6, 0.7, 0.5, 1.0); let result = colorize_svg(svg, color);
1490 let result_str = String::from_utf8(result).unwrap();
1491 assert!(
1492 !result_str.contains("fill=\"black\""),
1493 "fill=\"black\" should be replaced, got: {}",
1494 result_str
1495 );
1496 assert!(
1497 result_str.contains("fill=\"#"),
1498 "should contain hex fill, got: {}",
1499 result_str
1500 );
1501 }
1502
1503 #[test]
1504 fn colorize_svg_replaces_fill_hex_black() {
1505 let svg = b"<svg><rect fill=\"#000000\" width=\"24\" height=\"24\"/></svg>";
1506 let color = gpui::hsla(0.0, 1.0, 0.5, 1.0); let result = colorize_svg(svg, color);
1508 let result_str = String::from_utf8(result).unwrap();
1509 assert!(
1510 !result_str.contains("#000000"),
1511 "fill=\"#000000\" should be replaced, got: {}",
1512 result_str
1513 );
1514 }
1515
1516 #[test]
1517 fn colorize_svg_replaces_fill_short_hex_black() {
1518 let svg = b"<svg><rect fill=\"#000\" width=\"24\" height=\"24\"/></svg>";
1519 let color = gpui::hsla(0.3, 0.8, 0.4, 1.0); let result = colorize_svg(svg, color);
1521 let result_str = String::from_utf8(result).unwrap();
1522 assert!(
1523 !result_str.contains("fill=\"#000\""),
1524 "fill=\"#000\" should be replaced, got: {}",
1525 result_str
1526 );
1527 }
1528
1529 #[test]
1530 fn colorize_svg_current_color_still_works() {
1531 let svg = b"<svg><path stroke=\"currentColor\" d=\"M0 0\"/></svg>";
1532 let color = gpui::hsla(0.0, 1.0, 0.5, 1.0);
1533 let result = colorize_svg(svg, color);
1534 let result_str = String::from_utf8(result).unwrap();
1535 assert!(
1536 !result_str.contains("currentColor"),
1537 "currentColor should be replaced"
1538 );
1539 assert!(result_str.contains('#'), "should contain hex color");
1540 }
1541
1542 #[test]
1543 fn colorize_svg_implicit_black_still_works() {
1544 let svg = b"<svg xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M0 0\"/></svg>";
1546 let color = gpui::hsla(0.0, 1.0, 0.5, 1.0);
1547 let result = colorize_svg(svg, color);
1548 let result_str = String::from_utf8(result).unwrap();
1549 assert!(
1550 result_str.contains("fill=\"#"),
1551 "should inject fill into root svg tag, got: {}",
1552 result_str
1553 );
1554 }
1555
1556 #[test]
1557 fn colorize_svg_non_utf8_returns_original() {
1558 let mut svg = b"<svg><path fill=\"black\" d=\"M0 0\"/>".to_vec();
1560 svg.push(0xFF); svg.extend_from_slice(b"</svg>");
1562 let color = gpui::hsla(0.0, 1.0, 0.5, 1.0);
1563 let result = colorize_svg(&svg, color);
1564 assert_eq!(result, svg, "non-UTF-8 input should be returned unchanged");
1565 }
1566
1567 #[test]
1568 fn colorize_self_closing_svg_produces_valid_xml() {
1569 let svg = b"<svg xmlns=\"http://www.w3.org/2000/svg\" />";
1571 let color = gpui::hsla(0.0, 1.0, 0.5, 1.0);
1572 let result = colorize_svg(svg, color);
1573 let result_str = String::from_utf8(result).unwrap();
1574 assert!(
1575 result_str.contains("fill=\"#"),
1576 "should inject fill, got: {}",
1577 result_str
1578 );
1579 assert!(
1581 !result_str.contains("/ fill="),
1582 "fill must be before '/', got: {}",
1583 result_str
1584 );
1585 assert!(
1587 result_str.trim().ends_with("/>"),
1588 "should remain self-closing, got: {}",
1589 result_str
1590 );
1591 }
1592
1593 #[test]
1596 fn into_image_source_svg_returns_some() {
1597 let svg = IconData::Svg(
1598 b"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><circle cx='12' cy='12' r='10' fill='red'/></svg>".to_vec(),
1599 );
1600 let result = into_image_source(svg, None, None);
1601 assert!(result.is_some(), "valid SVG should convert");
1602 }
1603
1604 #[test]
1605 fn into_image_source_rgba_returns_some() {
1606 let rgba = IconData::Rgba {
1607 width: 2,
1608 height: 2,
1609 data: vec![
1610 255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 0, 255,
1611 ],
1612 };
1613 let result = into_image_source(rgba, None, None);
1614 assert!(result.is_some(), "RGBA should convert");
1615 }
1616
1617 #[test]
1618 fn into_image_source_with_color() {
1619 let svg = IconData::Svg(
1620 b"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M0 0' stroke='currentColor'/></svg>".to_vec(),
1621 );
1622 let color = gpui::hsla(0.0, 1.0, 0.5, 1.0);
1623 let result = into_image_source(svg, Some(color), None);
1624 assert!(result.is_some(), "colorized SVG should convert");
1625 }
1626
1627 #[derive(Debug)]
1631 struct TestCustomIcon;
1632
1633 impl native_theme::IconProvider for TestCustomIcon {
1634 fn icon_name(&self, _set: native_theme::IconSet) -> Option<&str> {
1635 None }
1637 fn icon_svg(&self, _set: native_theme::IconSet) -> Option<&'static [u8]> {
1638 Some(b"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><circle cx='12' cy='12' r='10'/></svg>")
1639 }
1640 }
1641
1642 #[derive(Debug)]
1644 struct EmptyProvider;
1645
1646 impl native_theme::IconProvider for EmptyProvider {
1647 fn icon_name(&self, _set: native_theme::IconSet) -> Option<&str> {
1648 None
1649 }
1650 fn icon_svg(&self, _set: native_theme::IconSet) -> Option<&'static [u8]> {
1651 None
1652 }
1653 }
1654
1655 #[test]
1656 fn custom_icon_to_image_source_with_svg_provider_returns_some() {
1657 let result = custom_icon_to_image_source(
1658 &TestCustomIcon,
1659 native_theme::IconSet::Material,
1660 None,
1661 None,
1662 );
1663 assert!(result.is_some());
1664 }
1665
1666 #[test]
1667 fn custom_icon_to_image_source_with_empty_provider_returns_none() {
1668 let result = custom_icon_to_image_source(
1669 &EmptyProvider,
1670 native_theme::IconSet::Material,
1671 None,
1672 None,
1673 );
1674 assert!(result.is_none());
1675 }
1676
1677 #[test]
1678 fn custom_icon_to_image_source_with_color() {
1679 let color = Hsla {
1680 h: 0.0,
1681 s: 1.0,
1682 l: 0.5,
1683 a: 1.0,
1684 };
1685 let result = custom_icon_to_image_source(
1686 &TestCustomIcon,
1687 native_theme::IconSet::Material,
1688 Some(color),
1689 None,
1690 );
1691 assert!(result.is_some());
1692 }
1693
1694 #[test]
1695 fn custom_icon_to_image_source_accepts_dyn_provider() {
1696 let boxed: Box<dyn native_theme::IconProvider> = Box::new(TestCustomIcon);
1697 let result =
1698 custom_icon_to_image_source(&*boxed, native_theme::IconSet::Material, None, None);
1699 assert!(result.is_some());
1700 }
1701
1702 #[test]
1705 fn bundled_icon_lucide_returns_some() {
1706 let result = bundled_icon_to_image_source(
1707 IconName::Search,
1708 native_theme::IconSet::Lucide,
1709 None,
1710 None,
1711 );
1712 assert!(result.is_some(), "Lucide search icon should convert");
1713 }
1714
1715 #[test]
1716 fn bundled_icon_material_returns_some() {
1717 let result = bundled_icon_to_image_source(
1718 IconName::Search,
1719 native_theme::IconSet::Material,
1720 None,
1721 None,
1722 );
1723 assert!(result.is_some(), "Material search icon should convert");
1724 }
1725
1726 #[test]
1727 fn bundled_icon_freedesktop_returns_none() {
1728 let result = bundled_icon_to_image_source(
1729 IconName::Search,
1730 native_theme::IconSet::Freedesktop,
1731 None,
1732 None,
1733 );
1734 assert!(
1735 result.is_none(),
1736 "Freedesktop is not bundled -- should return None"
1737 );
1738 }
1739
1740 #[test]
1741 fn bundled_icon_with_color() {
1742 let color = Hsla {
1743 h: 0.0,
1744 s: 1.0,
1745 l: 0.5,
1746 a: 1.0,
1747 };
1748 let result = bundled_icon_to_image_source(
1749 IconName::Check,
1750 native_theme::IconSet::Lucide,
1751 Some(color),
1752 None,
1753 );
1754 assert!(result.is_some(), "colorized bundled icon should convert");
1755 }
1756
1757 #[test]
1760 fn animated_frames_returns_sources() {
1761 let anim = AnimatedIcon::Frames {
1762 frames: vec![
1763 IconData::Svg(b"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><circle cx='12' cy='12' r='10' fill='red'/></svg>".to_vec()),
1764 IconData::Svg(b"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><circle cx='12' cy='12' r='8' fill='blue'/></svg>".to_vec()),
1765 IconData::Svg(b"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><circle cx='12' cy='12' r='6' fill='green'/></svg>".to_vec()),
1766 ],
1767 frame_duration_ms: 80,
1768 };
1769 let result = animated_frames_to_image_sources(&anim, None, None);
1770 let ais = result.expect("Frames variant should return Some");
1771 assert_eq!(ais.sources.len(), 3);
1772 assert_eq!(ais.frame_duration_ms, 80);
1773 }
1774
1775 #[test]
1776 fn animated_frames_transform_returns_none() {
1777 let anim = AnimatedIcon::Transform {
1778 icon: IconData::Svg(
1779 b"<svg xmlns='http://www.w3.org/2000/svg'><circle cx='12' cy='12' r='10'/></svg>"
1780 .to_vec(),
1781 ),
1782 animation: native_theme::TransformAnimation::Spin { duration_ms: 1000 },
1783 };
1784 let result = animated_frames_to_image_sources(&anim, None, None);
1785 assert!(result.is_none());
1786 }
1787
1788 #[test]
1789 fn animated_frames_empty_returns_none() {
1790 let anim = AnimatedIcon::Frames {
1791 frames: vec![],
1792 frame_duration_ms: 80,
1793 };
1794 let result = animated_frames_to_image_sources(&anim, None, None);
1795 assert!(result.is_none());
1796 }
1797
1798 #[test]
1799 fn spin_animation_constructs_without_context() {
1800 let svg_element = gpui::svg();
1801 let _animated = with_spin_animation(svg_element, "test-spin", 1000);
1804 }
1805}
1806
1807#[cfg(test)]
1808#[cfg(target_os = "linux")]
1809#[allow(clippy::unwrap_used, clippy::expect_used)]
1810mod freedesktop_mapping_tests {
1811 use super::tests::ALL_ICON_NAMES;
1812 use super::*;
1813 use native_theme::LinuxDesktop;
1814
1815 #[test]
1816 fn all_86_gpui_icons_have_mapping_on_kde() {
1817 for name in ALL_ICON_NAMES {
1818 let fd_name = freedesktop_name_for_gpui_icon(name.clone(), LinuxDesktop::Kde);
1819 assert!(
1820 !fd_name.is_empty(),
1821 "Empty KDE freedesktop mapping for an IconName variant",
1822 );
1823 }
1824 }
1825
1826 #[test]
1827 fn eye_differs_by_de() {
1828 assert_eq!(
1829 freedesktop_name_for_gpui_icon(IconName::Eye, LinuxDesktop::Kde),
1830 "view-visible",
1831 );
1832 assert_eq!(
1833 freedesktop_name_for_gpui_icon(IconName::Eye, LinuxDesktop::Gnome),
1834 "view-reveal",
1835 );
1836 }
1837
1838 #[test]
1839 fn freedesktop_standard_ignores_de() {
1840 assert_eq!(
1842 freedesktop_name_for_gpui_icon(IconName::Copy, LinuxDesktop::Kde),
1843 freedesktop_name_for_gpui_icon(IconName::Copy, LinuxDesktop::Gnome),
1844 );
1845 }
1846
1847 #[test]
1848 fn all_86_gpui_icons_have_mapping_on_gnome() {
1849 for name in ALL_ICON_NAMES {
1850 let fd_name = freedesktop_name_for_gpui_icon(name.clone(), LinuxDesktop::Gnome);
1851 assert!(
1852 !fd_name.is_empty(),
1853 "Empty GNOME freedesktop mapping for an IconName variant",
1854 );
1855 }
1856 }
1857
1858 #[test]
1859 fn xfce_uses_gnome_names() {
1860 assert_eq!(
1862 freedesktop_name_for_gpui_icon(IconName::Eye, LinuxDesktop::Xfce),
1863 "view-reveal",
1864 );
1865 assert_eq!(
1866 freedesktop_name_for_gpui_icon(IconName::Bell, LinuxDesktop::Xfce),
1867 "alarm",
1868 );
1869 }
1870
1871 #[test]
1872 fn all_kde_names_resolve_in_breeze() {
1873 let theme = native_theme::system_icon_theme();
1874 if !theme.to_lowercase().contains("breeze") {
1876 eprintln!("Skipping: system theme is '{}', not Breeze", theme);
1877 return;
1878 }
1879
1880 let mut missing = Vec::new();
1881 for name in ALL_ICON_NAMES {
1882 let fd_name = freedesktop_name_for_gpui_icon(name.clone(), LinuxDesktop::Kde);
1883 if native_theme::load_freedesktop_icon_by_name(fd_name, theme, 24).is_none() {
1884 missing.push(format!("{} (not found)", fd_name));
1885 }
1886 }
1887 assert!(
1888 missing.is_empty(),
1889 "These gpui icons did not resolve in Breeze:\n {}",
1890 missing.join("\n "),
1891 );
1892 }
1893
1894 #[test]
1895 fn gnome_names_resolve_in_adwaita() {
1896 let mut missing = Vec::new();
1899 for name in ALL_ICON_NAMES {
1900 let fd_name = freedesktop_name_for_gpui_icon(name.clone(), LinuxDesktop::Gnome);
1901 if native_theme::load_freedesktop_icon_by_name(fd_name, "Adwaita", 24).is_none() {
1902 missing.push(format!("{} (not found)", fd_name));
1903 }
1904 }
1905 assert!(
1906 missing.is_empty(),
1907 "These GNOME mappings did not resolve in Adwaita:\n {}",
1908 missing.join("\n "),
1909 );
1910 }
1911}