1use gpui::{
17 Animation, AnimationExt, Hsla, Image, ImageFormat, ImageSource, Svg, Transformation, percentage,
18};
19use gpui_component::IconName;
20use native_theme::{AnimatedIcon, IconData, IconProvider, IconRole, load_custom_icon};
21use std::sync::Arc;
22use std::time::Duration;
23
24pub struct AnimatedImageSources {
29 pub sources: Vec<ImageSource>,
31 pub frame_duration_ms: u32,
33}
34
35pub fn icon_name(role: IconRole) -> Option<IconName> {
61 Some(match role {
62 IconRole::DialogWarning => IconName::TriangleAlert,
64 IconRole::DialogError => IconName::CircleX,
65 IconRole::DialogInfo => IconName::Info,
66 IconRole::DialogSuccess => IconName::CircleCheck,
67
68 IconRole::WindowClose => IconName::WindowClose,
70 IconRole::WindowMinimize => IconName::WindowMinimize,
71 IconRole::WindowMaximize => IconName::WindowMaximize,
72 IconRole::WindowRestore => IconName::WindowRestore,
73
74 IconRole::ActionDelete => IconName::Delete,
76 IconRole::ActionCopy => IconName::Copy,
77 IconRole::ActionUndo => IconName::Undo2,
78 IconRole::ActionRedo => IconName::Redo2,
79 IconRole::ActionSearch => IconName::Search,
80 IconRole::ActionSettings => IconName::Settings,
81 IconRole::ActionAdd => IconName::Plus,
82 IconRole::ActionRemove => IconName::Minus,
83
84 IconRole::NavBack => IconName::ChevronLeft,
86 IconRole::NavForward => IconName::ChevronRight,
87 IconRole::NavUp => IconName::ChevronUp,
88 IconRole::NavDown => IconName::ChevronDown,
89 IconRole::NavMenu => IconName::Menu,
90
91 IconRole::FileGeneric => IconName::File,
93 IconRole::FolderClosed => IconName::FolderClosed,
94 IconRole::FolderOpen => IconName::FolderOpen,
95 IconRole::TrashEmpty => IconName::Delete,
96
97 IconRole::StatusBusy => IconName::Loader,
99 IconRole::StatusCheck => IconName::Check,
100 IconRole::StatusError => IconName::CircleX,
101
102 IconRole::UserAccount => IconName::User,
104 IconRole::Notification => IconName::Bell,
105
106 _ => return None,
108 })
109}
110
111pub fn lucide_name_for_gpui_icon(icon: IconName) -> Option<&'static str> {
118 Some(match icon {
119 IconName::ALargeSmall => "a-large-small",
120 IconName::ArrowDown => "arrow-down",
121 IconName::ArrowLeft => "arrow-left",
122 IconName::ArrowRight => "arrow-right",
123 IconName::ArrowUp => "arrow-up",
124 IconName::Asterisk => "asterisk",
125 IconName::Bell => "bell",
126 IconName::BookOpen => "book-open",
127 IconName::Bot => "bot",
128 IconName::Building2 => "building-2",
129 IconName::Calendar => "calendar",
130 IconName::CaseSensitive => "case-sensitive",
131 IconName::ChartPie => "chart-pie",
132 IconName::Check => "check",
133 IconName::ChevronDown => "chevron-down",
134 IconName::ChevronLeft => "chevron-left",
135 IconName::ChevronRight => "chevron-right",
136 IconName::ChevronsUpDown => "chevrons-up-down",
137 IconName::ChevronUp => "chevron-up",
138 IconName::CircleCheck => "circle-check",
139 IconName::CircleUser => "circle-user",
140 IconName::CircleX => "circle-x",
141 IconName::Close => "close",
142 IconName::Copy => "copy",
143 IconName::Dash => "dash",
144 IconName::Delete => "delete",
145 IconName::Ellipsis => "ellipsis",
146 IconName::EllipsisVertical => "ellipsis-vertical",
147 IconName::ExternalLink => "external-link",
148 IconName::Eye => "eye",
149 IconName::EyeOff => "eye-off",
150 IconName::File => "file",
151 IconName::Folder => "folder",
152 IconName::FolderClosed => "folder-closed",
153 IconName::FolderOpen => "folder-open",
154 IconName::Frame => "frame",
155 IconName::GalleryVerticalEnd => "gallery-vertical-end",
156 IconName::GitHub => "github",
157 IconName::Globe => "globe",
158 IconName::Heart => "heart",
159 IconName::HeartOff => "heart-off",
160 IconName::Inbox => "inbox",
161 IconName::Info => "info",
162 IconName::Inspector => "inspect",
163 IconName::LayoutDashboard => "layout-dashboard",
164 IconName::Loader => "loader",
165 IconName::LoaderCircle => "loader-circle",
166 IconName::Map => "map",
167 IconName::Maximize => "maximize",
168 IconName::Menu => "menu",
169 IconName::Minimize => "minimize",
170 IconName::Minus => "minus",
171 IconName::Moon => "moon",
172 IconName::Palette => "palette",
173 IconName::PanelBottom => "panel-bottom",
174 IconName::PanelBottomOpen => "panel-bottom-open",
175 IconName::PanelLeft => "panel-left",
176 IconName::PanelLeftClose => "panel-left-close",
177 IconName::PanelLeftOpen => "panel-left-open",
178 IconName::PanelRight => "panel-right",
179 IconName::PanelRightClose => "panel-right-close",
180 IconName::PanelRightOpen => "panel-right-open",
181 IconName::Plus => "plus",
182 IconName::Redo => "redo",
183 IconName::Redo2 => "redo-2",
184 IconName::Replace => "replace",
185 IconName::ResizeCorner => "resize-corner",
186 IconName::Search => "search",
187 IconName::Settings => "settings",
188 IconName::Settings2 => "settings-2",
189 IconName::SortAscending => "sort-ascending",
190 IconName::SortDescending => "sort-descending",
191 IconName::SquareTerminal => "square-terminal",
192 IconName::Star => "star",
193 IconName::StarOff => "star-off",
194 IconName::Sun => "sun",
195 IconName::ThumbsDown => "thumbs-down",
196 IconName::ThumbsUp => "thumbs-up",
197 IconName::TriangleAlert => "triangle-alert",
198 IconName::Undo => "undo",
199 IconName::Undo2 => "undo-2",
200 IconName::User => "user",
201 IconName::WindowClose => "window-close",
202 IconName::WindowMaximize => "window-maximize",
203 IconName::WindowMinimize => "window-minimize",
204 IconName::WindowRestore => "window-restore",
205 })
206}
207
208pub fn material_name_for_gpui_icon(icon: IconName) -> Option<&'static str> {
215 Some(match icon {
216 IconName::ALargeSmall => "font_size",
217 IconName::ArrowDown => "arrow_downward",
218 IconName::ArrowLeft => "arrow_back",
219 IconName::ArrowRight => "arrow_forward",
220 IconName::ArrowUp => "arrow_upward",
221 IconName::Asterisk => "emergency",
222 IconName::Bell => "notifications",
223 IconName::BookOpen => "menu_book",
224 IconName::Bot => "smart_toy",
225 IconName::Building2 => "apartment",
226 IconName::Calendar => "calendar_today",
227 IconName::CaseSensitive => "match_case",
228 IconName::ChartPie => "pie_chart",
229 IconName::Check => "check",
230 IconName::ChevronDown => "expand_more",
231 IconName::ChevronLeft => "chevron_left",
232 IconName::ChevronRight => "chevron_right",
233 IconName::ChevronsUpDown => "unfold_more",
234 IconName::ChevronUp => "expand_less",
235 IconName::CircleCheck => "check_circle",
236 IconName::CircleUser => "account_circle",
237 IconName::CircleX => "cancel",
238 IconName::Close => "close",
239 IconName::Copy => "content_copy",
240 IconName::Dash => "remove",
241 IconName::Delete => "delete",
242 IconName::Ellipsis => "more_horiz",
243 IconName::EllipsisVertical => "more_vert",
244 IconName::ExternalLink => "open_in_new",
245 IconName::Eye => "visibility",
246 IconName::EyeOff => "visibility_off",
247 IconName::File => "description",
248 IconName::Folder => "folder",
249 IconName::FolderClosed => "folder",
250 IconName::FolderOpen => "folder_open",
251 IconName::Frame => "crop_free",
252 IconName::GalleryVerticalEnd => "view_carousel",
253 IconName::GitHub => "code",
254 IconName::Globe => "language",
255 IconName::Heart => "favorite",
256 IconName::HeartOff => "heart_broken",
257 IconName::Inbox => "inbox",
258 IconName::Info => "info",
259 IconName::Inspector => "developer_mode",
260 IconName::LayoutDashboard => "dashboard",
261 IconName::Loader => "progress_activity",
262 IconName::LoaderCircle => "autorenew",
263 IconName::Map => "map",
264 IconName::Maximize => "open_in_full",
265 IconName::Menu => "menu",
266 IconName::Minimize => "minimize",
267 IconName::Minus => "remove",
268 IconName::Moon => "dark_mode",
269 IconName::Palette => "palette",
270 IconName::PanelBottom => "dock_to_bottom",
271 IconName::PanelBottomOpen => "web_asset",
272 IconName::PanelLeft => "side_navigation",
273 IconName::PanelLeftClose => "left_panel_close",
274 IconName::PanelLeftOpen => "left_panel_open",
275 IconName::PanelRight => "right_panel_close",
276 IconName::PanelRightClose => "right_panel_close",
277 IconName::PanelRightOpen => "right_panel_open",
278 IconName::Plus => "add",
279 IconName::Redo => "redo",
280 IconName::Redo2 => "redo",
281 IconName::Replace => "find_replace",
282 IconName::ResizeCorner => "drag_indicator",
283 IconName::Search => "search",
284 IconName::Settings => "settings",
285 IconName::Settings2 => "tune",
286 IconName::SortAscending => "arrow_upward",
287 IconName::SortDescending => "arrow_downward",
288 IconName::SquareTerminal => "terminal",
289 IconName::Star => "star",
290 IconName::StarOff => "star_border",
291 IconName::Sun => "light_mode",
292 IconName::ThumbsDown => "thumb_down",
293 IconName::ThumbsUp => "thumb_up",
294 IconName::TriangleAlert => "warning",
295 IconName::Undo => "undo",
296 IconName::Undo2 => "undo",
297 IconName::User => "person",
298 IconName::WindowClose => "close",
299 IconName::WindowMaximize => "open_in_full",
300 IconName::WindowMinimize => "minimize",
301 IconName::WindowRestore => "close_fullscreen",
302 })
303}
304
305#[cfg(target_os = "linux")]
329pub fn freedesktop_name_for_gpui_icon(
330 icon: IconName,
331 de: native_theme::LinuxDesktop,
332) -> Option<&'static str> {
333 use native_theme::LinuxDesktop;
334
335 let is_gtk = matches!(
337 de,
338 LinuxDesktop::Gnome
339 | LinuxDesktop::Budgie
340 | LinuxDesktop::Cinnamon
341 | LinuxDesktop::Mate
342 | LinuxDesktop::Xfce
343 );
344
345 Some(match icon {
346 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 => {
393 if is_gtk {
394 "go-bottom"
395 } else {
396 "go-down-skip"
397 }
398 } IconName::ArrowLeft => {
400 if is_gtk {
401 "go-first"
402 } else {
403 "go-previous-skip"
404 }
405 } IconName::ArrowRight => {
407 if is_gtk {
408 "go-last"
409 } else {
410 "go-next-skip"
411 }
412 } IconName::ArrowUp => {
414 if is_gtk {
415 "go-top"
416 } else {
417 "go-up-skip"
418 }
419 } IconName::Calendar => {
421 if is_gtk {
422 "x-office-calendar"
423 } else {
424 "view-calendar"
425 }
426 } IconName::Check => {
428 if is_gtk {
429 "object-select"
430 } else {
431 "dialog-ok"
432 }
433 } IconName::CircleCheck => {
435 if is_gtk {
436 "object-select"
437 } else {
438 "emblem-ok-symbolic"
439 }
440 } IconName::CircleUser => {
442 if is_gtk {
443 "avatar-default"
444 } else {
445 "user-identity"
446 }
447 } IconName::Close => {
449 if is_gtk {
450 "window-close"
451 } else {
452 "tab-close"
453 }
454 } IconName::Ellipsis => {
456 if is_gtk {
457 "view-more-horizontal"
458 } else {
459 "overflow-menu"
460 }
461 } IconName::EllipsisVertical => {
463 if is_gtk {
464 "view-more"
465 } else {
466 "overflow-menu"
467 }
468 } IconName::Eye => {
470 if is_gtk {
471 "view-reveal"
472 } else {
473 "view-visible"
474 }
475 } IconName::EyeOff => {
477 if is_gtk {
478 "view-conceal"
479 } else {
480 "view-hidden"
481 }
482 } IconName::Frame => {
484 if is_gtk {
485 "selection-mode"
486 } else {
487 "select-rectangular"
488 }
489 } IconName::Heart => {
491 if is_gtk {
492 "starred"
493 } else {
494 "emblem-favorite"
495 }
496 } IconName::Loader => {
498 if is_gtk {
499 "content-loading"
500 } else {
501 "process-working"
502 }
503 } IconName::LoaderCircle => {
505 if is_gtk {
506 "content-loading"
507 } else {
508 "process-working"
509 }
510 } IconName::Palette => {
512 if is_gtk {
513 "color-select"
514 } else {
515 "palette"
516 }
517 } IconName::PanelLeft => {
519 if is_gtk {
520 "sidebar-show"
521 } else {
522 "sidebar-expand-left"
523 }
524 } IconName::PanelLeftClose => {
526 if is_gtk {
527 "sidebar-show"
528 } else {
529 "view-left-close"
530 }
531 } IconName::PanelLeftOpen => {
533 if is_gtk {
534 "sidebar-show"
535 } else {
536 "view-left-new"
537 }
538 } IconName::PanelRight => {
540 if is_gtk {
541 "sidebar-show-right"
542 } else {
543 "view-right-new"
544 }
545 } IconName::PanelRightClose => {
547 if is_gtk {
548 "sidebar-show-right"
549 } else {
550 "view-right-close"
551 }
552 } IconName::PanelRightOpen => {
554 if is_gtk {
555 "sidebar-show-right"
556 } else {
557 "view-right-new"
558 }
559 } IconName::ResizeCorner => {
561 if is_gtk {
562 "list-drag-handle"
563 } else {
564 "drag-handle"
565 }
566 } IconName::Settings2 => {
568 if is_gtk {
569 "preferences-other"
570 } else {
571 "configure"
572 }
573 } IconName::ALargeSmall => {
577 if is_gtk {
578 "zoom-in"
579 } else {
580 "format-font-size-more"
581 }
582 } IconName::Asterisk => {
584 if is_gtk {
585 "starred"
586 } else {
587 "rating"
588 }
589 } IconName::Bell => {
591 if is_gtk {
592 "alarm"
593 } else {
594 "notification-active"
595 }
596 } IconName::Building2 => {
598 if is_gtk {
599 "network-workgroup"
600 } else {
601 "applications-office"
602 }
603 } IconName::CaseSensitive => {
605 if is_gtk {
606 "format-text-rich"
607 } else {
608 "format-text-uppercase"
609 }
610 } IconName::ChartPie => {
612 if is_gtk {
613 "x-office-spreadsheet"
614 } else {
615 "office-chart-pie"
616 }
617 } IconName::ChevronsUpDown => {
619 if is_gtk {
620 "list-drag-handle"
621 } else {
622 "handle-sort"
623 }
624 } IconName::ExternalLink => {
626 if is_gtk {
627 "insert-link"
628 } else {
629 "external-link"
630 }
631 } IconName::GalleryVerticalEnd => {
633 if is_gtk {
634 "view-paged"
635 } else {
636 "view-list-icons"
637 }
638 } IconName::GitHub => {
640 if is_gtk {
641 "applications-engineering"
642 } else {
643 "vcs-branch"
644 }
645 } IconName::Globe => {
647 if is_gtk {
648 "web-browser"
649 } else {
650 "globe"
651 }
652 } IconName::Inbox => {
654 if is_gtk {
655 "mail-send-receive"
656 } else {
657 "mail-folder-inbox"
658 }
659 } IconName::Inspector => {
661 if is_gtk {
662 "preferences-system-details"
663 } else {
664 "code-context"
665 }
666 } IconName::PanelBottom => {
668 if is_gtk {
669 "view-dual"
670 } else {
671 "view-split-top-bottom"
672 }
673 } IconName::PanelBottomOpen => {
675 if is_gtk {
676 "view-dual"
677 } else {
678 "view-split-top-bottom"
679 }
680 } IconName::ThumbsDown => {
682 if is_gtk {
683 "process-stop"
684 } else {
685 "rating-unrated"
686 }
687 } IconName::ThumbsUp => {
689 if is_gtk {
690 "checkbox-checked"
691 } else {
692 "approved"
693 }
694 } })
696}
697
698const SVG_RASTERIZE_SIZE: u32 = 48;
703
704pub fn to_image_source(
732 data: &IconData,
733 color: Option<Hsla>,
734 size: Option<u32>,
735) -> Option<ImageSource> {
736 let raster_size = size.unwrap_or(SVG_RASTERIZE_SIZE);
737 match data {
738 IconData::Svg(bytes) => {
739 if let Some(c) = color {
740 let colored = colorize_svg(bytes, c);
741 svg_to_bmp_source(&colored, raster_size)
742 } else {
743 svg_to_bmp_source(bytes, raster_size)
744 }
745 }
746 IconData::Rgba {
747 width,
748 height,
749 data,
750 } => {
751 let bmp = encode_rgba_as_bmp(*width, *height, data);
752 let image = Image::from_bytes(ImageFormat::Bmp, bmp);
753 Some(ImageSource::Image(Arc::new(image)))
754 }
755 _ => None,
756 }
757}
758
759pub fn custom_icon_to_image_source(
769 provider: &(impl IconProvider + ?Sized),
770 icon_set: native_theme::IconSet,
771 color: Option<Hsla>,
772 size: Option<u32>,
773) -> Option<ImageSource> {
774 let data = load_custom_icon(provider, icon_set)?;
775 to_image_source(&data, color, size)
776}
777
778pub fn animated_frames_to_image_sources(anim: &AnimatedIcon) -> Option<AnimatedImageSources> {
806 match anim {
807 AnimatedIcon::Frames {
808 frames,
809 frame_duration_ms,
810 } => {
811 let sources: Vec<ImageSource> = frames
812 .iter()
813 .filter_map(|f| to_image_source(f, None, None))
814 .collect();
815 Some(AnimatedImageSources {
816 sources,
817 frame_duration_ms: *frame_duration_ms,
818 })
819 }
820 _ => None,
821 }
822}
823
824pub fn with_spin_animation(
850 element: Svg,
851 animation_id: impl Into<gpui::ElementId>,
852 duration_ms: u32,
853) -> impl gpui::IntoElement {
854 element.with_animation(
855 animation_id,
856 Animation::new(Duration::from_millis(duration_ms as u64)).repeat(),
857 |el, delta| el.with_transformation(Transformation::rotate(percentage(delta))),
858 )
859}
860
861fn svg_to_bmp_source(svg_bytes: &[u8], size: u32) -> Option<ImageSource> {
869 let Ok(IconData::Rgba {
870 width,
871 height,
872 data,
873 }) = native_theme::rasterize::rasterize_svg(svg_bytes, size)
874 else {
875 return None;
876 };
877 let bmp = encode_rgba_as_bmp(width, height, &data);
878 let image = Image::from_bytes(ImageFormat::Bmp, bmp);
879 Some(ImageSource::Image(Arc::new(image)))
880}
881
882fn colorize_svg(svg_bytes: &[u8], color: Hsla) -> Vec<u8> {
896 let rgba: gpui::Rgba = color.into();
897 let r = (rgba.r.clamp(0.0, 1.0) * 255.0).round() as u8;
898 let g = (rgba.g.clamp(0.0, 1.0) * 255.0).round() as u8;
899 let b = (rgba.b.clamp(0.0, 1.0) * 255.0).round() as u8;
900 let hex = format!("#{:02x}{:02x}{:02x}", r, g, b);
901
902 let svg_str = String::from_utf8_lossy(svg_bytes);
903
904 if svg_str.contains("currentColor") {
906 return svg_str.replace("currentColor", &hex).into_bytes();
907 }
908
909 let fill_hex = format!("fill=\"{}\"", hex);
911 let replaced = svg_str
912 .replace("fill=\"black\"", &fill_hex)
913 .replace("fill=\"#000000\"", &fill_hex)
914 .replace("fill=\"#000\"", &fill_hex);
915 if replaced != svg_str {
916 return replaced.into_bytes();
917 }
918
919 if let Some(pos) = svg_str.find("<svg")
922 && let Some(close) = svg_str[pos..].find('>')
923 {
924 let tag_end = pos + close;
925 let tag = &svg_str[pos..tag_end];
926 if !tag.contains("fill=") {
927 let mut result = String::with_capacity(svg_str.len() + 20);
928 result.push_str(&svg_str[..tag_end]);
929 result.push_str(&format!(" fill=\"{}\"", hex));
930 result.push_str(&svg_str[tag_end..]);
931 return result.into_bytes();
932 }
933 }
934
935 svg_bytes.to_vec()
937}
938
939fn encode_rgba_as_bmp(width: u32, height: u32, rgba: &[u8]) -> Vec<u8> {
944 let pixel_data_size = (width * height * 4) as usize;
945 let header_size: u32 = 14; let dib_header_size: u32 = 108; let file_size = header_size + dib_header_size + pixel_data_size as u32;
948
949 let mut buf = Vec::with_capacity(file_size as usize);
950
951 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 + dib_header_size).to_le_bytes()); buf.extend_from_slice(&dib_header_size.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_size as 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]);
983
984 buf.extend_from_slice(&0u32.to_le_bytes());
986 buf.extend_from_slice(&0u32.to_le_bytes());
987 buf.extend_from_slice(&0u32.to_le_bytes());
988
989 for pixel in rgba.chunks_exact(4) {
991 buf.push(pixel[2]); buf.push(pixel[1]); buf.push(pixel[0]); buf.push(pixel[3]); }
996
997 buf
998}
999
1000#[cfg(test)]
1001#[allow(clippy::unwrap_used, clippy::expect_used)]
1002mod tests {
1003 use super::*;
1004
1005 pub(super) const ALL_ICON_NAMES: &[IconName] = &[
1006 IconName::ALargeSmall,
1007 IconName::ArrowDown,
1008 IconName::ArrowLeft,
1009 IconName::ArrowRight,
1010 IconName::ArrowUp,
1011 IconName::Asterisk,
1012 IconName::Bell,
1013 IconName::BookOpen,
1014 IconName::Bot,
1015 IconName::Building2,
1016 IconName::Calendar,
1017 IconName::CaseSensitive,
1018 IconName::ChartPie,
1019 IconName::Check,
1020 IconName::ChevronDown,
1021 IconName::ChevronLeft,
1022 IconName::ChevronRight,
1023 IconName::ChevronsUpDown,
1024 IconName::ChevronUp,
1025 IconName::CircleCheck,
1026 IconName::CircleUser,
1027 IconName::CircleX,
1028 IconName::Close,
1029 IconName::Copy,
1030 IconName::Dash,
1031 IconName::Delete,
1032 IconName::Ellipsis,
1033 IconName::EllipsisVertical,
1034 IconName::ExternalLink,
1035 IconName::Eye,
1036 IconName::EyeOff,
1037 IconName::File,
1038 IconName::Folder,
1039 IconName::FolderClosed,
1040 IconName::FolderOpen,
1041 IconName::Frame,
1042 IconName::GalleryVerticalEnd,
1043 IconName::GitHub,
1044 IconName::Globe,
1045 IconName::Heart,
1046 IconName::HeartOff,
1047 IconName::Inbox,
1048 IconName::Info,
1049 IconName::Inspector,
1050 IconName::LayoutDashboard,
1051 IconName::Loader,
1052 IconName::LoaderCircle,
1053 IconName::Map,
1054 IconName::Maximize,
1055 IconName::Menu,
1056 IconName::Minimize,
1057 IconName::Minus,
1058 IconName::Moon,
1059 IconName::Palette,
1060 IconName::PanelBottom,
1061 IconName::PanelBottomOpen,
1062 IconName::PanelLeft,
1063 IconName::PanelLeftClose,
1064 IconName::PanelLeftOpen,
1065 IconName::PanelRight,
1066 IconName::PanelRightClose,
1067 IconName::PanelRightOpen,
1068 IconName::Plus,
1069 IconName::Redo,
1070 IconName::Redo2,
1071 IconName::Replace,
1072 IconName::ResizeCorner,
1073 IconName::Search,
1074 IconName::Settings,
1075 IconName::Settings2,
1076 IconName::SortAscending,
1077 IconName::SortDescending,
1078 IconName::SquareTerminal,
1079 IconName::Star,
1080 IconName::StarOff,
1081 IconName::Sun,
1082 IconName::ThumbsDown,
1083 IconName::ThumbsUp,
1084 IconName::TriangleAlert,
1085 IconName::Undo,
1086 IconName::Undo2,
1087 IconName::User,
1088 IconName::WindowClose,
1089 IconName::WindowMaximize,
1090 IconName::WindowMinimize,
1091 IconName::WindowRestore,
1092 ];
1093
1094 #[test]
1095 fn all_icons_have_lucide_mapping() {
1096 for icon in ALL_ICON_NAMES {
1097 assert!(
1098 lucide_name_for_gpui_icon(icon.clone()).is_some(),
1099 "Missing Lucide mapping for an IconName variant",
1100 );
1101 }
1102 }
1103
1104 #[test]
1105 fn all_icons_have_material_mapping() {
1106 for icon in ALL_ICON_NAMES {
1107 assert!(
1108 material_name_for_gpui_icon(icon.clone()).is_some(),
1109 "Missing Material mapping for an IconName variant",
1110 );
1111 }
1112 }
1113
1114 #[test]
1117 fn icon_name_dialog_warning_maps_to_triangle_alert() {
1118 assert!(matches!(
1119 icon_name(IconRole::DialogWarning),
1120 Some(IconName::TriangleAlert)
1121 ));
1122 }
1123
1124 #[test]
1125 fn icon_name_dialog_error_maps_to_circle_x() {
1126 assert!(matches!(
1127 icon_name(IconRole::DialogError),
1128 Some(IconName::CircleX)
1129 ));
1130 }
1131
1132 #[test]
1133 fn icon_name_dialog_info_maps_to_info() {
1134 assert!(matches!(
1135 icon_name(IconRole::DialogInfo),
1136 Some(IconName::Info)
1137 ));
1138 }
1139
1140 #[test]
1141 fn icon_name_dialog_success_maps_to_circle_check() {
1142 assert!(matches!(
1143 icon_name(IconRole::DialogSuccess),
1144 Some(IconName::CircleCheck)
1145 ));
1146 }
1147
1148 #[test]
1149 fn icon_name_window_close_maps() {
1150 assert!(matches!(
1151 icon_name(IconRole::WindowClose),
1152 Some(IconName::WindowClose)
1153 ));
1154 }
1155
1156 #[test]
1157 fn icon_name_action_copy_maps_to_copy() {
1158 assert!(matches!(
1159 icon_name(IconRole::ActionCopy),
1160 Some(IconName::Copy)
1161 ));
1162 }
1163
1164 #[test]
1165 fn icon_name_nav_back_maps_to_chevron_left() {
1166 assert!(matches!(
1167 icon_name(IconRole::NavBack),
1168 Some(IconName::ChevronLeft)
1169 ));
1170 }
1171
1172 #[test]
1173 fn icon_name_file_generic_maps_to_file() {
1174 assert!(matches!(
1175 icon_name(IconRole::FileGeneric),
1176 Some(IconName::File)
1177 ));
1178 }
1179
1180 #[test]
1181 fn icon_name_status_check_maps_to_check() {
1182 assert!(matches!(
1183 icon_name(IconRole::StatusCheck),
1184 Some(IconName::Check)
1185 ));
1186 }
1187
1188 #[test]
1189 fn icon_name_user_account_maps_to_user() {
1190 assert!(matches!(
1191 icon_name(IconRole::UserAccount),
1192 Some(IconName::User)
1193 ));
1194 }
1195
1196 #[test]
1197 fn icon_name_notification_maps_to_bell() {
1198 assert!(matches!(
1199 icon_name(IconRole::Notification),
1200 Some(IconName::Bell)
1201 ));
1202 }
1203
1204 #[test]
1206 fn icon_name_shield_returns_none() {
1207 assert!(icon_name(IconRole::Shield).is_none());
1208 }
1209
1210 #[test]
1211 fn icon_name_lock_returns_none() {
1212 assert!(icon_name(IconRole::Lock).is_none());
1213 }
1214
1215 #[test]
1216 fn icon_name_action_save_returns_none() {
1217 assert!(icon_name(IconRole::ActionSave).is_none());
1218 }
1219
1220 #[test]
1221 fn icon_name_help_returns_none() {
1222 assert!(icon_name(IconRole::Help).is_none());
1223 }
1224
1225 #[test]
1226 fn icon_name_dialog_question_returns_none() {
1227 assert!(icon_name(IconRole::DialogQuestion).is_none());
1228 }
1229
1230 #[test]
1232 fn icon_name_maps_at_least_28_roles() {
1233 let some_count = IconRole::ALL
1234 .iter()
1235 .filter(|r| icon_name(**r).is_some())
1236 .count();
1237 assert!(
1238 some_count >= 28,
1239 "Expected at least 28 mappings, got {}",
1240 some_count
1241 );
1242 }
1243
1244 #[test]
1245 fn icon_name_maps_exactly_30_roles() {
1246 let some_count = IconRole::ALL
1247 .iter()
1248 .filter(|r| icon_name(**r).is_some())
1249 .count();
1250 assert_eq!(
1251 some_count, 30,
1252 "Expected exactly 30 mappings, got {some_count}"
1253 );
1254 }
1255
1256 #[test]
1259 fn to_image_source_svg_returns_bmp_rasterized() {
1260 let svg = IconData::Svg(
1262 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(),
1263 );
1264 let source = to_image_source(&svg, None, None).expect("valid SVG should convert");
1265 match source {
1267 ImageSource::Image(arc) => {
1268 assert_eq!(arc.format, ImageFormat::Bmp);
1269 assert!(arc.bytes.starts_with(b"BM"), "BMP should start with 'BM'");
1270 }
1271 _ => panic!("Expected ImageSource::Image for SVG data"),
1272 }
1273 }
1274
1275 #[test]
1276 fn to_image_source_rgba_returns_bmp_image_source() {
1277 let rgba = IconData::Rgba {
1278 width: 2,
1279 height: 2,
1280 data: vec![
1281 255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 0, 255, ],
1286 };
1287 let source = to_image_source(&rgba, None, None).expect("RGBA should convert");
1288 match source {
1289 ImageSource::Image(arc) => {
1290 assert_eq!(arc.format, ImageFormat::Bmp);
1291 assert_eq!(&arc.bytes[0..2], b"BM");
1293 }
1294 _ => panic!("Expected ImageSource::Image for RGBA data"),
1295 }
1296 }
1297
1298 #[test]
1299 fn to_image_source_with_color() {
1300 let svg = IconData::Svg(
1301 b"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M0 0' stroke='currentColor'/></svg>".to_vec(),
1302 );
1303 let color = gpui::hsla(0.0, 1.0, 0.5, 1.0);
1304 let result = to_image_source(&svg, Some(color), None);
1305 assert!(result.is_some(), "colorized SVG should convert");
1306 }
1307
1308 #[test]
1309 fn to_image_source_with_custom_size() {
1310 let svg = IconData::Svg(
1311 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(),
1312 );
1313 let result = to_image_source(&svg, None, Some(32));
1314 assert!(result.is_some(), "custom size SVG should convert");
1315 }
1316
1317 #[test]
1320 fn encode_rgba_as_bmp_correct_file_size() {
1321 let rgba = vec![0u8; 4 * 4 * 4]; let bmp = encode_rgba_as_bmp(4, 4, &rgba);
1323 let expected_size = 14 + 108 + (4 * 4 * 4); assert_eq!(bmp.len(), expected_size);
1325 }
1326
1327 #[test]
1328 fn encode_rgba_as_bmp_starts_with_bm() {
1329 let rgba = vec![0u8; 4]; let bmp = encode_rgba_as_bmp(1, 1, &rgba);
1331 assert_eq!(&bmp[0..2], b"BM");
1332 }
1333
1334 #[test]
1335 fn encode_rgba_as_bmp_pixel_order_is_bgra() {
1336 let rgba = vec![0xAA, 0xBB, 0xCC, 0xDD];
1338 let bmp = encode_rgba_as_bmp(1, 1, &rgba);
1339 let pixel_offset = (14 + 108) as usize;
1340 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); }
1346 #[test]
1349 fn colorize_svg_replaces_fill_black() {
1350 let svg = b"<svg><path fill=\"black\" d=\"M0 0h24v24H0z\"/></svg>";
1351 let color = gpui::hsla(0.6, 0.7, 0.5, 1.0); let result = colorize_svg(svg, color);
1353 let result_str = String::from_utf8(result).unwrap();
1354 assert!(
1355 !result_str.contains("fill=\"black\""),
1356 "fill=\"black\" should be replaced, got: {}",
1357 result_str
1358 );
1359 assert!(
1360 result_str.contains("fill=\"#"),
1361 "should contain hex fill, got: {}",
1362 result_str
1363 );
1364 }
1365
1366 #[test]
1367 fn colorize_svg_replaces_fill_hex_black() {
1368 let svg = b"<svg><rect fill=\"#000000\" width=\"24\" height=\"24\"/></svg>";
1369 let color = gpui::hsla(0.0, 1.0, 0.5, 1.0); let result = colorize_svg(svg, color);
1371 let result_str = String::from_utf8(result).unwrap();
1372 assert!(
1373 !result_str.contains("#000000"),
1374 "fill=\"#000000\" should be replaced, got: {}",
1375 result_str
1376 );
1377 }
1378
1379 #[test]
1380 fn colorize_svg_replaces_fill_short_hex_black() {
1381 let svg = b"<svg><rect fill=\"#000\" width=\"24\" height=\"24\"/></svg>";
1382 let color = gpui::hsla(0.3, 0.8, 0.4, 1.0); let result = colorize_svg(svg, color);
1384 let result_str = String::from_utf8(result).unwrap();
1385 assert!(
1386 !result_str.contains("fill=\"#000\""),
1387 "fill=\"#000\" should be replaced, got: {}",
1388 result_str
1389 );
1390 }
1391
1392 #[test]
1393 fn colorize_svg_current_color_still_works() {
1394 let svg = b"<svg><path stroke=\"currentColor\" d=\"M0 0\"/></svg>";
1395 let color = gpui::hsla(0.0, 1.0, 0.5, 1.0);
1396 let result = colorize_svg(svg, color);
1397 let result_str = String::from_utf8(result).unwrap();
1398 assert!(
1399 !result_str.contains("currentColor"),
1400 "currentColor should be replaced"
1401 );
1402 assert!(result_str.contains('#'), "should contain hex color");
1403 }
1404
1405 #[test]
1406 fn colorize_svg_implicit_black_still_works() {
1407 let svg = b"<svg xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M0 0\"/></svg>";
1409 let color = gpui::hsla(0.0, 1.0, 0.5, 1.0);
1410 let result = colorize_svg(svg, color);
1411 let result_str = String::from_utf8(result).unwrap();
1412 assert!(
1413 result_str.contains("fill=\"#"),
1414 "should inject fill into root svg tag, got: {}",
1415 result_str
1416 );
1417 }
1418
1419 #[derive(Debug)]
1423 struct TestCustomIcon;
1424
1425 impl native_theme::IconProvider for TestCustomIcon {
1426 fn icon_name(&self, _set: native_theme::IconSet) -> Option<&str> {
1427 None }
1429 fn icon_svg(&self, _set: native_theme::IconSet) -> Option<&'static [u8]> {
1430 Some(b"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><circle cx='12' cy='12' r='10'/></svg>")
1431 }
1432 }
1433
1434 #[derive(Debug)]
1436 struct EmptyProvider;
1437
1438 impl native_theme::IconProvider for EmptyProvider {
1439 fn icon_name(&self, _set: native_theme::IconSet) -> Option<&str> {
1440 None
1441 }
1442 fn icon_svg(&self, _set: native_theme::IconSet) -> Option<&'static [u8]> {
1443 None
1444 }
1445 }
1446
1447 #[test]
1448 fn custom_icon_to_image_source_with_svg_provider_returns_some() {
1449 let result = custom_icon_to_image_source(
1450 &TestCustomIcon,
1451 native_theme::IconSet::Material,
1452 None,
1453 None,
1454 );
1455 assert!(result.is_some());
1456 }
1457
1458 #[test]
1459 fn custom_icon_to_image_source_with_empty_provider_returns_none() {
1460 let result = custom_icon_to_image_source(
1461 &EmptyProvider,
1462 native_theme::IconSet::Material,
1463 None,
1464 None,
1465 );
1466 assert!(result.is_none());
1467 }
1468
1469 #[test]
1470 fn custom_icon_to_image_source_with_color() {
1471 let color = Hsla {
1472 h: 0.0,
1473 s: 1.0,
1474 l: 0.5,
1475 a: 1.0,
1476 };
1477 let result = custom_icon_to_image_source(
1478 &TestCustomIcon,
1479 native_theme::IconSet::Material,
1480 Some(color),
1481 None,
1482 );
1483 assert!(result.is_some());
1484 }
1485
1486 #[test]
1487 fn custom_icon_to_image_source_accepts_dyn_provider() {
1488 let boxed: Box<dyn native_theme::IconProvider> = Box::new(TestCustomIcon);
1489 let result =
1490 custom_icon_to_image_source(&*boxed, native_theme::IconSet::Material, None, None);
1491 assert!(result.is_some());
1492 }
1493
1494 #[test]
1497 fn animated_frames_returns_sources() {
1498 let anim = AnimatedIcon::Frames {
1499 frames: vec![
1500 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()),
1501 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()),
1502 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()),
1503 ],
1504 frame_duration_ms: 80,
1505 };
1506 let result = animated_frames_to_image_sources(&anim);
1507 let ais = result.expect("Frames variant should return Some");
1508 assert_eq!(ais.sources.len(), 3);
1509 assert_eq!(ais.frame_duration_ms, 80);
1510 }
1511
1512 #[test]
1513 fn animated_frames_transform_returns_none() {
1514 let anim = AnimatedIcon::Transform {
1515 icon: IconData::Svg(
1516 b"<svg xmlns='http://www.w3.org/2000/svg'><circle cx='12' cy='12' r='10'/></svg>"
1517 .to_vec(),
1518 ),
1519 animation: native_theme::TransformAnimation::Spin { duration_ms: 1000 },
1520 };
1521 let result = animated_frames_to_image_sources(&anim);
1522 assert!(result.is_none());
1523 }
1524
1525 #[test]
1526 fn animated_frames_empty_returns_empty_vec() {
1527 let anim = AnimatedIcon::Frames {
1528 frames: vec![],
1529 frame_duration_ms: 80,
1530 };
1531 let result = animated_frames_to_image_sources(&anim);
1532 let ais = result.expect("Frames variant should return Some even if empty");
1533 assert_eq!(ais.sources.len(), 0);
1534 assert_eq!(ais.frame_duration_ms, 80);
1535 }
1536
1537 #[test]
1538 fn spin_animation_constructs_without_context() {
1539 let svg_element = gpui::svg();
1540 let _animated = with_spin_animation(svg_element, "test-spin", 1000);
1543 }
1544}
1545
1546#[cfg(test)]
1547#[cfg(target_os = "linux")]
1548#[allow(clippy::unwrap_used, clippy::expect_used)]
1549mod freedesktop_mapping_tests {
1550 use super::tests::ALL_ICON_NAMES;
1551 use super::*;
1552 use native_theme::LinuxDesktop;
1553
1554 #[test]
1555 fn all_86_gpui_icons_have_mapping_on_kde() {
1556 let mut missing_count = 0;
1557 for name in ALL_ICON_NAMES {
1558 if freedesktop_name_for_gpui_icon(name.clone(), LinuxDesktop::Kde).is_none() {
1559 missing_count += 1;
1560 }
1561 }
1562 assert!(
1563 missing_count == 0,
1564 "Missing KDE freedesktop mappings for {} icon(s)",
1565 missing_count,
1566 );
1567 }
1568
1569 #[test]
1570 fn eye_differs_by_de() {
1571 assert_eq!(
1572 freedesktop_name_for_gpui_icon(IconName::Eye, LinuxDesktop::Kde),
1573 Some("view-visible"),
1574 );
1575 assert_eq!(
1576 freedesktop_name_for_gpui_icon(IconName::Eye, LinuxDesktop::Gnome),
1577 Some("view-reveal"),
1578 );
1579 }
1580
1581 #[test]
1582 fn freedesktop_standard_ignores_de() {
1583 assert_eq!(
1585 freedesktop_name_for_gpui_icon(IconName::Copy, LinuxDesktop::Kde),
1586 freedesktop_name_for_gpui_icon(IconName::Copy, LinuxDesktop::Gnome),
1587 );
1588 }
1589
1590 #[test]
1591 fn all_86_gpui_icons_have_mapping_on_gnome() {
1592 let mut missing_count = 0;
1593 for name in ALL_ICON_NAMES {
1594 if freedesktop_name_for_gpui_icon(name.clone(), LinuxDesktop::Gnome).is_none() {
1595 missing_count += 1;
1596 }
1597 }
1598 assert!(
1599 missing_count == 0,
1600 "Missing GNOME freedesktop mappings for {} icon(s)",
1601 missing_count,
1602 );
1603 }
1604
1605 #[test]
1606 fn xfce_uses_gnome_names() {
1607 assert_eq!(
1609 freedesktop_name_for_gpui_icon(IconName::Eye, LinuxDesktop::Xfce),
1610 Some("view-reveal"),
1611 );
1612 assert_eq!(
1613 freedesktop_name_for_gpui_icon(IconName::Bell, LinuxDesktop::Xfce),
1614 Some("alarm"),
1615 );
1616 }
1617
1618 #[test]
1619 fn all_kde_names_resolve_in_breeze() {
1620 let theme = native_theme::system_icon_theme();
1621 if !theme.to_lowercase().contains("breeze") {
1623 eprintln!("Skipping: system theme is '{}', not Breeze", theme);
1624 return;
1625 }
1626
1627 let mut missing = Vec::new();
1628 for name in ALL_ICON_NAMES {
1629 let fd_name = freedesktop_name_for_gpui_icon(name.clone(), LinuxDesktop::Kde)
1630 .expect("icon has no KDE mapping");
1631 if native_theme::load_freedesktop_icon_by_name(fd_name, theme, 24).is_none() {
1632 missing.push(format!("{} (not found)", fd_name));
1633 }
1634 }
1635 assert!(
1636 missing.is_empty(),
1637 "These gpui icons did not resolve in Breeze:\n {}",
1638 missing.join("\n "),
1639 );
1640 }
1641
1642 #[test]
1643 fn gnome_names_resolve_in_adwaita() {
1644 let mut missing = Vec::new();
1647 for name in ALL_ICON_NAMES {
1648 if let Some(fd_name) = freedesktop_name_for_gpui_icon(name.clone(), LinuxDesktop::Gnome)
1649 {
1650 if native_theme::load_freedesktop_icon_by_name(fd_name, "Adwaita", 24).is_none() {
1652 missing.push(format!("{} (not found)", fd_name));
1653 }
1654 }
1655 }
1657 assert!(
1658 missing.is_empty(),
1659 "These GNOME mappings did not resolve in Adwaita:\n {}",
1660 missing.join("\n "),
1661 );
1662 }
1663}