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,
92 IconRole::ActionCopy => IconName::Copy,
93 IconRole::ActionUndo => IconName::Undo2,
94 IconRole::ActionRedo => IconName::Redo2,
95 IconRole::ActionSearch => IconName::Search,
96 IconRole::ActionSettings => IconName::Settings,
97 IconRole::ActionAdd => IconName::Plus,
98 IconRole::ActionRemove => IconName::Minus,
99
100 IconRole::NavBack => IconName::ChevronLeft,
102 IconRole::NavForward => IconName::ChevronRight,
103 IconRole::NavUp => IconName::ChevronUp,
104 IconRole::NavDown => IconName::ChevronDown,
105 IconRole::NavMenu => IconName::Menu,
106
107 IconRole::FileGeneric => IconName::File,
109 IconRole::FolderClosed => IconName::FolderClosed,
110 IconRole::FolderOpen => IconName::FolderOpen,
111 IconRole::TrashEmpty => IconName::Delete,
112
113 IconRole::StatusBusy => IconName::Loader,
115 IconRole::StatusCheck => IconName::Check,
116 IconRole::StatusError => IconName::CircleX,
120
121 IconRole::UserAccount => IconName::User,
123 IconRole::Notification => IconName::Bell,
124
125 _ => return None,
127 })
128}
129
130#[must_use]
137pub fn lucide_name_for_gpui_icon(icon: IconName) -> &'static str {
138 match icon {
139 IconName::ALargeSmall => "a-large-small",
140 IconName::ArrowDown => "arrow-down",
141 IconName::ArrowLeft => "arrow-left",
142 IconName::ArrowRight => "arrow-right",
143 IconName::ArrowUp => "arrow-up",
144 IconName::Asterisk => "asterisk",
145 IconName::Bell => "bell",
146 IconName::BookOpen => "book-open",
147 IconName::Bot => "bot",
148 IconName::Building2 => "building-2",
149 IconName::Calendar => "calendar",
150 IconName::CaseSensitive => "case-sensitive",
151 IconName::ChartPie => "chart-pie",
152 IconName::Check => "check",
153 IconName::ChevronDown => "chevron-down",
154 IconName::ChevronLeft => "chevron-left",
155 IconName::ChevronRight => "chevron-right",
156 IconName::ChevronsUpDown => "chevrons-up-down",
157 IconName::ChevronUp => "chevron-up",
158 IconName::CircleCheck => "circle-check",
159 IconName::CircleUser => "circle-user",
160 IconName::CircleX => "circle-x",
161 IconName::Close => "close",
162 IconName::Copy => "copy",
163 IconName::Dash => "dash",
164 IconName::Delete => "delete",
165 IconName::Ellipsis => "ellipsis",
166 IconName::EllipsisVertical => "ellipsis-vertical",
167 IconName::ExternalLink => "external-link",
168 IconName::Eye => "eye",
169 IconName::EyeOff => "eye-off",
170 IconName::File => "file",
171 IconName::Folder => "folder",
172 IconName::FolderClosed => "folder-closed",
173 IconName::FolderOpen => "folder-open",
174 IconName::Frame => "frame",
175 IconName::GalleryVerticalEnd => "gallery-vertical-end",
176 IconName::GitHub => "github",
177 IconName::Globe => "globe",
178 IconName::Heart => "heart",
179 IconName::HeartOff => "heart-off",
180 IconName::Inbox => "inbox",
181 IconName::Info => "info",
182 IconName::Inspector => "inspect",
183 IconName::LayoutDashboard => "layout-dashboard",
184 IconName::Loader => "loader",
185 IconName::LoaderCircle => "loader-circle",
186 IconName::Map => "map",
187 IconName::Maximize => "maximize",
188 IconName::Menu => "menu",
189 IconName::Minimize => "minimize",
190 IconName::Minus => "minus",
191 IconName::Moon => "moon",
192 IconName::Palette => "palette",
193 IconName::PanelBottom => "panel-bottom",
194 IconName::PanelBottomOpen => "panel-bottom-open",
195 IconName::PanelLeft => "panel-left",
196 IconName::PanelLeftClose => "panel-left-close",
197 IconName::PanelLeftOpen => "panel-left-open",
198 IconName::PanelRight => "panel-right",
199 IconName::PanelRightClose => "panel-right-close",
200 IconName::PanelRightOpen => "panel-right-open",
201 IconName::Plus => "plus",
202 IconName::Redo => "redo",
203 IconName::Redo2 => "redo-2",
204 IconName::Replace => "replace",
205 IconName::ResizeCorner => "resize-corner",
206 IconName::Search => "search",
207 IconName::Settings => "settings",
208 IconName::Settings2 => "settings-2",
209 IconName::SortAscending => "sort-ascending",
210 IconName::SortDescending => "sort-descending",
211 IconName::SquareTerminal => "square-terminal",
212 IconName::Star => "star",
213 IconName::StarOff => "star-off",
214 IconName::Sun => "sun",
215 IconName::ThumbsDown => "thumbs-down",
216 IconName::ThumbsUp => "thumbs-up",
217 IconName::TriangleAlert => "triangle-alert",
218 IconName::Undo => "undo",
219 IconName::Undo2 => "undo-2",
220 IconName::User => "user",
221 IconName::WindowClose => "window-close",
222 IconName::WindowMaximize => "window-maximize",
223 IconName::WindowMinimize => "window-minimize",
224 IconName::WindowRestore => "window-restore",
225 }
226}
227
228#[must_use]
247pub fn material_name_for_gpui_icon(icon: IconName) -> &'static str {
248 match icon {
249 IconName::ALargeSmall => "font_size",
250 IconName::ArrowDown => "arrow_downward",
251 IconName::ArrowLeft => "arrow_back",
252 IconName::ArrowRight => "arrow_forward",
253 IconName::ArrowUp => "arrow_upward",
254 IconName::Asterisk => "emergency",
255 IconName::Bell => "notifications",
256 IconName::BookOpen => "menu_book",
257 IconName::Bot => "smart_toy",
258 IconName::Building2 => "apartment",
259 IconName::Calendar => "calendar_today",
260 IconName::CaseSensitive => "match_case",
261 IconName::ChartPie => "pie_chart",
262 IconName::Check => "check",
263 IconName::ChevronDown => "expand_more",
264 IconName::ChevronLeft => "chevron_left",
265 IconName::ChevronRight => "chevron_right",
266 IconName::ChevronsUpDown => "unfold_more",
267 IconName::ChevronUp => "expand_less",
268 IconName::CircleCheck => "check_circle",
269 IconName::CircleUser => "account_circle",
270 IconName::CircleX => "cancel",
271 IconName::Close => "close",
272 IconName::Copy => "content_copy",
273 IconName::Dash => "remove",
274 IconName::Delete => "delete",
275 IconName::Ellipsis => "more_horiz",
276 IconName::EllipsisVertical => "more_vert",
277 IconName::ExternalLink => "open_in_new",
278 IconName::Eye => "visibility",
279 IconName::EyeOff => "visibility_off",
280 IconName::File => "description",
281 IconName::Folder => "folder",
282 IconName::FolderClosed => "folder",
283 IconName::FolderOpen => "folder_open",
284 IconName::Frame => "crop_free",
285 IconName::GalleryVerticalEnd => "view_carousel",
286 IconName::GitHub => "code",
287 IconName::Globe => "language",
288 IconName::Heart => "favorite",
289 IconName::HeartOff => "heart_broken",
290 IconName::Inbox => "inbox",
291 IconName::Info => "info",
292 IconName::Inspector => "developer_mode",
293 IconName::LayoutDashboard => "dashboard",
294 IconName::Loader => "progress_activity",
295 IconName::LoaderCircle => "autorenew",
296 IconName::Map => "map",
297 IconName::Maximize => "open_in_full",
298 IconName::Menu => "menu",
299 IconName::Minimize => "minimize",
300 IconName::Minus => "remove",
301 IconName::Moon => "dark_mode",
302 IconName::Palette => "palette",
303 IconName::PanelBottom => "dock_to_bottom",
304 IconName::PanelBottomOpen => "web_asset",
305 IconName::PanelLeft => "side_navigation",
306 IconName::PanelLeftClose => "left_panel_close",
307 IconName::PanelLeftOpen => "left_panel_open",
308 IconName::PanelRight => "right_panel_close",
309 IconName::PanelRightClose => "right_panel_close",
310 IconName::PanelRightOpen => "right_panel_open",
311 IconName::Plus => "add",
312 IconName::Redo => "redo",
313 IconName::Redo2 => "redo",
314 IconName::Replace => "find_replace",
315 IconName::ResizeCorner => "drag_indicator",
316 IconName::Search => "search",
317 IconName::Settings => "settings",
318 IconName::Settings2 => "tune",
319 IconName::SortAscending => "arrow_upward",
320 IconName::SortDescending => "arrow_downward",
321 IconName::SquareTerminal => "terminal",
322 IconName::Star => "star",
323 IconName::StarOff => "star_border",
324 IconName::Sun => "light_mode",
325 IconName::ThumbsDown => "thumb_down",
326 IconName::ThumbsUp => "thumb_up",
327 IconName::TriangleAlert => "warning",
328 IconName::Undo => "undo",
329 IconName::Undo2 => "undo",
330 IconName::User => "person",
331 IconName::WindowClose => "close",
332 IconName::WindowMaximize => "open_in_full",
333 IconName::WindowMinimize => "minimize",
334 IconName::WindowRestore => "close_fullscreen",
335 }
336}
337
338#[cfg(target_os = "linux")]
359#[must_use]
360pub fn freedesktop_name_for_gpui_icon(
361 icon: IconName,
362 de: native_theme::LinuxDesktop,
363) -> &'static str {
364 use native_theme::LinuxDesktop;
365
366 let is_gtk = matches!(
368 de,
369 LinuxDesktop::Gnome
370 | LinuxDesktop::Budgie
371 | LinuxDesktop::Cinnamon
372 | LinuxDesktop::Mate
373 | LinuxDesktop::Xfce
374 );
375
376 match icon {
377 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 => {
424 if is_gtk {
425 "go-bottom"
426 } else {
427 "go-down-skip"
428 }
429 } IconName::ArrowLeft => {
431 if is_gtk {
432 "go-first"
433 } else {
434 "go-previous-skip"
435 }
436 } IconName::ArrowRight => {
438 if is_gtk {
439 "go-last"
440 } else {
441 "go-next-skip"
442 }
443 } IconName::ArrowUp => {
445 if is_gtk {
446 "go-top"
447 } else {
448 "go-up-skip"
449 }
450 } IconName::Calendar => {
452 if is_gtk {
453 "x-office-calendar"
454 } else {
455 "view-calendar"
456 }
457 } IconName::Check => {
459 if is_gtk {
460 "object-select"
461 } else {
462 "dialog-ok"
463 }
464 } IconName::CircleCheck => {
466 if is_gtk {
467 "object-select"
468 } else {
469 "emblem-ok-symbolic"
470 }
471 } IconName::CircleUser => {
473 if is_gtk {
474 "avatar-default"
475 } else {
476 "user-identity"
477 }
478 } IconName::Close => {
480 if is_gtk {
481 "window-close"
482 } else {
483 "tab-close"
484 }
485 } IconName::Ellipsis => {
487 if is_gtk {
488 "view-more-horizontal"
489 } else {
490 "overflow-menu"
491 }
492 } IconName::EllipsisVertical => {
494 if is_gtk {
495 "view-more"
496 } else {
497 "overflow-menu"
498 }
499 } IconName::Eye => {
501 if is_gtk {
502 "view-reveal"
503 } else {
504 "view-visible"
505 }
506 } IconName::EyeOff => {
508 if is_gtk {
509 "view-conceal"
510 } else {
511 "view-hidden"
512 }
513 } IconName::Frame => {
515 if is_gtk {
516 "selection-mode"
517 } else {
518 "select-rectangular"
519 }
520 } IconName::Heart => {
522 if is_gtk {
523 "starred"
524 } else {
525 "emblem-favorite"
526 }
527 } IconName::Loader => {
529 if is_gtk {
530 "content-loading"
531 } else {
532 "process-working"
533 }
534 } IconName::LoaderCircle => {
536 if is_gtk {
537 "content-loading"
538 } else {
539 "process-working"
540 }
541 } IconName::Palette => {
543 if is_gtk {
544 "color-select"
545 } else {
546 "palette"
547 }
548 } IconName::PanelLeft => {
550 if is_gtk {
551 "sidebar-show"
552 } else {
553 "sidebar-expand-left"
554 }
555 } IconName::PanelLeftClose => {
557 if is_gtk {
558 "sidebar-show"
559 } else {
560 "view-left-close"
561 }
562 } IconName::PanelLeftOpen => {
564 if is_gtk {
565 "sidebar-show"
566 } else {
567 "view-left-new"
568 }
569 } IconName::PanelRight => {
571 if is_gtk {
572 "sidebar-show-right"
573 } else {
574 "view-right-new"
575 }
576 } IconName::PanelRightClose => {
578 if is_gtk {
579 "sidebar-show-right"
580 } else {
581 "view-right-close"
582 }
583 } IconName::PanelRightOpen => {
585 if is_gtk {
586 "sidebar-show-right"
587 } else {
588 "view-right-new"
589 }
590 } IconName::ResizeCorner => {
592 if is_gtk {
593 "list-drag-handle"
594 } else {
595 "drag-handle"
596 }
597 } IconName::Settings2 => {
599 if is_gtk {
600 "preferences-other"
601 } else {
602 "configure"
603 }
604 } IconName::ALargeSmall => {
608 if is_gtk {
609 "zoom-in"
610 } else {
611 "format-font-size-more"
612 }
613 } IconName::Asterisk => {
615 if is_gtk {
616 "starred"
617 } else {
618 "rating"
619 }
620 } IconName::Bell => {
622 if is_gtk {
623 "alarm"
624 } else {
625 "notification-active"
626 }
627 } IconName::Building2 => {
629 if is_gtk {
630 "network-workgroup"
631 } else {
632 "applications-office"
633 }
634 } IconName::CaseSensitive => {
636 if is_gtk {
637 "format-text-rich"
638 } else {
639 "format-text-uppercase"
640 }
641 } IconName::ChartPie => {
643 if is_gtk {
644 "x-office-spreadsheet"
645 } else {
646 "office-chart-pie"
647 }
648 } IconName::ChevronsUpDown => {
650 if is_gtk {
651 "list-drag-handle"
652 } else {
653 "handle-sort"
654 }
655 } IconName::ExternalLink => {
657 if is_gtk {
658 "insert-link"
659 } else {
660 "external-link"
661 }
662 } IconName::GalleryVerticalEnd => {
664 if is_gtk {
665 "view-paged"
666 } else {
667 "view-list-icons"
668 }
669 } IconName::GitHub => {
671 if is_gtk {
672 "applications-engineering"
673 } else {
674 "vcs-branch"
675 }
676 } IconName::Globe => {
678 if is_gtk {
679 "web-browser"
680 } else {
681 "globe"
682 }
683 } IconName::Inbox => {
685 if is_gtk {
686 "mail-send-receive"
687 } else {
688 "mail-folder-inbox"
689 }
690 } IconName::Inspector => {
692 if is_gtk {
693 "preferences-system-details"
694 } else {
695 "code-context"
696 }
697 } IconName::PanelBottom => {
699 if is_gtk {
700 "view-dual"
701 } else {
702 "view-split-top-bottom"
703 }
704 } IconName::PanelBottomOpen => {
706 if is_gtk {
707 "view-dual"
708 } else {
709 "view-split-top-bottom"
710 }
711 } IconName::ThumbsDown => {
713 if is_gtk {
714 "process-stop"
715 } else {
716 "rating-unrated"
717 }
718 } IconName::ThumbsUp => {
720 if is_gtk {
721 "checkbox-checked"
722 } else {
723 "approved"
724 }
725 } }
727}
728
729const SVG_RASTERIZE_SIZE: u32 = 48;
734
735const MAX_ICON_SIZE: u32 = 512;
740
741#[must_use]
771pub fn to_image_source(
772 data: &IconData,
773 color: Option<Hsla>,
774 size: Option<u32>,
775) -> Option<ImageSource> {
776 let raster_size = size.unwrap_or(SVG_RASTERIZE_SIZE).clamp(1, MAX_ICON_SIZE);
778 match data {
779 IconData::Svg(bytes) => {
780 if let Some(c) = color {
781 let colored = colorize_svg(bytes, c);
782 svg_to_bmp_source(&colored, raster_size)
783 } else {
784 svg_to_bmp_source(bytes, raster_size)
785 }
786 }
787 IconData::Rgba {
788 width,
789 height,
790 data,
791 } => {
792 let bmp = encode_rgba_as_bmp(*width, *height, data)?;
793 let image = Image::from_bytes(ImageFormat::Bmp, bmp);
794 Some(ImageSource::Image(Arc::new(image)))
795 }
796 _ => None,
797 }
798}
799
800#[must_use]
812pub fn into_image_source(
813 data: IconData,
814 color: Option<Hsla>,
815 size: Option<u32>,
816) -> Option<ImageSource> {
817 to_image_source(&data, color, size)
818}
819
820#[must_use]
830pub fn custom_icon_to_image_source(
831 provider: &(impl IconProvider + ?Sized),
832 icon_set: native_theme::IconSet,
833 color: Option<Hsla>,
834 size: Option<u32>,
835) -> Option<ImageSource> {
836 let data = load_custom_icon(provider, icon_set)?;
837 to_image_source(&data, color, size)
838}
839
840#[must_use]
859pub fn bundled_icon_to_image_source(
860 icon: IconName,
861 icon_set: native_theme::IconSet,
862 color: Option<Hsla>,
863 size: Option<u32>,
864) -> Option<ImageSource> {
865 let name = match icon_set {
866 native_theme::IconSet::Lucide => lucide_name_for_gpui_icon(icon),
867 native_theme::IconSet::Material => material_name_for_gpui_icon(icon),
868 _ => return None,
869 };
870 let svg = native_theme::bundled_icon_by_name(name, icon_set)?;
871 svg_bytes_to_image_source(svg, color, size)
873}
874
875#[must_use]
883pub fn bundled_svg_to_image_source(
884 svg_bytes: &[u8],
885 color: Option<Hsla>,
886 size: Option<u32>,
887) -> Option<ImageSource> {
888 svg_bytes_to_image_source(svg_bytes, color, size)
890}
891
892fn svg_bytes_to_image_source(
898 svg_bytes: &[u8],
899 color: Option<Hsla>,
900 size: Option<u32>,
901) -> Option<ImageSource> {
902 let raster_size = size.unwrap_or(SVG_RASTERIZE_SIZE).clamp(1, MAX_ICON_SIZE);
903 if let Some(c) = color {
904 let colored = colorize_svg(svg_bytes, c);
905 svg_to_bmp_source(&colored, raster_size)
906 } else {
907 svg_to_bmp_source(svg_bytes, raster_size)
908 }
909}
910
911#[must_use]
944pub fn animated_frames_to_image_sources(
945 anim: &AnimatedIcon,
946 color: Option<Hsla>,
947 size: Option<u32>,
948) -> Option<AnimatedImageSources> {
949 match anim {
950 AnimatedIcon::Frames {
951 frames,
952 frame_duration_ms,
953 } => {
954 if frames.is_empty() {
955 return None;
956 }
957 let sources: Option<Vec<ImageSource>> = frames
961 .iter()
962 .map(|f| to_image_source(f, color, size))
963 .collect();
964 sources.map(|s| AnimatedImageSources {
965 sources: s,
966 frame_duration_ms: *frame_duration_ms,
967 })
968 }
969 _ => None,
970 }
971}
972
973#[must_use]
1002pub fn with_spin_animation(
1003 element: Svg,
1004 animation_id: impl Into<gpui::ElementId>,
1005 duration_ms: u32,
1006) -> impl gpui::IntoElement {
1007 element.with_animation(
1008 animation_id,
1009 Animation::new(Duration::from_millis(duration_ms as u64)).repeat(),
1010 |el, delta| el.with_transformation(Transformation::rotate(percentage(delta))),
1011 )
1012}
1013
1014fn svg_to_bmp_source(svg_bytes: &[u8], size: u32) -> Option<ImageSource> {
1022 let Ok(IconData::Rgba {
1023 width,
1024 height,
1025 data,
1026 }) = native_theme::rasterize::rasterize_svg(svg_bytes, size)
1027 else {
1028 return None;
1029 };
1030 let bmp = encode_rgba_as_bmp(width, height, &data)?;
1031 let image = Image::from_bytes(ImageFormat::Bmp, bmp);
1032 Some(ImageSource::Image(Arc::new(image)))
1033}
1034
1035fn colorize_svg(svg_bytes: &[u8], color: Hsla) -> Vec<u8> {
1055 let rgba: gpui::Rgba = color.into();
1056 let r = (rgba.r.clamp(0.0, 1.0) * 255.0).round() as u8;
1057 let g = (rgba.g.clamp(0.0, 1.0) * 255.0).round() as u8;
1058 let b = (rgba.b.clamp(0.0, 1.0) * 255.0).round() as u8;
1059 let hex = format!("#{r:02x}{g:02x}{b:02x}");
1060
1061 let Ok(svg_str) = std::str::from_utf8(svg_bytes) else {
1062 return svg_bytes.to_vec();
1065 };
1066
1067 let replaced = if svg_str.contains("currentColor") {
1069 svg_str.replace("currentColor", &hex)
1070 } else {
1071 svg_str.to_owned()
1072 };
1073
1074 let fill_hex = format!("fill=\"{hex}\"");
1076 let replaced = replaced
1077 .replace("fill=\"black\"", &fill_hex)
1078 .replace("fill=\"#000000\"", &fill_hex)
1079 .replace("fill=\"#000\"", &fill_hex);
1080
1081 let stroke_hex = format!("stroke=\"{hex}\"");
1083 let replaced = replaced
1084 .replace("stroke=\"black\"", &stroke_hex)
1085 .replace("stroke=\"#000000\"", &stroke_hex)
1086 .replace("stroke=\"#000\"", &stroke_hex);
1087
1088 if replaced != svg_str {
1089 return replaced.into_bytes();
1090 }
1091
1092 if let Some(pos) = svg_str.find("<svg")
1095 && let Some(close) = svg_str[pos..].find('>')
1096 {
1097 let tag_end = pos + close;
1098 let tag = &svg_str[pos..tag_end];
1099 if !tag.contains("fill=") {
1100 let inject_pos = if tag_end > 0 && svg_str.as_bytes()[tag_end - 1] == b'/' {
1102 tag_end - 1
1103 } else {
1104 tag_end
1105 };
1106 let mut result = String::with_capacity(svg_str.len() + 20);
1107 result.push_str(&svg_str[..inject_pos]);
1108 result.push_str(&format!(" fill=\"{hex}\""));
1109 result.push_str(&svg_str[inject_pos..]);
1110 return result.into_bytes();
1111 }
1112 }
1113
1114 svg_bytes.to_vec()
1116}
1117
1118fn encode_rgba_as_bmp(width: u32, height: u32, rgba: &[u8]) -> Option<Vec<u8>> {
1127 if width == 0 || height == 0 {
1128 return None;
1129 }
1130 let pixel_data_size = (width as usize)
1131 .checked_mul(height as usize)?
1132 .checked_mul(4)?;
1133 if rgba.len() != pixel_data_size {
1134 return None;
1135 }
1136 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()?;
1139
1140 let mut buf = Vec::with_capacity(file_size as usize);
1141
1142 let dib_header_u32 = dib_header_size as u32;
1143 let pixel_data_u32 = pixel_data_size as u32;
1144
1145 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()); if height > i32::MAX as u32 {
1157 return None;
1158 }
1159 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]);
1183
1184 buf.extend_from_slice(&0u32.to_le_bytes());
1186 buf.extend_from_slice(&0u32.to_le_bytes());
1187 buf.extend_from_slice(&0u32.to_le_bytes());
1188
1189 for pixel in rgba.chunks_exact(4) {
1191 buf.push(pixel[2]); buf.push(pixel[1]); buf.push(pixel[0]); buf.push(pixel[3]); }
1196
1197 Some(buf)
1198}
1199
1200#[cfg(test)]
1201#[allow(clippy::unwrap_used, clippy::expect_used)]
1202mod tests {
1203 use super::*;
1204
1205 pub(super) const ALL_ICON_NAMES: &[IconName] = &[
1206 IconName::ALargeSmall,
1207 IconName::ArrowDown,
1208 IconName::ArrowLeft,
1209 IconName::ArrowRight,
1210 IconName::ArrowUp,
1211 IconName::Asterisk,
1212 IconName::Bell,
1213 IconName::BookOpen,
1214 IconName::Bot,
1215 IconName::Building2,
1216 IconName::Calendar,
1217 IconName::CaseSensitive,
1218 IconName::ChartPie,
1219 IconName::Check,
1220 IconName::ChevronDown,
1221 IconName::ChevronLeft,
1222 IconName::ChevronRight,
1223 IconName::ChevronsUpDown,
1224 IconName::ChevronUp,
1225 IconName::CircleCheck,
1226 IconName::CircleUser,
1227 IconName::CircleX,
1228 IconName::Close,
1229 IconName::Copy,
1230 IconName::Dash,
1231 IconName::Delete,
1232 IconName::Ellipsis,
1233 IconName::EllipsisVertical,
1234 IconName::ExternalLink,
1235 IconName::Eye,
1236 IconName::EyeOff,
1237 IconName::File,
1238 IconName::Folder,
1239 IconName::FolderClosed,
1240 IconName::FolderOpen,
1241 IconName::Frame,
1242 IconName::GalleryVerticalEnd,
1243 IconName::GitHub,
1244 IconName::Globe,
1245 IconName::Heart,
1246 IconName::HeartOff,
1247 IconName::Inbox,
1248 IconName::Info,
1249 IconName::Inspector,
1250 IconName::LayoutDashboard,
1251 IconName::Loader,
1252 IconName::LoaderCircle,
1253 IconName::Map,
1254 IconName::Maximize,
1255 IconName::Menu,
1256 IconName::Minimize,
1257 IconName::Minus,
1258 IconName::Moon,
1259 IconName::Palette,
1260 IconName::PanelBottom,
1261 IconName::PanelBottomOpen,
1262 IconName::PanelLeft,
1263 IconName::PanelLeftClose,
1264 IconName::PanelLeftOpen,
1265 IconName::PanelRight,
1266 IconName::PanelRightClose,
1267 IconName::PanelRightOpen,
1268 IconName::Plus,
1269 IconName::Redo,
1270 IconName::Redo2,
1271 IconName::Replace,
1272 IconName::ResizeCorner,
1273 IconName::Search,
1274 IconName::Settings,
1275 IconName::Settings2,
1276 IconName::SortAscending,
1277 IconName::SortDescending,
1278 IconName::SquareTerminal,
1279 IconName::Star,
1280 IconName::StarOff,
1281 IconName::Sun,
1282 IconName::ThumbsDown,
1283 IconName::ThumbsUp,
1284 IconName::TriangleAlert,
1285 IconName::Undo,
1286 IconName::Undo2,
1287 IconName::User,
1288 IconName::WindowClose,
1289 IconName::WindowMaximize,
1290 IconName::WindowMinimize,
1291 IconName::WindowRestore,
1292 ];
1293
1294 #[test]
1295 fn all_icons_have_lucide_mapping() {
1296 for icon in ALL_ICON_NAMES {
1297 let name = lucide_name_for_gpui_icon(icon.clone());
1298 assert!(
1299 !name.is_empty(),
1300 "Empty Lucide mapping for an IconName variant",
1301 );
1302 }
1303 }
1304
1305 #[test]
1306 fn all_icons_have_material_mapping() {
1307 for icon in ALL_ICON_NAMES {
1308 let name = material_name_for_gpui_icon(icon.clone());
1309 assert!(
1310 !name.is_empty(),
1311 "Empty Material mapping for an IconName variant",
1312 );
1313 }
1314 }
1315
1316 #[test]
1319 fn icon_name_dialog_warning_maps_to_triangle_alert() {
1320 assert!(matches!(
1321 icon_name(IconRole::DialogWarning),
1322 Some(IconName::TriangleAlert)
1323 ));
1324 }
1325
1326 #[test]
1327 fn icon_name_dialog_error_maps_to_circle_x() {
1328 assert!(matches!(
1329 icon_name(IconRole::DialogError),
1330 Some(IconName::CircleX)
1331 ));
1332 }
1333
1334 #[test]
1335 fn icon_name_dialog_info_maps_to_info() {
1336 assert!(matches!(
1337 icon_name(IconRole::DialogInfo),
1338 Some(IconName::Info)
1339 ));
1340 }
1341
1342 #[test]
1343 fn icon_name_dialog_success_maps_to_circle_check() {
1344 assert!(matches!(
1345 icon_name(IconRole::DialogSuccess),
1346 Some(IconName::CircleCheck)
1347 ));
1348 }
1349
1350 #[test]
1351 fn icon_name_window_close_maps() {
1352 assert!(matches!(
1353 icon_name(IconRole::WindowClose),
1354 Some(IconName::WindowClose)
1355 ));
1356 }
1357
1358 #[test]
1359 fn icon_name_action_copy_maps_to_copy() {
1360 assert!(matches!(
1361 icon_name(IconRole::ActionCopy),
1362 Some(IconName::Copy)
1363 ));
1364 }
1365
1366 #[test]
1367 fn icon_name_nav_back_maps_to_chevron_left() {
1368 assert!(matches!(
1369 icon_name(IconRole::NavBack),
1370 Some(IconName::ChevronLeft)
1371 ));
1372 }
1373
1374 #[test]
1375 fn icon_name_file_generic_maps_to_file() {
1376 assert!(matches!(
1377 icon_name(IconRole::FileGeneric),
1378 Some(IconName::File)
1379 ));
1380 }
1381
1382 #[test]
1383 fn icon_name_status_check_maps_to_check() {
1384 assert!(matches!(
1385 icon_name(IconRole::StatusCheck),
1386 Some(IconName::Check)
1387 ));
1388 }
1389
1390 #[test]
1391 fn icon_name_user_account_maps_to_user() {
1392 assert!(matches!(
1393 icon_name(IconRole::UserAccount),
1394 Some(IconName::User)
1395 ));
1396 }
1397
1398 #[test]
1399 fn icon_name_notification_maps_to_bell() {
1400 assert!(matches!(
1401 icon_name(IconRole::Notification),
1402 Some(IconName::Bell)
1403 ));
1404 }
1405
1406 #[test]
1408 fn icon_name_shield_returns_none() {
1409 assert!(icon_name(IconRole::Shield).is_none());
1410 }
1411
1412 #[test]
1413 fn icon_name_lock_returns_none() {
1414 assert!(icon_name(IconRole::Lock).is_none());
1415 }
1416
1417 #[test]
1418 fn icon_name_action_save_returns_none() {
1419 assert!(icon_name(IconRole::ActionSave).is_none());
1420 }
1421
1422 #[test]
1423 fn icon_name_help_returns_none() {
1424 assert!(icon_name(IconRole::Help).is_none());
1425 }
1426
1427 #[test]
1428 fn icon_name_dialog_question_returns_none() {
1429 assert!(icon_name(IconRole::DialogQuestion).is_none());
1430 }
1431
1432 #[test]
1434 fn icon_name_maps_at_least_28_roles() {
1435 let some_count = IconRole::ALL
1436 .iter()
1437 .filter(|r| icon_name(**r).is_some())
1438 .count();
1439 assert!(
1440 some_count >= 28,
1441 "Expected at least 28 mappings, got {}",
1442 some_count
1443 );
1444 }
1445
1446 #[test]
1447 fn icon_name_maps_exactly_30_roles() {
1448 let some_count = IconRole::ALL
1449 .iter()
1450 .filter(|r| icon_name(**r).is_some())
1451 .count();
1452 assert_eq!(
1453 some_count, 30,
1454 "Expected exactly 30 mappings, got {some_count}"
1455 );
1456 }
1457
1458 #[test]
1460 fn all_icon_names_count_matches_gpui_component() {
1461 assert_eq!(
1463 ALL_ICON_NAMES.len(),
1464 86,
1465 "ALL_ICON_NAMES count changed (got {}) -- update the list",
1466 ALL_ICON_NAMES.len()
1467 );
1468 }
1469
1470 #[test]
1473 fn icon_name_data_driven() {
1474 assert!(matches!(
1476 icon_name(IconRole::DialogWarning),
1477 Some(IconName::TriangleAlert)
1478 ));
1479 assert!(matches!(
1480 icon_name(IconRole::DialogError),
1481 Some(IconName::CircleX)
1482 ));
1483 assert!(matches!(
1484 icon_name(IconRole::DialogInfo),
1485 Some(IconName::Info)
1486 ));
1487 assert!(matches!(
1488 icon_name(IconRole::DialogSuccess),
1489 Some(IconName::CircleCheck)
1490 ));
1491 assert!(matches!(
1493 icon_name(IconRole::WindowClose),
1494 Some(IconName::WindowClose)
1495 ));
1496 assert!(matches!(
1497 icon_name(IconRole::WindowMinimize),
1498 Some(IconName::WindowMinimize)
1499 ));
1500 assert!(matches!(
1501 icon_name(IconRole::WindowMaximize),
1502 Some(IconName::WindowMaximize)
1503 ));
1504 assert!(matches!(
1505 icon_name(IconRole::WindowRestore),
1506 Some(IconName::WindowRestore)
1507 ));
1508 assert!(matches!(
1510 icon_name(IconRole::ActionDelete),
1511 Some(IconName::Delete)
1512 ));
1513 assert!(matches!(
1514 icon_name(IconRole::ActionCopy),
1515 Some(IconName::Copy)
1516 ));
1517 assert!(matches!(
1518 icon_name(IconRole::ActionUndo),
1519 Some(IconName::Undo2)
1520 ));
1521 assert!(matches!(
1522 icon_name(IconRole::ActionRedo),
1523 Some(IconName::Redo2)
1524 ));
1525 assert!(matches!(
1526 icon_name(IconRole::ActionSearch),
1527 Some(IconName::Search)
1528 ));
1529 assert!(matches!(
1530 icon_name(IconRole::ActionSettings),
1531 Some(IconName::Settings)
1532 ));
1533 assert!(matches!(
1534 icon_name(IconRole::ActionAdd),
1535 Some(IconName::Plus)
1536 ));
1537 assert!(matches!(
1538 icon_name(IconRole::ActionRemove),
1539 Some(IconName::Minus)
1540 ));
1541 assert!(matches!(
1543 icon_name(IconRole::NavBack),
1544 Some(IconName::ChevronLeft)
1545 ));
1546 assert!(matches!(
1547 icon_name(IconRole::NavForward),
1548 Some(IconName::ChevronRight)
1549 ));
1550 assert!(matches!(
1551 icon_name(IconRole::NavUp),
1552 Some(IconName::ChevronUp)
1553 ));
1554 assert!(matches!(
1555 icon_name(IconRole::NavDown),
1556 Some(IconName::ChevronDown)
1557 ));
1558 assert!(matches!(icon_name(IconRole::NavMenu), Some(IconName::Menu)));
1559 assert!(matches!(
1561 icon_name(IconRole::FileGeneric),
1562 Some(IconName::File)
1563 ));
1564 assert!(matches!(
1565 icon_name(IconRole::FolderClosed),
1566 Some(IconName::FolderClosed)
1567 ));
1568 assert!(matches!(
1569 icon_name(IconRole::FolderOpen),
1570 Some(IconName::FolderOpen)
1571 ));
1572 assert!(matches!(
1573 icon_name(IconRole::TrashEmpty),
1574 Some(IconName::Delete)
1575 ));
1576 assert!(matches!(
1578 icon_name(IconRole::StatusBusy),
1579 Some(IconName::Loader)
1580 ));
1581 assert!(matches!(
1582 icon_name(IconRole::StatusCheck),
1583 Some(IconName::Check)
1584 ));
1585 assert!(matches!(
1586 icon_name(IconRole::StatusError),
1587 Some(IconName::CircleX)
1588 ));
1589 assert!(matches!(
1591 icon_name(IconRole::UserAccount),
1592 Some(IconName::User)
1593 ));
1594 assert!(matches!(
1595 icon_name(IconRole::Notification),
1596 Some(IconName::Bell)
1597 ));
1598 assert!(icon_name(IconRole::Shield).is_none());
1600 assert!(icon_name(IconRole::Lock).is_none());
1601 assert!(icon_name(IconRole::Help).is_none());
1602 }
1603
1604 #[test]
1607 fn to_image_source_svg_returns_bmp_rasterized() {
1608 let svg = IconData::Svg(
1610 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(),
1611 );
1612 let source = to_image_source(&svg, None, None).expect("valid SVG should convert");
1613 match source {
1615 ImageSource::Image(arc) => {
1616 assert_eq!(arc.format, ImageFormat::Bmp);
1617 assert!(arc.bytes.starts_with(b"BM"), "BMP should start with 'BM'");
1618 }
1619 _ => panic!("Expected ImageSource::Image for SVG data"),
1620 }
1621 }
1622
1623 #[test]
1624 fn to_image_source_rgba_returns_bmp_image_source() {
1625 let rgba = IconData::Rgba {
1626 width: 2,
1627 height: 2,
1628 data: vec![
1629 255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 0, 255, ],
1634 };
1635 let source = to_image_source(&rgba, None, None).expect("RGBA should convert");
1636 match source {
1637 ImageSource::Image(arc) => {
1638 assert_eq!(arc.format, ImageFormat::Bmp);
1639 assert_eq!(&arc.bytes[0..2], b"BM");
1641 }
1642 _ => panic!("Expected ImageSource::Image for RGBA data"),
1643 }
1644 }
1645
1646 #[test]
1647 fn to_image_source_with_color() {
1648 let svg = IconData::Svg(
1649 b"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M0 0' stroke='currentColor'/></svg>".to_vec(),
1650 );
1651 let color = gpui::hsla(0.0, 1.0, 0.5, 1.0);
1652 let result = to_image_source(&svg, Some(color), None);
1653 assert!(result.is_some(), "colorized SVG should convert");
1654 }
1655
1656 #[test]
1657 fn to_image_source_with_custom_size() {
1658 let svg = IconData::Svg(
1659 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(),
1660 );
1661 let result = to_image_source(&svg, None, Some(32));
1662 assert!(result.is_some(), "custom size SVG should convert");
1663 }
1664
1665 #[test]
1667 fn to_image_source_clamps_oversized() {
1668 let svg = IconData::Svg(
1669 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(),
1670 );
1671 let result = to_image_source(&svg, None, Some(99999));
1673 assert!(result.is_some(), "oversized should clamp and still convert");
1674 }
1675
1676 #[test]
1677 fn to_image_source_clamps_zero_size() {
1678 let svg = IconData::Svg(
1679 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(),
1680 );
1681 let result = to_image_source(&svg, None, Some(0));
1683 assert!(result.is_some(), "zero size should clamp to 1 and convert");
1684 }
1685
1686 #[test]
1689 fn encode_rgba_as_bmp_correct_file_size() {
1690 let rgba = vec![0u8; 4 * 4 * 4]; let bmp = encode_rgba_as_bmp(4, 4, &rgba).expect("valid input");
1692 let expected_size = 14 + 108 + (4 * 4 * 4); assert_eq!(bmp.len(), expected_size);
1694 }
1695
1696 #[test]
1697 fn encode_rgba_as_bmp_starts_with_bm() {
1698 let rgba = vec![0u8; 4]; let bmp = encode_rgba_as_bmp(1, 1, &rgba).expect("valid input");
1700 assert_eq!(&bmp[0..2], b"BM");
1701 }
1702
1703 #[test]
1704 fn encode_rgba_as_bmp_pixel_order_is_bgra() {
1705 let rgba = vec![0xAA, 0xBB, 0xCC, 0xDD];
1707 let bmp = encode_rgba_as_bmp(1, 1, &rgba).expect("valid input");
1708 let pixel_offset = (14 + 108) as usize;
1709 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); }
1715
1716 #[test]
1717 fn encode_rgba_as_bmp_zero_width_returns_none() {
1718 let rgba = vec![0u8; 4];
1719 assert!(encode_rgba_as_bmp(0, 1, &rgba).is_none());
1720 }
1721
1722 #[test]
1723 fn encode_rgba_as_bmp_zero_height_returns_none() {
1724 let rgba = vec![0u8; 4];
1725 assert!(encode_rgba_as_bmp(1, 0, &rgba).is_none());
1726 }
1727
1728 #[test]
1729 fn encode_rgba_as_bmp_mismatched_length_returns_none() {
1730 let rgba = vec![0u8; 12];
1732 assert!(encode_rgba_as_bmp(2, 2, &rgba).is_none());
1733 }
1734
1735 #[test]
1736 fn encode_rgba_as_bmp_oversized_length_returns_none() {
1737 let rgba = vec![0u8; 20];
1739 assert!(encode_rgba_as_bmp(2, 2, &rgba).is_none());
1740 }
1741 #[test]
1744 fn colorize_svg_replaces_fill_black() {
1745 let svg = b"<svg><path fill=\"black\" d=\"M0 0h24v24H0z\"/></svg>";
1746 let color = gpui::hsla(0.6, 0.7, 0.5, 1.0); let result = colorize_svg(svg, color);
1748 let result_str = String::from_utf8(result).unwrap();
1749 assert!(
1750 !result_str.contains("fill=\"black\""),
1751 "fill=\"black\" should be replaced, got: {}",
1752 result_str
1753 );
1754 assert!(
1755 result_str.contains("fill=\"#"),
1756 "should contain hex fill, got: {}",
1757 result_str
1758 );
1759 }
1760
1761 #[test]
1762 fn colorize_svg_replaces_fill_hex_black() {
1763 let svg = b"<svg><rect fill=\"#000000\" width=\"24\" height=\"24\"/></svg>";
1764 let color = gpui::hsla(0.0, 1.0, 0.5, 1.0); let result = colorize_svg(svg, color);
1766 let result_str = String::from_utf8(result).unwrap();
1767 assert!(
1768 !result_str.contains("#000000"),
1769 "fill=\"#000000\" should be replaced, got: {}",
1770 result_str
1771 );
1772 }
1773
1774 #[test]
1775 fn colorize_svg_replaces_fill_short_hex_black() {
1776 let svg = b"<svg><rect fill=\"#000\" width=\"24\" height=\"24\"/></svg>";
1777 let color = gpui::hsla(0.3, 0.8, 0.4, 1.0); let result = colorize_svg(svg, color);
1779 let result_str = String::from_utf8(result).unwrap();
1780 assert!(
1781 !result_str.contains("fill=\"#000\""),
1782 "fill=\"#000\" should be replaced, got: {}",
1783 result_str
1784 );
1785 }
1786
1787 #[test]
1788 fn colorize_svg_current_color_still_works() {
1789 let svg = b"<svg><path stroke=\"currentColor\" d=\"M0 0\"/></svg>";
1790 let color = gpui::hsla(0.0, 1.0, 0.5, 1.0);
1791 let result = colorize_svg(svg, color);
1792 let result_str = String::from_utf8(result).unwrap();
1793 assert!(
1794 !result_str.contains("currentColor"),
1795 "currentColor should be replaced"
1796 );
1797 assert!(result_str.contains('#'), "should contain hex color");
1798 }
1799
1800 #[test]
1801 fn colorize_svg_implicit_black_still_works() {
1802 let svg = b"<svg xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M0 0\"/></svg>";
1804 let color = gpui::hsla(0.0, 1.0, 0.5, 1.0);
1805 let result = colorize_svg(svg, color);
1806 let result_str = String::from_utf8(result).unwrap();
1807 assert!(
1808 result_str.contains("fill=\"#"),
1809 "should inject fill into root svg tag, got: {}",
1810 result_str
1811 );
1812 }
1813
1814 #[test]
1815 fn colorize_svg_non_utf8_returns_original() {
1816 let mut svg = b"<svg><path fill=\"black\" d=\"M0 0\"/>".to_vec();
1818 svg.push(0xFF); svg.extend_from_slice(b"</svg>");
1820 let color = gpui::hsla(0.0, 1.0, 0.5, 1.0);
1821 let result = colorize_svg(&svg, color);
1822 assert_eq!(result, svg, "non-UTF-8 input should be returned unchanged");
1823 }
1824
1825 #[test]
1827 fn colorize_svg_replaces_stroke_black() {
1828 let svg = b"<svg><path stroke=\"black\" d=\"M0 0h24\"/></svg>";
1829 let color = gpui::hsla(0.6, 0.7, 0.5, 1.0);
1830 let result = colorize_svg(svg, color);
1831 let result_str = String::from_utf8(result).unwrap();
1832 assert!(
1833 !result_str.contains("stroke=\"black\""),
1834 "stroke=\"black\" should be replaced, got: {}",
1835 result_str
1836 );
1837 assert!(
1838 result_str.contains("stroke=\"#"),
1839 "should contain hex stroke, got: {}",
1840 result_str
1841 );
1842 }
1843
1844 #[test]
1845 fn colorize_svg_replaces_stroke_hex_black() {
1846 let svg = b"<svg><line stroke=\"#000000\" x1=\"0\" y1=\"0\" x2=\"24\" y2=\"24\"/></svg>";
1847 let color = gpui::hsla(0.0, 1.0, 0.5, 1.0);
1848 let result = colorize_svg(svg, color);
1849 let result_str = String::from_utf8(result).unwrap();
1850 assert!(
1851 !result_str.contains("#000000"),
1852 "stroke=\"#000000\" should be replaced"
1853 );
1854 }
1855
1856 #[test]
1857 fn colorize_self_closing_svg_produces_valid_xml() {
1858 let svg = b"<svg xmlns=\"http://www.w3.org/2000/svg\" />";
1860 let color = gpui::hsla(0.0, 1.0, 0.5, 1.0);
1861 let result = colorize_svg(svg, color);
1862 let result_str = String::from_utf8(result).unwrap();
1863 assert!(
1864 result_str.contains("fill=\"#"),
1865 "should inject fill, got: {}",
1866 result_str
1867 );
1868 assert!(
1870 !result_str.contains("/ fill="),
1871 "fill must be before '/', got: {}",
1872 result_str
1873 );
1874 assert!(
1876 result_str.trim().ends_with("/>"),
1877 "should remain self-closing, got: {}",
1878 result_str
1879 );
1880 }
1881
1882 #[test]
1884 fn colorize_svg_with_fill_white_root() {
1885 let svg = b"<svg fill=\"white\"><path/></svg>";
1887 let color = gpui::hsla(0.0, 1.0, 0.5, 1.0); let result = colorize_svg(svg, color);
1889 let result_str = String::from_utf8(result).unwrap();
1890 assert!(
1891 result_str.contains("fill=\"white\""),
1892 "fill=\"white\" should be preserved, got: {}",
1893 result_str
1894 );
1895 }
1896
1897 #[test]
1899 fn colorize_svg_with_fill_none_root() {
1900 let svg = b"<svg fill=\"none\"><path stroke=\"black\"/></svg>";
1901 let color = gpui::hsla(0.0, 1.0, 0.5, 1.0); let result = colorize_svg(svg, color);
1903 let result_str = String::from_utf8(result).unwrap();
1904 assert!(
1906 !result_str.contains("stroke=\"black\""),
1907 "stroke=\"black\" should be replaced, got: {}",
1908 result_str
1909 );
1910 assert!(
1911 result_str.contains("stroke=\"#"),
1912 "should contain hex stroke, got: {}",
1913 result_str
1914 );
1915 }
1916
1917 #[test]
1920 fn into_image_source_svg_returns_some() {
1921 let svg = IconData::Svg(
1922 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(),
1923 );
1924 let result = into_image_source(svg, None, None);
1925 assert!(result.is_some(), "valid SVG should convert");
1926 }
1927
1928 #[test]
1929 fn into_image_source_rgba_returns_some() {
1930 let rgba = IconData::Rgba {
1931 width: 2,
1932 height: 2,
1933 data: vec![
1934 255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 0, 255,
1935 ],
1936 };
1937 let result = into_image_source(rgba, None, None);
1938 assert!(result.is_some(), "RGBA should convert");
1939 }
1940
1941 #[test]
1942 fn into_image_source_with_color() {
1943 let svg = IconData::Svg(
1944 b"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M0 0' stroke='currentColor'/></svg>".to_vec(),
1945 );
1946 let color = gpui::hsla(0.0, 1.0, 0.5, 1.0);
1947 let result = into_image_source(svg, Some(color), None);
1948 assert!(result.is_some(), "colorized SVG should convert");
1949 }
1950
1951 #[derive(Debug)]
1955 struct TestCustomIcon;
1956
1957 impl native_theme::IconProvider for TestCustomIcon {
1958 fn icon_name(&self, _set: native_theme::IconSet) -> Option<&str> {
1959 None }
1961 fn icon_svg(&self, _set: native_theme::IconSet) -> Option<&'static [u8]> {
1962 Some(b"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><circle cx='12' cy='12' r='10'/></svg>")
1963 }
1964 }
1965
1966 #[derive(Debug)]
1968 struct EmptyProvider;
1969
1970 impl native_theme::IconProvider for EmptyProvider {
1971 fn icon_name(&self, _set: native_theme::IconSet) -> Option<&str> {
1972 None
1973 }
1974 fn icon_svg(&self, _set: native_theme::IconSet) -> Option<&'static [u8]> {
1975 None
1976 }
1977 }
1978
1979 #[test]
1980 fn custom_icon_to_image_source_with_svg_provider_returns_some() {
1981 let result = custom_icon_to_image_source(
1982 &TestCustomIcon,
1983 native_theme::IconSet::Material,
1984 None,
1985 None,
1986 );
1987 assert!(result.is_some());
1988 }
1989
1990 #[test]
1991 fn custom_icon_to_image_source_with_empty_provider_returns_none() {
1992 let result = custom_icon_to_image_source(
1993 &EmptyProvider,
1994 native_theme::IconSet::Material,
1995 None,
1996 None,
1997 );
1998 assert!(result.is_none());
1999 }
2000
2001 #[test]
2002 fn custom_icon_to_image_source_with_color() {
2003 let color = Hsla {
2004 h: 0.0,
2005 s: 1.0,
2006 l: 0.5,
2007 a: 1.0,
2008 };
2009 let result = custom_icon_to_image_source(
2010 &TestCustomIcon,
2011 native_theme::IconSet::Material,
2012 Some(color),
2013 None,
2014 );
2015 assert!(result.is_some());
2016 }
2017
2018 #[test]
2019 fn custom_icon_to_image_source_accepts_dyn_provider() {
2020 let boxed: Box<dyn native_theme::IconProvider> = Box::new(TestCustomIcon);
2021 let result =
2022 custom_icon_to_image_source(&*boxed, native_theme::IconSet::Material, None, None);
2023 assert!(result.is_some());
2024 }
2025
2026 #[test]
2029 fn bundled_icon_lucide_returns_some() {
2030 let result = bundled_icon_to_image_source(
2031 IconName::Search,
2032 native_theme::IconSet::Lucide,
2033 None,
2034 None,
2035 );
2036 assert!(result.is_some(), "Lucide search icon should convert");
2037 }
2038
2039 #[test]
2040 fn bundled_icon_material_returns_some() {
2041 let result = bundled_icon_to_image_source(
2042 IconName::Search,
2043 native_theme::IconSet::Material,
2044 None,
2045 None,
2046 );
2047 assert!(result.is_some(), "Material search icon should convert");
2048 }
2049
2050 #[test]
2051 fn bundled_icon_freedesktop_returns_none() {
2052 let result = bundled_icon_to_image_source(
2053 IconName::Search,
2054 native_theme::IconSet::Freedesktop,
2055 None,
2056 None,
2057 );
2058 assert!(
2059 result.is_none(),
2060 "Freedesktop is not bundled -- should return None"
2061 );
2062 }
2063
2064 #[test]
2065 fn bundled_icon_with_color() {
2066 let color = Hsla {
2067 h: 0.0,
2068 s: 1.0,
2069 l: 0.5,
2070 a: 1.0,
2071 };
2072 let result = bundled_icon_to_image_source(
2073 IconName::Check,
2074 native_theme::IconSet::Lucide,
2075 Some(color),
2076 None,
2077 );
2078 assert!(result.is_some(), "colorized bundled icon should convert");
2079 }
2080
2081 #[test]
2084 fn animated_frames_returns_sources() {
2085 let anim = AnimatedIcon::Frames {
2086 frames: vec![
2087 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()),
2088 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()),
2089 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()),
2090 ],
2091 frame_duration_ms: 80,
2092 };
2093 let result = animated_frames_to_image_sources(&anim, None, None);
2094 let ais = result.expect("Frames variant should return Some");
2095 assert_eq!(ais.sources.len(), 3);
2096 assert_eq!(ais.frame_duration_ms, 80);
2097 }
2098
2099 #[test]
2100 fn animated_frames_transform_returns_none() {
2101 let anim = AnimatedIcon::Transform {
2102 icon: IconData::Svg(
2103 b"<svg xmlns='http://www.w3.org/2000/svg'><circle cx='12' cy='12' r='10'/></svg>"
2104 .to_vec(),
2105 ),
2106 animation: native_theme::TransformAnimation::Spin { duration_ms: 1000 },
2107 };
2108 let result = animated_frames_to_image_sources(&anim, None, None);
2109 assert!(result.is_none());
2110 }
2111
2112 #[test]
2113 fn animated_frames_empty_returns_none() {
2114 let anim = AnimatedIcon::Frames {
2115 frames: vec![],
2116 frame_duration_ms: 80,
2117 };
2118 let result = animated_frames_to_image_sources(&anim, None, None);
2119 assert!(result.is_none());
2120 }
2121
2122 #[test]
2123 fn spin_animation_constructs_without_context() {
2124 let svg_element = gpui::svg();
2125 let _animated = with_spin_animation(svg_element, "test-spin", 1000);
2128 }
2129}
2130
2131#[cfg(test)]
2132#[cfg(target_os = "linux")]
2133#[allow(clippy::unwrap_used, clippy::expect_used)]
2134mod freedesktop_mapping_tests {
2135 use super::tests::ALL_ICON_NAMES;
2136 use super::*;
2137 use native_theme::LinuxDesktop;
2138
2139 #[test]
2140 fn all_86_gpui_icons_have_mapping_on_kde() {
2141 for name in ALL_ICON_NAMES {
2142 let fd_name = freedesktop_name_for_gpui_icon(name.clone(), LinuxDesktop::Kde);
2143 assert!(
2144 !fd_name.is_empty(),
2145 "Empty KDE freedesktop mapping for an IconName variant",
2146 );
2147 }
2148 }
2149
2150 #[test]
2151 fn eye_differs_by_de() {
2152 assert_eq!(
2153 freedesktop_name_for_gpui_icon(IconName::Eye, LinuxDesktop::Kde),
2154 "view-visible",
2155 );
2156 assert_eq!(
2157 freedesktop_name_for_gpui_icon(IconName::Eye, LinuxDesktop::Gnome),
2158 "view-reveal",
2159 );
2160 }
2161
2162 #[test]
2163 fn freedesktop_standard_ignores_de() {
2164 assert_eq!(
2166 freedesktop_name_for_gpui_icon(IconName::Copy, LinuxDesktop::Kde),
2167 freedesktop_name_for_gpui_icon(IconName::Copy, LinuxDesktop::Gnome),
2168 );
2169 }
2170
2171 #[test]
2172 fn all_86_gpui_icons_have_mapping_on_gnome() {
2173 for name in ALL_ICON_NAMES {
2174 let fd_name = freedesktop_name_for_gpui_icon(name.clone(), LinuxDesktop::Gnome);
2175 assert!(
2176 !fd_name.is_empty(),
2177 "Empty GNOME freedesktop mapping for an IconName variant",
2178 );
2179 }
2180 }
2181
2182 #[test]
2183 fn xfce_uses_gnome_names() {
2184 assert_eq!(
2186 freedesktop_name_for_gpui_icon(IconName::Eye, LinuxDesktop::Xfce),
2187 "view-reveal",
2188 );
2189 assert_eq!(
2190 freedesktop_name_for_gpui_icon(IconName::Bell, LinuxDesktop::Xfce),
2191 "alarm",
2192 );
2193 }
2194
2195 #[test]
2196 fn all_kde_names_resolve_in_breeze() {
2197 let theme = native_theme::system_icon_theme();
2198 if !theme.to_lowercase().contains("breeze") {
2200 eprintln!("Skipping: system theme is '{}', not Breeze", theme);
2201 return;
2202 }
2203
2204 let mut missing = Vec::new();
2205 for name in ALL_ICON_NAMES {
2206 let fd_name = freedesktop_name_for_gpui_icon(name.clone(), LinuxDesktop::Kde);
2207 if native_theme::load_freedesktop_icon_by_name(fd_name, theme, 24).is_none() {
2208 missing.push(format!("{} (not found)", fd_name));
2209 }
2210 }
2211 assert!(
2212 missing.is_empty(),
2213 "These gpui icons did not resolve in Breeze:\n {}",
2214 missing.join("\n "),
2215 );
2216 }
2217
2218 #[test]
2219 fn gnome_names_resolve_in_adwaita() {
2220 let mut missing = Vec::new();
2223 for name in ALL_ICON_NAMES {
2224 let fd_name = freedesktop_name_for_gpui_icon(name.clone(), LinuxDesktop::Gnome);
2225 if native_theme::load_freedesktop_icon_by_name(fd_name, "Adwaita", 24).is_none() {
2226 missing.push(format!("{} (not found)", fd_name));
2227 }
2228 }
2229 assert!(
2230 missing.is_empty(),
2231 "These GNOME mappings did not resolve in Adwaita:\n {}",
2232 missing.join("\n "),
2233 );
2234 }
2235}