Skip to main content

native_theme_gpui/
icons.rs

1//! Icon conversion functions for the gpui connector.
2//!
3//! # Function Overview
4//!
5//! | Function | Purpose |
6//! |----------|---------|
7//! | [`icon_name`] | Map [`IconRole`] → [`IconName`] (Lucide, zero-I/O) |
8//! | [`lucide_name_for_gpui_icon`] | Map [`IconName`] → Lucide name (`&str`) |
9//! | [`material_name_for_gpui_icon`] | Map [`IconName`] → Material name (`&str`) |
10//! | [`freedesktop_name_for_gpui_icon`] | Map [`IconName`] → freedesktop name (Linux only) |
11//! | [`to_image_source`] | Convert [`IconData`] → [`ImageSource`] with optional color/size |
12//! | [`custom_icon_to_image_source`] | Load + convert via [`IconProvider`] |
13//! | [`animated_frames_to_image_sources`] | Convert animation frames → [`AnimatedImageSources`] |
14//! | [`with_spin_animation`] | Wrap an SVG element with spin animation |
15
16use 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
24/// Converted animation frames with timing metadata.
25///
26/// Returned by [`animated_frames_to_image_sources`]. Contains the
27/// rasterized frames and the per-frame duration needed to drive playback.
28pub struct AnimatedImageSources {
29    /// Rasterized frames ready for gpui rendering.
30    pub sources: Vec<ImageSource>,
31    /// Duration of each frame in milliseconds.
32    pub frame_duration_ms: u32,
33}
34
35/// Map an [`IconRole`] to a gpui-component [`IconName`] for the Lucide icon set.
36///
37/// Returns `Some(IconName)` for roles that have a direct Lucide equivalent in
38/// gpui-component's bundled icon set. Returns `None` for roles where
39/// gpui-component doesn't ship the corresponding Lucide icon.
40///
41/// This is a zero-I/O operation -- no icon files are loaded. The returned
42/// `IconName` can be rendered directly via gpui-component's `Icon::new()`.
43///
44/// # Coverage
45///
46/// Maps 30 of the 42 `IconRole` variants to `IconName`. The 12 unmapped roles
47/// (Shield, ActionSave, ActionPaste, ActionCut, ActionEdit, ActionRefresh,
48/// ActionPrint, NavHome, TrashFull, DialogQuestion, Help, Lock) have no
49/// corresponding Lucide icon in gpui-component 0.5.
50///
51/// # Examples
52///
53/// ```ignore
54/// use native_theme::IconRole;
55/// use native_theme_gpui::icons::icon_name;
56///
57/// assert_eq!(icon_name(IconRole::DialogWarning), Some(IconName::TriangleAlert));
58/// assert_eq!(icon_name(IconRole::Shield), None);
59/// ```
60pub fn icon_name(role: IconRole) -> Option<IconName> {
61    Some(match role {
62        // Dialog / Alert
63        IconRole::DialogWarning => IconName::TriangleAlert,
64        IconRole::DialogError => IconName::CircleX,
65        IconRole::DialogInfo => IconName::Info,
66        IconRole::DialogSuccess => IconName::CircleCheck,
67
68        // Window Controls
69        IconRole::WindowClose => IconName::WindowClose,
70        IconRole::WindowMinimize => IconName::WindowMinimize,
71        IconRole::WindowMaximize => IconName::WindowMaximize,
72        IconRole::WindowRestore => IconName::WindowRestore,
73
74        // Common Actions
75        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        // Navigation
85        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        // Files / Places
92        IconRole::FileGeneric => IconName::File,
93        IconRole::FolderClosed => IconName::FolderClosed,
94        IconRole::FolderOpen => IconName::FolderOpen,
95        IconRole::TrashEmpty => IconName::Delete,
96
97        // Status
98        IconRole::StatusBusy => IconName::Loader,
99        IconRole::StatusCheck => IconName::Check,
100        IconRole::StatusError => IconName::CircleX,
101
102        // System
103        IconRole::UserAccount => IconName::User,
104        IconRole::Notification => IconName::Bell,
105
106        // No Lucide equivalent in gpui-component 0.5
107        _ => return None,
108    })
109}
110
111/// Map a gpui-component [`IconName`] to its canonical Lucide icon name.
112///
113/// Returns the kebab-case Lucide name for use with
114/// [`native_theme::bundled_icon_by_name`].
115///
116/// Covers all 86 gpui-component `IconName` variants.
117pub 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
208/// Map a gpui-component [`IconName`] to its canonical Material icon name.
209///
210/// Returns the snake_case Material Symbols name for use with
211/// [`native_theme::bundled_icon_by_name`].
212///
213/// Covers all 86 gpui-component `IconName` variants.
214pub 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/// Map a gpui-component [`IconName`] to its freedesktop icon name for the
306/// given desktop environment.
307///
308/// Returns the best freedesktop name for the detected DE's naming
309/// convention. When KDE and GNOME use different names for the same
310/// concept, the DE parameter selects the right one. For freedesktop
311/// standard names (present in all themes), the DE is ignored.
312///
313/// GTK-based DEs (GNOME, Budgie, Cinnamon, MATE, XFCE) share the
314/// Adwaita/GNOME naming convention. Qt-based DEs (KDE, LxQt) and
315/// Unknown share the Breeze/KDE convention.
316///
317/// Returns `None` when no icon exists in the DE's naming convention,
318/// signaling the caller to fall back to bundled Lucide/Material icons.
319///
320/// ## Confidence levels
321///
322/// Each mapping is annotated with a confidence level:
323/// - `exact`: the freedesktop icon is semantically identical
324/// - `close`: same concept, minor visual difference
325/// - `approximate`: best available match, different metaphor
326///
327/// Covers all 86 gpui-component `IconName` variants.
328#[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    // GTK-based DEs follow GNOME/Adwaita naming; Qt-based follow KDE/Breeze
336    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        // --- Icons with freedesktop standard names (all DEs) ---
347        IconName::BookOpen => "help-contents",      // close
348        IconName::Bot => "face-smile",              // approximate
349        IconName::ChevronDown => "go-down",         // close: full nav arrow, not disclosure chevron
350        IconName::ChevronLeft => "go-previous",     // close
351        IconName::ChevronRight => "go-next",        // close
352        IconName::ChevronUp => "go-up",             // close
353        IconName::CircleX => "dialog-error",        // close
354        IconName::Copy => "edit-copy",              // exact
355        IconName::Dash => "list-remove",            // exact
356        IconName::Delete => "edit-delete",          // exact
357        IconName::File => "text-x-generic",         // exact
358        IconName::Folder => "folder",               // exact
359        IconName::FolderClosed => "folder",         // exact
360        IconName::FolderOpen => "folder-open",      // exact
361        IconName::HeartOff => "non-starred",        // close: un-favorite semantics
362        IconName::Info => "dialog-information",     // exact
363        IconName::LayoutDashboard => "view-grid",   // close
364        IconName::Map => "find-location",           // close
365        IconName::Maximize => "view-fullscreen",    // exact
366        IconName::Menu => "open-menu",              // exact
367        IconName::Minimize => "window-minimize",    // exact
368        IconName::Minus => "list-remove",           // exact
369        IconName::Moon => "weather-clear-night",    // close: dark mode toggle
370        IconName::Plus => "list-add",               // exact
371        IconName::Redo => "edit-redo",              // exact
372        IconName::Redo2 => "edit-redo",             // exact
373        IconName::Replace => "edit-find-replace",   // exact
374        IconName::Search => "edit-find",            // exact
375        IconName::Settings => "preferences-system", // exact
376        IconName::SortAscending => "view-sort-ascending", // exact
377        IconName::SortDescending => "view-sort-descending", // exact
378        IconName::SquareTerminal => "utilities-terminal", // close
379        IconName::Star => "starred",                // exact
380        IconName::StarOff => "non-starred",         // exact
381        IconName::Sun => "weather-clear",           // close: light mode toggle
382        IconName::TriangleAlert => "dialog-warning", // exact
383        IconName::Undo => "edit-undo",              // exact
384        IconName::Undo2 => "edit-undo",             // exact
385        IconName::User => "system-users",           // exact
386        IconName::WindowClose => "window-close",    // exact
387        IconName::WindowMaximize => "window-maximize", // exact
388        IconName::WindowMinimize => "window-minimize", // exact
389        IconName::WindowRestore => "window-restore", // exact
390
391        // --- Icons where KDE and GNOME both have names but they differ ---
392        IconName::ArrowDown => {
393            if is_gtk {
394                "go-bottom"
395            } else {
396                "go-down-skip"
397            }
398        } // close
399        IconName::ArrowLeft => {
400            if is_gtk {
401                "go-first"
402            } else {
403                "go-previous-skip"
404            }
405        } // close
406        IconName::ArrowRight => {
407            if is_gtk {
408                "go-last"
409            } else {
410                "go-next-skip"
411            }
412        } // close
413        IconName::ArrowUp => {
414            if is_gtk {
415                "go-top"
416            } else {
417                "go-up-skip"
418            }
419        } // close
420        IconName::Calendar => {
421            if is_gtk {
422                "x-office-calendar"
423            } else {
424                "view-calendar"
425            }
426        } // exact
427        IconName::Check => {
428            if is_gtk {
429                "object-select"
430            } else {
431                "dialog-ok"
432            }
433        } // close
434        IconName::CircleCheck => {
435            if is_gtk {
436                "object-select"
437            } else {
438                "emblem-ok-symbolic"
439            }
440        } // close
441        IconName::CircleUser => {
442            if is_gtk {
443                "avatar-default"
444            } else {
445                "user-identity"
446            }
447        } // close
448        IconName::Close => {
449            if is_gtk {
450                "window-close"
451            } else {
452                "tab-close"
453            }
454        } // close
455        IconName::Ellipsis => {
456            if is_gtk {
457                "view-more-horizontal"
458            } else {
459                "overflow-menu"
460            }
461        } // exact
462        IconName::EllipsisVertical => {
463            if is_gtk {
464                "view-more"
465            } else {
466                "overflow-menu"
467            }
468        } // close: no vertical variant in KDE
469        IconName::Eye => {
470            if is_gtk {
471                "view-reveal"
472            } else {
473                "view-visible"
474            }
475        } // exact
476        IconName::EyeOff => {
477            if is_gtk {
478                "view-conceal"
479            } else {
480                "view-hidden"
481            }
482        } // exact
483        IconName::Frame => {
484            if is_gtk {
485                "selection-mode"
486            } else {
487                "select-rectangular"
488            }
489        } // close
490        IconName::Heart => {
491            if is_gtk {
492                "starred"
493            } else {
494                "emblem-favorite"
495            }
496        } // close
497        IconName::Loader => {
498            if is_gtk {
499                "content-loading"
500            } else {
501                "process-working"
502            }
503        } // exact
504        IconName::LoaderCircle => {
505            if is_gtk {
506                "content-loading"
507            } else {
508                "process-working"
509            }
510        } // exact
511        IconName::Palette => {
512            if is_gtk {
513                "color-select"
514            } else {
515                "palette"
516            }
517        } // close
518        IconName::PanelLeft => {
519            if is_gtk {
520                "sidebar-show"
521            } else {
522                "sidebar-expand-left"
523            }
524        } // close
525        IconName::PanelLeftClose => {
526            if is_gtk {
527                "sidebar-show"
528            } else {
529                "view-left-close"
530            }
531        } // close
532        IconName::PanelLeftOpen => {
533            if is_gtk {
534                "sidebar-show"
535            } else {
536                "view-left-new"
537            }
538        } // close
539        IconName::PanelRight => {
540            if is_gtk {
541                "sidebar-show-right"
542            } else {
543                "view-right-new"
544            }
545        } // close
546        IconName::PanelRightClose => {
547            if is_gtk {
548                "sidebar-show-right"
549            } else {
550                "view-right-close"
551            }
552        } // close
553        IconName::PanelRightOpen => {
554            if is_gtk {
555                "sidebar-show-right"
556            } else {
557                "view-right-new"
558            }
559        } // close
560        IconName::ResizeCorner => {
561            if is_gtk {
562                "list-drag-handle"
563            } else {
564                "drag-handle"
565            }
566        } // close
567        IconName::Settings2 => {
568            if is_gtk {
569                "preferences-other"
570            } else {
571                "configure"
572            }
573        } // close
574
575        // --- Icons where GNOME uses a different (approximate) alternative ---
576        IconName::ALargeSmall => {
577            if is_gtk {
578                "zoom-in"
579            } else {
580                "format-font-size-more"
581            }
582        } // approximate
583        IconName::Asterisk => {
584            if is_gtk {
585                "starred"
586            } else {
587                "rating"
588            }
589        } // approximate
590        IconName::Bell => {
591            if is_gtk {
592                "alarm"
593            } else {
594                "notification-active"
595            }
596        } // close
597        IconName::Building2 => {
598            if is_gtk {
599                "network-workgroup"
600            } else {
601                "applications-office"
602            }
603        } // approximate
604        IconName::CaseSensitive => {
605            if is_gtk {
606                "format-text-rich"
607            } else {
608                "format-text-uppercase"
609            }
610        } // approximate
611        IconName::ChartPie => {
612            if is_gtk {
613                "x-office-spreadsheet"
614            } else {
615                "office-chart-pie"
616            }
617        } // approximate
618        IconName::ChevronsUpDown => {
619            if is_gtk {
620                "list-drag-handle"
621            } else {
622                "handle-sort"
623            }
624        } // close
625        IconName::ExternalLink => {
626            if is_gtk {
627                "insert-link"
628            } else {
629                "external-link"
630            }
631        } // close
632        IconName::GalleryVerticalEnd => {
633            if is_gtk {
634                "view-paged"
635            } else {
636                "view-list-icons"
637            }
638        } // approximate
639        IconName::GitHub => {
640            if is_gtk {
641                "applications-engineering"
642            } else {
643                "vcs-branch"
644            }
645        } // approximate
646        IconName::Globe => {
647            if is_gtk {
648                "web-browser"
649            } else {
650                "globe"
651            }
652        } // close
653        IconName::Inbox => {
654            if is_gtk {
655                "mail-send-receive"
656            } else {
657                "mail-folder-inbox"
658            }
659        } // close
660        IconName::Inspector => {
661            if is_gtk {
662                "preferences-system-details"
663            } else {
664                "code-context"
665            }
666        } // approximate
667        IconName::PanelBottom => {
668            if is_gtk {
669                "view-dual"
670            } else {
671                "view-split-top-bottom"
672            }
673        } // close
674        IconName::PanelBottomOpen => {
675            if is_gtk {
676                "view-dual"
677            } else {
678                "view-split-top-bottom"
679            }
680        } // close
681        IconName::ThumbsDown => {
682            if is_gtk {
683                "process-stop"
684            } else {
685                "rating-unrated"
686            }
687        } // approximate
688        IconName::ThumbsUp => {
689            if is_gtk {
690                "checkbox-checked"
691            } else {
692                "approved"
693            }
694        } // approximate
695    })
696}
697
698/// Default rasterization size for SVG icons.
699///
700/// SVGs are rasterized at 2x the typical display size (24px) to look sharp
701/// on HiDPI screens. gpui uses the same 2x scale factor internally.
702const SVG_RASTERIZE_SIZE: u32 = 48;
703
704/// Convert [`IconData`] to a gpui [`ImageSource`] for rendering.
705///
706/// Returns `None` if the icon data cannot be converted (corrupt SVG,
707/// unknown variant).
708///
709/// # Parameters
710///
711/// - `color`: If `Some`, colorizes monochrome SVGs with the given color
712///   (replaces `currentColor`, explicit black fills, or injects a fill
713///   attribute). Best for bundled icon sets (Material, Lucide). Pass `None`
714///   for system/OS icons to preserve their native palette.
715///   RGBA icons are passed through unchanged regardless of this parameter.
716/// - `size`: Rasterize size in pixels for SVG icons. `None` defaults to 48px
717///   (2x HiDPI at 24px logical). Pass `logical_size * scale_factor` for
718///   DPI-correct rendering.
719///
720/// # Examples
721///
722/// ```ignore
723/// use native_theme::IconData;
724/// use native_theme_gpui::icons::to_image_source;
725///
726/// let svg = IconData::Svg(b"<svg></svg>".to_vec());
727/// let source = to_image_source(&svg, None, None);        // uncolorized, 48px
728/// let colored = to_image_source(&svg, Some(color), None); // colorized, 48px
729/// let sized = to_image_source(&svg, None, Some(96));      // uncolorized, 96px
730/// ```
731pub 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
759/// Load a custom icon from an [`IconProvider`] and convert to a gpui [`ImageSource`].
760///
761/// Equivalent to calling [`load_custom_icon()`](native_theme::load_custom_icon)
762/// followed by [`to_image_source()`], composing the loading and conversion steps.
763///
764/// Returns `None` if the provider has no icon for the given set or if
765/// conversion fails.
766///
767/// See [`to_image_source()`] for details on the `color` and `size` parameters.
768pub 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
778/// Convert all frames of an [`AnimatedIcon::Frames`] to gpui [`ImageSource`]s.
779///
780/// Returns `Some(Vec<ImageSource>)` when the icon is the `Frames` variant,
781/// with one `ImageSource` per frame. Returns `None` for `Transform` variants.
782///
783/// **Call this once and cache the result.** Do not call on every frame tick --
784/// SVG rasterization is expensive. Index into the cached `Vec` using a
785/// timer-driven frame counter.
786///
787/// Callers should check [`native_theme::prefers_reduced_motion()`] and fall
788/// back to [`AnimatedIcon::first_frame()`] for a static display when the user
789/// has requested reduced motion.
790///
791/// # Examples
792///
793/// ```ignore
794/// use native_theme_gpui::icons::{animated_frames_to_image_sources, AnimatedImageSources};
795///
796/// let anim = native_theme::loading_indicator();
797/// if let Some(AnimatedImageSources { sources, frame_duration_ms }) =
798///     animated_frames_to_image_sources(&anim)
799/// {
800///     // Cache `sources`, then on each timer tick (every `frame_duration_ms` ms):
801///     // frame_index = (frame_index + 1) % sources.len();
802///     // gpui::img(sources[frame_index].clone())
803/// }
804/// ```
805pub 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
824/// Wrap a gpui [`Svg`] element with continuous rotation animation.
825///
826/// Returns an animated element that spins 360 degrees over `duration_ms`
827/// milliseconds, repeating infinitely. Uses linear easing for constant-speed
828/// rotation suitable for loading spinners.
829///
830/// `duration_ms` comes from [`native_theme::TransformAnimation::Spin`].
831/// `animation_id` must be unique among sibling animated elements (accepts
832/// `&'static str`, integer IDs, or any `impl Into<ElementId>`).
833///
834/// This is pure data construction -- no gpui render context is needed to call
835/// this function. Only `paint()` on the resulting element requires a window.
836///
837/// Callers should check [`native_theme::prefers_reduced_motion()`] and fall
838/// back to a static icon when the user has requested reduced motion.
839///
840/// # Examples
841///
842/// ```ignore
843/// use native_theme_gpui::icons::with_spin_animation;
844///
845/// let spinner = gpui::svg().path("spinner.svg").size_6();
846/// let animated = with_spin_animation(spinner, "my-spinner", 1000);
847/// // Use `animated` as a child element in your gpui view
848/// ```
849pub 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
861/// Rasterize SVG bytes and return as a BMP-backed [`ImageSource`].
862///
863/// Returns `None` if rasterization fails (corrupt SVG, empty data).
864///
865/// Works around a gpui bug where `ImageFormat::Svg` in `Image::to_image_data`
866/// skips the RGBA→BGRA pixel conversion that all other formats perform,
867/// causing red and blue channels to be swapped.
868fn 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
882/// Rewrite SVG bytes to use the given color for strokes and fills.
883///
884/// Handles three SVG color patterns (in order):
885/// 1. **`currentColor`** — replaced with the hex color (Lucide-style SVGs).
886/// 2. **Explicit black fills** — `fill="black"`, `fill="#000000"`, `fill="#000"`
887///    are replaced with the hex color (third-party SVGs with hardcoded black).
888/// 3. **Implicit black** — if the root `<svg>` tag has no `fill=` attribute,
889///    injects `fill="<hex>"` (Material-style SVGs).
890///
891/// Not handled: `stroke="black"`, CSS `style="fill:black"`, `fill="rgb(0,0,0)"`,
892/// or explicit black on child elements when the root tag has a different fill.
893/// This function is designed for monochrome icon sets; multi-color SVGs should
894/// not be colorized.
895fn 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    // 1. Replace currentColor (handles Lucide-style SVGs)
905    if svg_str.contains("currentColor") {
906        return svg_str.replace("currentColor", &hex).into_bytes();
907    }
908
909    // 2. Replace explicit black fills (handles third-party SVGs)
910    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    // 3. No currentColor or explicit black — inject fill into root <svg> tag
920    // (handles Material-style SVGs with implicit black fill)
921    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 already has non-black fill and no currentColor — return as-is
936    svg_bytes.to_vec()
937}
938
939/// Encode RGBA pixel data as a BMP with BITMAPV4HEADER.
940///
941/// BMP with a V4 header supports 32-bit RGBA via channel masks.
942/// The pixel data is stored bottom-up (BMP convention) with no compression.
943fn 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; // BITMAPFILEHEADER
946    let dib_header_size: u32 = 108; // BITMAPV4HEADER
947    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    // BITMAPFILEHEADER (14 bytes)
952    buf.extend_from_slice(b"BM"); // signature
953    buf.extend_from_slice(&file_size.to_le_bytes()); // file size
954    buf.extend_from_slice(&0u16.to_le_bytes()); // reserved1
955    buf.extend_from_slice(&0u16.to_le_bytes()); // reserved2
956    buf.extend_from_slice(&(header_size + dib_header_size).to_le_bytes()); // pixel data offset
957
958    // BITMAPV4HEADER (108 bytes)
959    buf.extend_from_slice(&dib_header_size.to_le_bytes()); // header size
960    buf.extend_from_slice(&(width as i32).to_le_bytes()); // width
961    // Negative height = top-down (avoids flipping rows)
962    buf.extend_from_slice(&(-(height as i32)).to_le_bytes()); // height (top-down)
963    buf.extend_from_slice(&1u16.to_le_bytes()); // planes
964    buf.extend_from_slice(&32u16.to_le_bytes()); // bits per pixel
965    buf.extend_from_slice(&3u32.to_le_bytes()); // compression = BI_BITFIELDS
966    buf.extend_from_slice(&(pixel_data_size as u32).to_le_bytes()); // image size
967    buf.extend_from_slice(&2835u32.to_le_bytes()); // x pixels per meter (~72 DPI)
968    buf.extend_from_slice(&2835u32.to_le_bytes()); // y pixels per meter
969    buf.extend_from_slice(&0u32.to_le_bytes()); // colors used
970    buf.extend_from_slice(&0u32.to_le_bytes()); // important colors
971
972    // Channel masks (RGBA -> BGRA in BMP, but we use BI_BITFIELDS to specify layout)
973    buf.extend_from_slice(&0x00FF0000u32.to_le_bytes()); // red mask
974    buf.extend_from_slice(&0x0000FF00u32.to_le_bytes()); // green mask
975    buf.extend_from_slice(&0x000000FFu32.to_le_bytes()); // blue mask
976    buf.extend_from_slice(&0xFF000000u32.to_le_bytes()); // alpha mask
977
978    // Color space type: LCS_sRGB
979    buf.extend_from_slice(&0x73524742u32.to_le_bytes()); // 'sRGB'
980
981    // CIEXYZTRIPLE endpoints (36 bytes of zeros)
982    buf.extend_from_slice(&[0u8; 36]);
983
984    // Gamma values (red, green, blue) - unused with sRGB
985    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    // Pixel data: RGBA -> BGRA conversion for BMP
990    for pixel in rgba.chunks_exact(4) {
991        buf.push(pixel[2]); // B
992        buf.push(pixel[1]); // G
993        buf.push(pixel[0]); // R
994        buf.push(pixel[3]); // A
995    }
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    // --- icon_name tests ---
1115
1116    #[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    // None cases
1205    #[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    // Count test: at least 28 roles map to Some
1231    #[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    // --- to_image_source tests ---
1257
1258    #[test]
1259    fn to_image_source_svg_returns_bmp_rasterized() {
1260        // Valid SVG that resvg can parse
1261        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        // SVGs are rasterized to BMP to work around gpui's RGBA/BGRA bug
1266        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, // red
1282                0, 255, 0, 255, // green
1283                0, 0, 255, 255, // blue
1284                255, 255, 0, 255, // yellow
1285            ],
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                // BMP header starts with "BM"
1292                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    // --- BMP encoding tests ---
1318
1319    #[test]
1320    fn encode_rgba_as_bmp_correct_file_size() {
1321        let rgba = vec![0u8; 4 * 4 * 4]; // 4x4 image
1322        let bmp = encode_rgba_as_bmp(4, 4, &rgba);
1323        let expected_size = 14 + 108 + (4 * 4 * 4); // header + dib + pixels
1324        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]; // 1x1 image
1330        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        // Input RGBA: R=0xAA, G=0xBB, B=0xCC, A=0xDD
1337        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        // BMP stores as BGRA
1341        assert_eq!(bmp[pixel_offset], 0xCC); // B
1342        assert_eq!(bmp[pixel_offset + 1], 0xBB); // G
1343        assert_eq!(bmp[pixel_offset + 2], 0xAA); // R
1344        assert_eq!(bmp[pixel_offset + 3], 0xDD); // A
1345    }
1346    // --- colorize_svg tests ---
1347
1348    #[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); // a blue-ish color
1352        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); // red
1370        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); // green
1383        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        // SVG with no fill attribute at all (Material-style)
1408        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    // --- custom_icon tests ---
1420
1421    // Test helper: minimal IconProvider that returns a bundled SVG
1422    #[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 // No system name -- forces bundled SVG path
1428        }
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    // Provider with no mappings at all
1435    #[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    // --- animated icon tests ---
1495
1496    #[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        // with_spin_animation wraps an Svg element with continuous rotation.
1541        // This is pure construction -- no gpui render context needed.
1542        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        // edit-copy is freedesktop standard — same for all DEs
1584        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        // XFCE is GTK-based and should use GNOME naming convention
1608        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        // Only meaningful on a KDE system with Breeze installed
1622        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        // Verify GNOME mappings resolve against installed Adwaita theme.
1645        // Only runs when Adwaita is installed (it usually is on any Linux).
1646        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                // Has a GNOME mapping — verify it resolves in Adwaita
1651                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            // None means "fall back to bundled" — that's intentional, not a failure
1656        }
1657        assert!(
1658            missing.is_empty(),
1659            "These GNOME mappings did not resolve in Adwaita:\n  {}",
1660            missing.join("\n  "),
1661        );
1662    }
1663}