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//! | [`into_image_source`] | Consuming variant of [`to_image_source`] (avoids clone) |
13//! | [`custom_icon_to_image_source`] | Load + convert via [`IconProvider`] |
14//! | [`bundled_icon_to_image_source`] | Convert [`IconName`] + [`native_theme::IconSet`] → [`ImageSource`] in one call |
15//! | [`animated_frames_to_image_sources`] | Convert animation frames → [`AnimatedImageSources`] |
16//! | [`with_spin_animation`] | Wrap an SVG element with spin animation |
17
18use 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/// Converted animation frames with timing metadata.
27///
28/// Returned by [`animated_frames_to_image_sources`]. Contains the
29/// rasterized frames and the per-frame duration needed to drive playback.
30#[derive(Clone)]
31pub struct AnimatedImageSources {
32    /// Rasterized frames ready for gpui rendering.
33    pub sources: Vec<ImageSource>,
34    /// Duration of each frame in milliseconds.
35    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/// Map an [`IconRole`] to a gpui-component [`IconName`] for the Lucide icon set.
48///
49/// Returns `Some(IconName)` for roles that have a direct Lucide equivalent in
50/// gpui-component's bundled icon set. Returns `None` for roles where
51/// gpui-component doesn't ship the corresponding Lucide icon.
52///
53/// This is a zero-I/O operation -- no icon files are loaded. The returned
54/// `IconName` can be rendered directly via gpui-component's `Icon::new()`.
55///
56/// # Coverage
57///
58/// Maps 30 of the 42 `IconRole` variants to `IconName`. The 12 unmapped roles
59/// (Shield, ActionSave, ActionPaste, ActionCut, ActionEdit, ActionRefresh,
60/// ActionPrint, NavHome, TrashFull, DialogQuestion, Help, Lock) have no
61/// corresponding Lucide icon in gpui-component 0.5.
62///
63/// # Examples
64///
65/// ```ignore
66/// use native_theme::IconRole;
67/// use native_theme_gpui::icons::icon_name;
68///
69/// assert_eq!(icon_name(IconRole::DialogWarning), Some(IconName::TriangleAlert));
70/// assert_eq!(icon_name(IconRole::Shield), None);
71/// ```
72#[must_use]
73pub fn icon_name(role: IconRole) -> Option<IconName> {
74    Some(match role {
75        // Dialog / Alert
76        IconRole::DialogWarning => IconName::TriangleAlert,
77        IconRole::DialogError => IconName::CircleX,
78        IconRole::DialogInfo => IconName::Info,
79        IconRole::DialogSuccess => IconName::CircleCheck,
80
81        // Window Controls
82        IconRole::WindowClose => IconName::WindowClose,
83        IconRole::WindowMinimize => IconName::WindowMinimize,
84        IconRole::WindowMaximize => IconName::WindowMaximize,
85        IconRole::WindowRestore => IconName::WindowRestore,
86
87        // Common Actions
88        IconRole::ActionDelete => IconName::Delete,
89        IconRole::ActionCopy => IconName::Copy,
90        IconRole::ActionUndo => IconName::Undo2,
91        IconRole::ActionRedo => IconName::Redo2,
92        IconRole::ActionSearch => IconName::Search,
93        IconRole::ActionSettings => IconName::Settings,
94        IconRole::ActionAdd => IconName::Plus,
95        IconRole::ActionRemove => IconName::Minus,
96
97        // Navigation
98        IconRole::NavBack => IconName::ChevronLeft,
99        IconRole::NavForward => IconName::ChevronRight,
100        IconRole::NavUp => IconName::ChevronUp,
101        IconRole::NavDown => IconName::ChevronDown,
102        IconRole::NavMenu => IconName::Menu,
103
104        // Files / Places
105        IconRole::FileGeneric => IconName::File,
106        IconRole::FolderClosed => IconName::FolderClosed,
107        IconRole::FolderOpen => IconName::FolderOpen,
108        IconRole::TrashEmpty => IconName::Delete,
109
110        // Status
111        IconRole::StatusBusy => IconName::Loader,
112        IconRole::StatusCheck => IconName::Check,
113        IconRole::StatusError => IconName::CircleX,
114
115        // System
116        IconRole::UserAccount => IconName::User,
117        IconRole::Notification => IconName::Bell,
118
119        // No Lucide equivalent in gpui-component 0.5
120        _ => return None,
121    })
122}
123
124/// Map a gpui-component [`IconName`] to its canonical Lucide icon name.
125///
126/// Returns the kebab-case Lucide name for use with
127/// [`native_theme::bundled_icon_by_name`].
128///
129/// Covers all 86 gpui-component `IconName` variants.
130#[must_use]
131pub fn lucide_name_for_gpui_icon(icon: IconName) -> &'static str {
132    match icon {
133        IconName::ALargeSmall => "a-large-small",
134        IconName::ArrowDown => "arrow-down",
135        IconName::ArrowLeft => "arrow-left",
136        IconName::ArrowRight => "arrow-right",
137        IconName::ArrowUp => "arrow-up",
138        IconName::Asterisk => "asterisk",
139        IconName::Bell => "bell",
140        IconName::BookOpen => "book-open",
141        IconName::Bot => "bot",
142        IconName::Building2 => "building-2",
143        IconName::Calendar => "calendar",
144        IconName::CaseSensitive => "case-sensitive",
145        IconName::ChartPie => "chart-pie",
146        IconName::Check => "check",
147        IconName::ChevronDown => "chevron-down",
148        IconName::ChevronLeft => "chevron-left",
149        IconName::ChevronRight => "chevron-right",
150        IconName::ChevronsUpDown => "chevrons-up-down",
151        IconName::ChevronUp => "chevron-up",
152        IconName::CircleCheck => "circle-check",
153        IconName::CircleUser => "circle-user",
154        IconName::CircleX => "circle-x",
155        IconName::Close => "close",
156        IconName::Copy => "copy",
157        IconName::Dash => "dash",
158        IconName::Delete => "delete",
159        IconName::Ellipsis => "ellipsis",
160        IconName::EllipsisVertical => "ellipsis-vertical",
161        IconName::ExternalLink => "external-link",
162        IconName::Eye => "eye",
163        IconName::EyeOff => "eye-off",
164        IconName::File => "file",
165        IconName::Folder => "folder",
166        IconName::FolderClosed => "folder-closed",
167        IconName::FolderOpen => "folder-open",
168        IconName::Frame => "frame",
169        IconName::GalleryVerticalEnd => "gallery-vertical-end",
170        IconName::GitHub => "github",
171        IconName::Globe => "globe",
172        IconName::Heart => "heart",
173        IconName::HeartOff => "heart-off",
174        IconName::Inbox => "inbox",
175        IconName::Info => "info",
176        IconName::Inspector => "inspect",
177        IconName::LayoutDashboard => "layout-dashboard",
178        IconName::Loader => "loader",
179        IconName::LoaderCircle => "loader-circle",
180        IconName::Map => "map",
181        IconName::Maximize => "maximize",
182        IconName::Menu => "menu",
183        IconName::Minimize => "minimize",
184        IconName::Minus => "minus",
185        IconName::Moon => "moon",
186        IconName::Palette => "palette",
187        IconName::PanelBottom => "panel-bottom",
188        IconName::PanelBottomOpen => "panel-bottom-open",
189        IconName::PanelLeft => "panel-left",
190        IconName::PanelLeftClose => "panel-left-close",
191        IconName::PanelLeftOpen => "panel-left-open",
192        IconName::PanelRight => "panel-right",
193        IconName::PanelRightClose => "panel-right-close",
194        IconName::PanelRightOpen => "panel-right-open",
195        IconName::Plus => "plus",
196        IconName::Redo => "redo",
197        IconName::Redo2 => "redo-2",
198        IconName::Replace => "replace",
199        IconName::ResizeCorner => "resize-corner",
200        IconName::Search => "search",
201        IconName::Settings => "settings",
202        IconName::Settings2 => "settings-2",
203        IconName::SortAscending => "sort-ascending",
204        IconName::SortDescending => "sort-descending",
205        IconName::SquareTerminal => "square-terminal",
206        IconName::Star => "star",
207        IconName::StarOff => "star-off",
208        IconName::Sun => "sun",
209        IconName::ThumbsDown => "thumbs-down",
210        IconName::ThumbsUp => "thumbs-up",
211        IconName::TriangleAlert => "triangle-alert",
212        IconName::Undo => "undo",
213        IconName::Undo2 => "undo-2",
214        IconName::User => "user",
215        IconName::WindowClose => "window-close",
216        IconName::WindowMaximize => "window-maximize",
217        IconName::WindowMinimize => "window-minimize",
218        IconName::WindowRestore => "window-restore",
219    }
220}
221
222/// Map a gpui-component [`IconName`] to its canonical Material icon name.
223///
224/// Returns the snake_case Material Symbols name for use with
225/// [`native_theme::bundled_icon_by_name`].
226///
227/// Covers all 86 gpui-component `IconName` variants.
228#[must_use]
229pub fn material_name_for_gpui_icon(icon: IconName) -> &'static str {
230    match icon {
231        IconName::ALargeSmall => "font_size",
232        IconName::ArrowDown => "arrow_downward",
233        IconName::ArrowLeft => "arrow_back",
234        IconName::ArrowRight => "arrow_forward",
235        IconName::ArrowUp => "arrow_upward",
236        IconName::Asterisk => "emergency",
237        IconName::Bell => "notifications",
238        IconName::BookOpen => "menu_book",
239        IconName::Bot => "smart_toy",
240        IconName::Building2 => "apartment",
241        IconName::Calendar => "calendar_today",
242        IconName::CaseSensitive => "match_case",
243        IconName::ChartPie => "pie_chart",
244        IconName::Check => "check",
245        IconName::ChevronDown => "expand_more",
246        IconName::ChevronLeft => "chevron_left",
247        IconName::ChevronRight => "chevron_right",
248        IconName::ChevronsUpDown => "unfold_more",
249        IconName::ChevronUp => "expand_less",
250        IconName::CircleCheck => "check_circle",
251        IconName::CircleUser => "account_circle",
252        IconName::CircleX => "cancel",
253        IconName::Close => "close",
254        IconName::Copy => "content_copy",
255        IconName::Dash => "remove",
256        IconName::Delete => "delete",
257        IconName::Ellipsis => "more_horiz",
258        IconName::EllipsisVertical => "more_vert",
259        IconName::ExternalLink => "open_in_new",
260        IconName::Eye => "visibility",
261        IconName::EyeOff => "visibility_off",
262        IconName::File => "description",
263        IconName::Folder => "folder",
264        IconName::FolderClosed => "folder",
265        IconName::FolderOpen => "folder_open",
266        IconName::Frame => "crop_free",
267        IconName::GalleryVerticalEnd => "view_carousel",
268        IconName::GitHub => "code",
269        IconName::Globe => "language",
270        IconName::Heart => "favorite",
271        IconName::HeartOff => "heart_broken",
272        IconName::Inbox => "inbox",
273        IconName::Info => "info",
274        IconName::Inspector => "developer_mode",
275        IconName::LayoutDashboard => "dashboard",
276        IconName::Loader => "progress_activity",
277        IconName::LoaderCircle => "autorenew",
278        IconName::Map => "map",
279        IconName::Maximize => "open_in_full",
280        IconName::Menu => "menu",
281        IconName::Minimize => "minimize",
282        IconName::Minus => "remove",
283        IconName::Moon => "dark_mode",
284        IconName::Palette => "palette",
285        IconName::PanelBottom => "dock_to_bottom",
286        IconName::PanelBottomOpen => "web_asset",
287        IconName::PanelLeft => "side_navigation",
288        IconName::PanelLeftClose => "left_panel_close",
289        IconName::PanelLeftOpen => "left_panel_open",
290        IconName::PanelRight => "right_panel_close",
291        IconName::PanelRightClose => "right_panel_close",
292        IconName::PanelRightOpen => "right_panel_open",
293        IconName::Plus => "add",
294        IconName::Redo => "redo",
295        IconName::Redo2 => "redo",
296        IconName::Replace => "find_replace",
297        IconName::ResizeCorner => "drag_indicator",
298        IconName::Search => "search",
299        IconName::Settings => "settings",
300        IconName::Settings2 => "tune",
301        IconName::SortAscending => "arrow_upward",
302        IconName::SortDescending => "arrow_downward",
303        IconName::SquareTerminal => "terminal",
304        IconName::Star => "star",
305        IconName::StarOff => "star_border",
306        IconName::Sun => "light_mode",
307        IconName::ThumbsDown => "thumb_down",
308        IconName::ThumbsUp => "thumb_up",
309        IconName::TriangleAlert => "warning",
310        IconName::Undo => "undo",
311        IconName::Undo2 => "undo",
312        IconName::User => "person",
313        IconName::WindowClose => "close",
314        IconName::WindowMaximize => "open_in_full",
315        IconName::WindowMinimize => "minimize",
316        IconName::WindowRestore => "close_fullscreen",
317    }
318}
319
320/// Map a gpui-component [`IconName`] to its freedesktop icon name for the
321/// given desktop environment.
322///
323/// Returns the best freedesktop name for the detected DE's naming
324/// convention. When KDE and GNOME use different names for the same
325/// concept, the DE parameter selects the right one. For freedesktop
326/// standard names (present in all themes), the DE is ignored.
327///
328/// GTK-based DEs (GNOME, Budgie, Cinnamon, MATE, XFCE) share the
329/// Adwaita/GNOME naming convention. Qt-based DEs (KDE, LxQt) and
330/// Unknown share the Breeze/KDE convention.
331///
332/// ## Confidence levels
333///
334/// Each mapping is annotated with a confidence level:
335/// - `exact`: the freedesktop icon is semantically identical
336/// - `close`: same concept, minor visual difference
337/// - `approximate`: best available match, different metaphor
338///
339/// Covers all 86 gpui-component `IconName` variants.
340#[cfg(target_os = "linux")]
341#[must_use]
342pub fn freedesktop_name_for_gpui_icon(
343    icon: IconName,
344    de: native_theme::LinuxDesktop,
345) -> &'static str {
346    use native_theme::LinuxDesktop;
347
348    // GTK-based DEs follow GNOME/Adwaita naming; Qt-based follow KDE/Breeze
349    let is_gtk = matches!(
350        de,
351        LinuxDesktop::Gnome
352            | LinuxDesktop::Budgie
353            | LinuxDesktop::Cinnamon
354            | LinuxDesktop::Mate
355            | LinuxDesktop::Xfce
356    );
357
358    match icon {
359        // --- Icons with freedesktop standard names (all DEs) ---
360        IconName::BookOpen => "help-contents",      // close
361        IconName::Bot => "face-smile",              // approximate
362        IconName::ChevronDown => "go-down",         // close: full nav arrow, not disclosure chevron
363        IconName::ChevronLeft => "go-previous",     // close
364        IconName::ChevronRight => "go-next",        // close
365        IconName::ChevronUp => "go-up",             // close
366        IconName::CircleX => "dialog-error",        // close
367        IconName::Copy => "edit-copy",              // exact
368        IconName::Dash => "list-remove",            // exact
369        IconName::Delete => "edit-delete",          // exact
370        IconName::File => "text-x-generic",         // exact
371        IconName::Folder => "folder",               // exact
372        IconName::FolderClosed => "folder",         // exact
373        IconName::FolderOpen => "folder-open",      // exact
374        IconName::HeartOff => "non-starred",        // close: un-favorite semantics
375        IconName::Info => "dialog-information",     // exact
376        IconName::LayoutDashboard => "view-grid",   // close
377        IconName::Map => "find-location",           // close
378        IconName::Maximize => "view-fullscreen",    // exact
379        IconName::Menu => "open-menu",              // exact
380        IconName::Minimize => "window-minimize",    // exact
381        IconName::Minus => "list-remove",           // exact
382        IconName::Moon => "weather-clear-night",    // close: dark mode toggle
383        IconName::Plus => "list-add",               // exact
384        IconName::Redo => "edit-redo",              // exact
385        IconName::Redo2 => "edit-redo",             // exact
386        IconName::Replace => "edit-find-replace",   // exact
387        IconName::Search => "edit-find",            // exact
388        IconName::Settings => "preferences-system", // exact
389        IconName::SortAscending => "view-sort-ascending", // exact
390        IconName::SortDescending => "view-sort-descending", // exact
391        IconName::SquareTerminal => "utilities-terminal", // close
392        IconName::Star => "starred",                // exact
393        IconName::StarOff => "non-starred",         // exact
394        IconName::Sun => "weather-clear",           // close: light mode toggle
395        IconName::TriangleAlert => "dialog-warning", // exact
396        IconName::Undo => "edit-undo",              // exact
397        IconName::Undo2 => "edit-undo",             // exact
398        IconName::User => "system-users",           // exact
399        IconName::WindowClose => "window-close",    // exact
400        IconName::WindowMaximize => "window-maximize", // exact
401        IconName::WindowMinimize => "window-minimize", // exact
402        IconName::WindowRestore => "window-restore", // exact
403
404        // --- Icons where KDE and GNOME both have names but they differ ---
405        IconName::ArrowDown => {
406            if is_gtk {
407                "go-bottom"
408            } else {
409                "go-down-skip"
410            }
411        } // close
412        IconName::ArrowLeft => {
413            if is_gtk {
414                "go-first"
415            } else {
416                "go-previous-skip"
417            }
418        } // close
419        IconName::ArrowRight => {
420            if is_gtk {
421                "go-last"
422            } else {
423                "go-next-skip"
424            }
425        } // close
426        IconName::ArrowUp => {
427            if is_gtk {
428                "go-top"
429            } else {
430                "go-up-skip"
431            }
432        } // close
433        IconName::Calendar => {
434            if is_gtk {
435                "x-office-calendar"
436            } else {
437                "view-calendar"
438            }
439        } // exact
440        IconName::Check => {
441            if is_gtk {
442                "object-select"
443            } else {
444                "dialog-ok"
445            }
446        } // close
447        IconName::CircleCheck => {
448            if is_gtk {
449                "object-select"
450            } else {
451                "emblem-ok-symbolic"
452            }
453        } // close
454        IconName::CircleUser => {
455            if is_gtk {
456                "avatar-default"
457            } else {
458                "user-identity"
459            }
460        } // close
461        IconName::Close => {
462            if is_gtk {
463                "window-close"
464            } else {
465                "tab-close"
466            }
467        } // close
468        IconName::Ellipsis => {
469            if is_gtk {
470                "view-more-horizontal"
471            } else {
472                "overflow-menu"
473            }
474        } // exact
475        IconName::EllipsisVertical => {
476            if is_gtk {
477                "view-more"
478            } else {
479                "overflow-menu"
480            }
481        } // close: no vertical variant in KDE
482        IconName::Eye => {
483            if is_gtk {
484                "view-reveal"
485            } else {
486                "view-visible"
487            }
488        } // exact
489        IconName::EyeOff => {
490            if is_gtk {
491                "view-conceal"
492            } else {
493                "view-hidden"
494            }
495        } // exact
496        IconName::Frame => {
497            if is_gtk {
498                "selection-mode"
499            } else {
500                "select-rectangular"
501            }
502        } // close
503        IconName::Heart => {
504            if is_gtk {
505                "starred"
506            } else {
507                "emblem-favorite"
508            }
509        } // close
510        IconName::Loader => {
511            if is_gtk {
512                "content-loading"
513            } else {
514                "process-working"
515            }
516        } // exact
517        IconName::LoaderCircle => {
518            if is_gtk {
519                "content-loading"
520            } else {
521                "process-working"
522            }
523        } // exact
524        IconName::Palette => {
525            if is_gtk {
526                "color-select"
527            } else {
528                "palette"
529            }
530        } // close
531        IconName::PanelLeft => {
532            if is_gtk {
533                "sidebar-show"
534            } else {
535                "sidebar-expand-left"
536            }
537        } // close
538        IconName::PanelLeftClose => {
539            if is_gtk {
540                "sidebar-show"
541            } else {
542                "view-left-close"
543            }
544        } // close
545        IconName::PanelLeftOpen => {
546            if is_gtk {
547                "sidebar-show"
548            } else {
549                "view-left-new"
550            }
551        } // close
552        IconName::PanelRight => {
553            if is_gtk {
554                "sidebar-show-right"
555            } else {
556                "view-right-new"
557            }
558        } // close
559        IconName::PanelRightClose => {
560            if is_gtk {
561                "sidebar-show-right"
562            } else {
563                "view-right-close"
564            }
565        } // close
566        IconName::PanelRightOpen => {
567            if is_gtk {
568                "sidebar-show-right"
569            } else {
570                "view-right-new"
571            }
572        } // close
573        IconName::ResizeCorner => {
574            if is_gtk {
575                "list-drag-handle"
576            } else {
577                "drag-handle"
578            }
579        } // close
580        IconName::Settings2 => {
581            if is_gtk {
582                "preferences-other"
583            } else {
584                "configure"
585            }
586        } // close
587
588        // --- Icons where GNOME uses a different (approximate) alternative ---
589        IconName::ALargeSmall => {
590            if is_gtk {
591                "zoom-in"
592            } else {
593                "format-font-size-more"
594            }
595        } // approximate
596        IconName::Asterisk => {
597            if is_gtk {
598                "starred"
599            } else {
600                "rating"
601            }
602        } // approximate
603        IconName::Bell => {
604            if is_gtk {
605                "alarm"
606            } else {
607                "notification-active"
608            }
609        } // close
610        IconName::Building2 => {
611            if is_gtk {
612                "network-workgroup"
613            } else {
614                "applications-office"
615            }
616        } // approximate
617        IconName::CaseSensitive => {
618            if is_gtk {
619                "format-text-rich"
620            } else {
621                "format-text-uppercase"
622            }
623        } // approximate
624        IconName::ChartPie => {
625            if is_gtk {
626                "x-office-spreadsheet"
627            } else {
628                "office-chart-pie"
629            }
630        } // approximate
631        IconName::ChevronsUpDown => {
632            if is_gtk {
633                "list-drag-handle"
634            } else {
635                "handle-sort"
636            }
637        } // close
638        IconName::ExternalLink => {
639            if is_gtk {
640                "insert-link"
641            } else {
642                "external-link"
643            }
644        } // close
645        IconName::GalleryVerticalEnd => {
646            if is_gtk {
647                "view-paged"
648            } else {
649                "view-list-icons"
650            }
651        } // approximate
652        IconName::GitHub => {
653            if is_gtk {
654                "applications-engineering"
655            } else {
656                "vcs-branch"
657            }
658        } // approximate
659        IconName::Globe => {
660            if is_gtk {
661                "web-browser"
662            } else {
663                "globe"
664            }
665        } // close
666        IconName::Inbox => {
667            if is_gtk {
668                "mail-send-receive"
669            } else {
670                "mail-folder-inbox"
671            }
672        } // close
673        IconName::Inspector => {
674            if is_gtk {
675                "preferences-system-details"
676            } else {
677                "code-context"
678            }
679        } // approximate
680        IconName::PanelBottom => {
681            if is_gtk {
682                "view-dual"
683            } else {
684                "view-split-top-bottom"
685            }
686        } // close
687        IconName::PanelBottomOpen => {
688            if is_gtk {
689                "view-dual"
690            } else {
691                "view-split-top-bottom"
692            }
693        } // close
694        IconName::ThumbsDown => {
695            if is_gtk {
696                "process-stop"
697            } else {
698                "rating-unrated"
699            }
700        } // approximate
701        IconName::ThumbsUp => {
702            if is_gtk {
703                "checkbox-checked"
704            } else {
705                "approved"
706            }
707        } // approximate
708    }
709}
710
711/// Default rasterization size for SVG icons.
712///
713/// SVGs are rasterized at 2x the typical display size (24px) to look sharp
714/// on HiDPI screens. gpui uses the same 2x scale factor internally.
715const SVG_RASTERIZE_SIZE: u32 = 48;
716
717/// Convert [`IconData`] to a gpui [`ImageSource`] for rendering.
718///
719/// Returns `None` if the icon data cannot be converted (corrupt SVG,
720/// unknown variant).
721///
722/// # Parameters
723///
724/// - `color`: If `Some`, colorizes monochrome SVGs with the given color
725///   (replaces `currentColor`, explicit black fills, or injects a fill
726///   attribute). Best for bundled icon sets (Material, Lucide). Pass `None`
727///   for system/OS icons to preserve their native palette.
728///   RGBA icons are passed through unchanged regardless of this parameter.
729/// - `size`: Rasterize size in pixels for SVG icons. `None` defaults to 48px
730///   (2x HiDPI at 24px logical). Pass `logical_size * scale_factor` for
731///   DPI-correct rendering.
732///
733/// # Examples
734///
735/// ```ignore
736/// use native_theme::IconData;
737/// use native_theme_gpui::icons::to_image_source;
738///
739/// let svg = IconData::Svg(b"<svg></svg>".to_vec());
740/// let source = to_image_source(&svg, None, None);        // uncolorized, 48px
741/// let colored = to_image_source(&svg, Some(color), None); // colorized, 48px
742/// let sized = to_image_source(&svg, None, Some(96));      // uncolorized, 96px
743/// ```
744#[must_use]
745pub fn to_image_source(
746    data: &IconData,
747    color: Option<Hsla>,
748    size: Option<u32>,
749) -> Option<ImageSource> {
750    let raster_size = size.unwrap_or(SVG_RASTERIZE_SIZE);
751    match data {
752        IconData::Svg(bytes) => {
753            if let Some(c) = color {
754                let colored = colorize_svg(bytes, c);
755                svg_to_bmp_source(&colored, raster_size)
756            } else {
757                svg_to_bmp_source(bytes, raster_size)
758            }
759        }
760        IconData::Rgba {
761            width,
762            height,
763            data,
764        } => {
765            let bmp = encode_rgba_as_bmp(*width, *height, data)?;
766            let image = Image::from_bytes(ImageFormat::Bmp, bmp);
767            Some(ImageSource::Image(Arc::new(image)))
768        }
769        _ => None,
770    }
771}
772
773/// Convert [`IconData`] to a gpui [`ImageSource`], consuming the data.
774///
775/// This is the consuming variant of [`to_image_source()`]. It takes ownership
776/// of the `IconData` to avoid cloning the underlying `Vec<u8>`. Prefer this
777/// when you already own the data and won't use it again.
778///
779/// Returns `None` if the icon data cannot be converted (corrupt SVG,
780/// unknown variant).
781///
782/// See [`to_image_source()`] for details on the `color` and `size` parameters.
783#[must_use]
784pub fn into_image_source(
785    data: IconData,
786    color: Option<Hsla>,
787    size: Option<u32>,
788) -> Option<ImageSource> {
789    to_image_source(&data, color, size)
790}
791
792/// Load a custom icon from an [`IconProvider`] and convert to a gpui [`ImageSource`].
793///
794/// Equivalent to calling [`load_custom_icon()`](native_theme::load_custom_icon)
795/// followed by [`to_image_source()`], composing the loading and conversion steps.
796///
797/// Returns `None` if the provider has no icon for the given set or if
798/// conversion fails.
799///
800/// See [`to_image_source()`] for details on the `color` and `size` parameters.
801#[must_use]
802pub fn custom_icon_to_image_source(
803    provider: &(impl IconProvider + ?Sized),
804    icon_set: native_theme::IconSet,
805    color: Option<Hsla>,
806    size: Option<u32>,
807) -> Option<ImageSource> {
808    let data = load_custom_icon(provider, icon_set)?;
809    to_image_source(&data, color, size)
810}
811
812/// Load a gpui-component icon from a bundled icon set and convert to an [`ImageSource`].
813///
814/// Combines the icon-name mapping and loading steps into a single call for
815/// bundled icon sets. Supports [`native_theme::IconSet::Lucide`] and [`native_theme::IconSet::Material`].
816/// Returns `None` for other icon sets (use [`to_image_source`] with
817/// [`native_theme::load_freedesktop_icon_by_name`] for freedesktop system icons).
818///
819/// See [`to_image_source()`] for details on the `color` and `size` parameters.
820///
821/// # Examples
822///
823/// ```ignore
824/// use gpui_component::IconName;
825/// use native_theme::IconSet;
826/// use native_theme_gpui::icons::bundled_icon_to_image_source;
827///
828/// let source = bundled_icon_to_image_source(IconName::Search, IconSet::Lucide, None, None);
829/// ```
830#[must_use]
831pub fn bundled_icon_to_image_source(
832    icon: IconName,
833    icon_set: native_theme::IconSet,
834    color: Option<Hsla>,
835    size: Option<u32>,
836) -> Option<ImageSource> {
837    let name = match icon_set {
838        native_theme::IconSet::Lucide => lucide_name_for_gpui_icon(icon),
839        native_theme::IconSet::Material => material_name_for_gpui_icon(icon),
840        _ => return None,
841    };
842    let svg = native_theme::bundled_icon_by_name(name, icon_set)?;
843    let data = IconData::Svg(svg.to_vec());
844    to_image_source(&data, color, size)
845}
846
847/// Convert all frames of an [`AnimatedIcon::Frames`] to gpui [`ImageSource`]s.
848///
849/// Returns `Some(Vec<ImageSource>)` when the icon is the `Frames` variant,
850/// with one `ImageSource` per frame. Returns `None` for `Transform` variants.
851///
852/// **Note:** Frames that cannot be converted to `ImageSource` (e.g., corrupt
853/// SVG data, rasterization failure) are silently excluded. The returned
854/// animation may have fewer frames than the input, causing it to play faster.
855/// If all frames fail, returns `None`.
856///
857/// **Call this once and cache the result.** Do not call on every frame tick --
858/// SVG rasterization is expensive. Index into the cached `Vec` using a
859/// timer-driven frame counter.
860///
861/// Callers should check [`native_theme::prefers_reduced_motion()`] and fall
862/// back to [`AnimatedIcon::first_frame()`] for a static display when the user
863/// has requested reduced motion.
864///
865/// # Examples
866///
867/// ```ignore
868/// use native_theme_gpui::icons::{animated_frames_to_image_sources, AnimatedImageSources};
869///
870/// let anim = native_theme::loading_indicator();
871/// if let Some(AnimatedImageSources { sources, frame_duration_ms }) =
872///     animated_frames_to_image_sources(&anim, None, None)
873/// {
874///     // Cache `sources`, then on each timer tick (every `frame_duration_ms` ms):
875///     // frame_index = (frame_index + 1) % sources.len();
876///     // gpui::img(sources[frame_index].clone())
877/// }
878/// ```
879#[must_use]
880pub fn animated_frames_to_image_sources(
881    anim: &AnimatedIcon,
882    color: Option<Hsla>,
883    size: Option<u32>,
884) -> Option<AnimatedImageSources> {
885    match anim {
886        AnimatedIcon::Frames {
887            frames,
888            frame_duration_ms,
889        } => {
890            let sources: Vec<ImageSource> = frames
891                .iter()
892                .filter_map(|f| to_image_source(f, color, size))
893                .collect();
894            if sources.is_empty() {
895                None
896            } else {
897                Some(AnimatedImageSources {
898                    sources,
899                    frame_duration_ms: *frame_duration_ms,
900                })
901            }
902        }
903        _ => None,
904    }
905}
906
907/// Wrap a gpui [`Svg`] element with continuous rotation animation.
908///
909/// Returns an animated element that spins 360 degrees over `duration_ms`
910/// milliseconds, repeating infinitely. Uses linear easing for constant-speed
911/// rotation suitable for loading spinners.
912///
913/// `duration_ms` comes from [`native_theme::TransformAnimation::Spin`].
914/// `animation_id` must be unique among sibling animated elements (accepts
915/// `&'static str`, integer IDs, or any `impl Into<ElementId>`).
916///
917/// This is pure data construction -- no gpui render context is needed to call
918/// this function. Only `paint()` on the resulting element requires a window.
919///
920/// Callers should check [`native_theme::prefers_reduced_motion()`] and fall
921/// back to a static icon when the user has requested reduced motion.
922///
923/// # Examples
924///
925/// ```ignore
926/// use native_theme_gpui::icons::with_spin_animation;
927///
928/// let spinner = gpui::svg().path("spinner.svg").size_6();
929/// let animated = with_spin_animation(spinner, "my-spinner", 1000);
930/// // Use `animated` as a child element in your gpui view
931/// ```
932#[must_use]
933pub fn with_spin_animation(
934    element: Svg,
935    animation_id: impl Into<gpui::ElementId>,
936    duration_ms: u32,
937) -> impl gpui::IntoElement {
938    element.with_animation(
939        animation_id,
940        Animation::new(Duration::from_millis(duration_ms as u64)).repeat(),
941        |el, delta| el.with_transformation(Transformation::rotate(percentage(delta))),
942    )
943}
944
945/// Rasterize SVG bytes and return as a BMP-backed [`ImageSource`].
946///
947/// Returns `None` if rasterization fails (corrupt SVG, empty data).
948///
949/// Works around a gpui bug where `ImageFormat::Svg` in `Image::to_image_data`
950/// skips the RGBA→BGRA pixel conversion that all other formats perform,
951/// causing red and blue channels to be swapped.
952fn svg_to_bmp_source(svg_bytes: &[u8], size: u32) -> Option<ImageSource> {
953    let Ok(IconData::Rgba {
954        width,
955        height,
956        data,
957    }) = native_theme::rasterize::rasterize_svg(svg_bytes, size)
958    else {
959        return None;
960    };
961    let bmp = encode_rgba_as_bmp(width, height, &data)?;
962    let image = Image::from_bytes(ImageFormat::Bmp, bmp);
963    Some(ImageSource::Image(Arc::new(image)))
964}
965
966/// Rewrite SVG bytes to use the given color for strokes and fills.
967///
968/// Handles three SVG color patterns (in order):
969/// 1. **`currentColor`** — replaced with the hex color (Lucide-style SVGs).
970/// 2. **Explicit black fills** — `fill="black"`, `fill="#000000"`, `fill="#000"`
971///    are replaced with the hex color (third-party SVGs with hardcoded black).
972/// 3. **Implicit black** — if the root `<svg>` tag has no `fill=` attribute,
973///    injects `fill="<hex>"` (Material-style SVGs).
974///
975/// Not handled: `stroke="black"`, CSS `style="fill:black"`, `fill="rgb(0,0,0)"`,
976/// or explicit black on child elements when the root tag has a different fill.
977/// This function is designed for monochrome icon sets; multi-color SVGs should
978/// not be colorized.
979fn colorize_svg(svg_bytes: &[u8], color: Hsla) -> Vec<u8> {
980    let rgba: gpui::Rgba = color.into();
981    let r = (rgba.r.clamp(0.0, 1.0) * 255.0).round() as u8;
982    let g = (rgba.g.clamp(0.0, 1.0) * 255.0).round() as u8;
983    let b = (rgba.b.clamp(0.0, 1.0) * 255.0).round() as u8;
984    let hex = format!("#{:02x}{:02x}{:02x}", r, g, b);
985
986    let Ok(svg_str) = std::str::from_utf8(svg_bytes) else {
987        // Non-UTF-8 SVGs pass through unmodified -- no corruption risk.
988        // These are typically multi-color system icons that shouldn't be colorized.
989        return svg_bytes.to_vec();
990    };
991
992    // 1. Replace currentColor (handles Lucide-style SVGs)
993    if svg_str.contains("currentColor") {
994        return svg_str.replace("currentColor", &hex).into_bytes();
995    }
996
997    // 2. Replace explicit black fills (handles third-party SVGs)
998    let fill_hex = format!("fill=\"{}\"", hex);
999    let replaced = svg_str
1000        .replace("fill=\"black\"", &fill_hex)
1001        .replace("fill=\"#000000\"", &fill_hex)
1002        .replace("fill=\"#000\"", &fill_hex);
1003    if replaced != svg_str {
1004        return replaced.into_bytes();
1005    }
1006
1007    // 3. No currentColor or explicit black — inject fill into root <svg> tag
1008    // (handles Material-style SVGs with implicit black fill)
1009    if let Some(pos) = svg_str.find("<svg")
1010        && let Some(close) = svg_str[pos..].find('>')
1011    {
1012        let tag_end = pos + close;
1013        let tag = &svg_str[pos..tag_end];
1014        if !tag.contains("fill=") {
1015            // Handle self-closing tags: inject before '/' in '<svg .../>'
1016            let inject_pos = if tag_end > 0 && svg_str.as_bytes()[tag_end - 1] == b'/' {
1017                tag_end - 1
1018            } else {
1019                tag_end
1020            };
1021            let mut result = String::with_capacity(svg_str.len() + 20);
1022            result.push_str(&svg_str[..inject_pos]);
1023            result.push_str(&format!(" fill=\"{}\"", hex));
1024            result.push_str(&svg_str[inject_pos..]);
1025            return result.into_bytes();
1026        }
1027    }
1028
1029    // SVG already has non-black fill and no currentColor — return as-is
1030    svg_bytes.to_vec()
1031}
1032
1033/// Encode RGBA pixel data as a BMP with BITMAPV4HEADER.
1034///
1035/// BMP with a V4 header supports 32-bit RGBA via channel masks.
1036/// The pixel data is stored top-down (negative height in the BMP header)
1037/// with no compression.
1038///
1039/// Returns `None` if dimensions are zero, the RGBA data length does not
1040/// match `width * height * 4`, or the total file size exceeds `u32::MAX`.
1041fn encode_rgba_as_bmp(width: u32, height: u32, rgba: &[u8]) -> Option<Vec<u8>> {
1042    if width == 0 || height == 0 {
1043        return None;
1044    }
1045    let pixel_data_size = (width as usize)
1046        .checked_mul(height as usize)?
1047        .checked_mul(4)?;
1048    if rgba.len() != pixel_data_size {
1049        return None;
1050    }
1051    let header_size: usize = 14; // BITMAPFILEHEADER
1052    let dib_header_size: usize = 108; // BITMAPV4HEADER
1053    let file_size = u32::try_from(header_size + dib_header_size + pixel_data_size).ok()?;
1054
1055    let mut buf = Vec::with_capacity(file_size as usize);
1056
1057    let dib_header_u32 = dib_header_size as u32;
1058    let pixel_data_u32 = pixel_data_size as u32;
1059
1060    // BITMAPFILEHEADER (14 bytes)
1061    buf.extend_from_slice(b"BM"); // signature
1062    buf.extend_from_slice(&file_size.to_le_bytes()); // file size
1063    buf.extend_from_slice(&0u16.to_le_bytes()); // reserved1
1064    buf.extend_from_slice(&0u16.to_le_bytes()); // reserved2
1065    buf.extend_from_slice(&(header_size as u32 + dib_header_u32).to_le_bytes()); // pixel data offset
1066
1067    // BITMAPV4HEADER (108 bytes)
1068    buf.extend_from_slice(&dib_header_u32.to_le_bytes()); // header size
1069    buf.extend_from_slice(&(width as i32).to_le_bytes()); // width
1070    // Negative height = top-down (avoids flipping rows)
1071    buf.extend_from_slice(&(-(height as i32)).to_le_bytes()); // height (top-down)
1072    buf.extend_from_slice(&1u16.to_le_bytes()); // planes
1073    buf.extend_from_slice(&32u16.to_le_bytes()); // bits per pixel
1074    buf.extend_from_slice(&3u32.to_le_bytes()); // compression = BI_BITFIELDS
1075    buf.extend_from_slice(&pixel_data_u32.to_le_bytes()); // image size
1076    buf.extend_from_slice(&2835u32.to_le_bytes()); // x pixels per meter (~72 DPI)
1077    buf.extend_from_slice(&2835u32.to_le_bytes()); // y pixels per meter
1078    buf.extend_from_slice(&0u32.to_le_bytes()); // colors used
1079    buf.extend_from_slice(&0u32.to_le_bytes()); // important colors
1080
1081    // Channel masks (RGBA -> BGRA in BMP, but we use BI_BITFIELDS to specify layout)
1082    buf.extend_from_slice(&0x00FF0000u32.to_le_bytes()); // red mask
1083    buf.extend_from_slice(&0x0000FF00u32.to_le_bytes()); // green mask
1084    buf.extend_from_slice(&0x000000FFu32.to_le_bytes()); // blue mask
1085    buf.extend_from_slice(&0xFF000000u32.to_le_bytes()); // alpha mask
1086
1087    // Color space type: LCS_sRGB
1088    buf.extend_from_slice(&0x73524742u32.to_le_bytes()); // 'sRGB'
1089
1090    // CIEXYZTRIPLE endpoints (36 bytes of zeros)
1091    buf.extend_from_slice(&[0u8; 36]);
1092
1093    // Gamma values (red, green, blue) - unused with sRGB
1094    buf.extend_from_slice(&0u32.to_le_bytes());
1095    buf.extend_from_slice(&0u32.to_le_bytes());
1096    buf.extend_from_slice(&0u32.to_le_bytes());
1097
1098    // Pixel data: RGBA -> BGRA conversion for BMP
1099    for pixel in rgba.chunks_exact(4) {
1100        buf.push(pixel[2]); // B
1101        buf.push(pixel[1]); // G
1102        buf.push(pixel[0]); // R
1103        buf.push(pixel[3]); // A
1104    }
1105
1106    Some(buf)
1107}
1108
1109#[cfg(test)]
1110#[allow(clippy::unwrap_used, clippy::expect_used)]
1111mod tests {
1112    use super::*;
1113
1114    pub(super) const ALL_ICON_NAMES: &[IconName] = &[
1115        IconName::ALargeSmall,
1116        IconName::ArrowDown,
1117        IconName::ArrowLeft,
1118        IconName::ArrowRight,
1119        IconName::ArrowUp,
1120        IconName::Asterisk,
1121        IconName::Bell,
1122        IconName::BookOpen,
1123        IconName::Bot,
1124        IconName::Building2,
1125        IconName::Calendar,
1126        IconName::CaseSensitive,
1127        IconName::ChartPie,
1128        IconName::Check,
1129        IconName::ChevronDown,
1130        IconName::ChevronLeft,
1131        IconName::ChevronRight,
1132        IconName::ChevronsUpDown,
1133        IconName::ChevronUp,
1134        IconName::CircleCheck,
1135        IconName::CircleUser,
1136        IconName::CircleX,
1137        IconName::Close,
1138        IconName::Copy,
1139        IconName::Dash,
1140        IconName::Delete,
1141        IconName::Ellipsis,
1142        IconName::EllipsisVertical,
1143        IconName::ExternalLink,
1144        IconName::Eye,
1145        IconName::EyeOff,
1146        IconName::File,
1147        IconName::Folder,
1148        IconName::FolderClosed,
1149        IconName::FolderOpen,
1150        IconName::Frame,
1151        IconName::GalleryVerticalEnd,
1152        IconName::GitHub,
1153        IconName::Globe,
1154        IconName::Heart,
1155        IconName::HeartOff,
1156        IconName::Inbox,
1157        IconName::Info,
1158        IconName::Inspector,
1159        IconName::LayoutDashboard,
1160        IconName::Loader,
1161        IconName::LoaderCircle,
1162        IconName::Map,
1163        IconName::Maximize,
1164        IconName::Menu,
1165        IconName::Minimize,
1166        IconName::Minus,
1167        IconName::Moon,
1168        IconName::Palette,
1169        IconName::PanelBottom,
1170        IconName::PanelBottomOpen,
1171        IconName::PanelLeft,
1172        IconName::PanelLeftClose,
1173        IconName::PanelLeftOpen,
1174        IconName::PanelRight,
1175        IconName::PanelRightClose,
1176        IconName::PanelRightOpen,
1177        IconName::Plus,
1178        IconName::Redo,
1179        IconName::Redo2,
1180        IconName::Replace,
1181        IconName::ResizeCorner,
1182        IconName::Search,
1183        IconName::Settings,
1184        IconName::Settings2,
1185        IconName::SortAscending,
1186        IconName::SortDescending,
1187        IconName::SquareTerminal,
1188        IconName::Star,
1189        IconName::StarOff,
1190        IconName::Sun,
1191        IconName::ThumbsDown,
1192        IconName::ThumbsUp,
1193        IconName::TriangleAlert,
1194        IconName::Undo,
1195        IconName::Undo2,
1196        IconName::User,
1197        IconName::WindowClose,
1198        IconName::WindowMaximize,
1199        IconName::WindowMinimize,
1200        IconName::WindowRestore,
1201    ];
1202
1203    #[test]
1204    fn all_icons_have_lucide_mapping() {
1205        for icon in ALL_ICON_NAMES {
1206            let name = lucide_name_for_gpui_icon(icon.clone());
1207            assert!(
1208                !name.is_empty(),
1209                "Empty Lucide mapping for an IconName variant",
1210            );
1211        }
1212    }
1213
1214    #[test]
1215    fn all_icons_have_material_mapping() {
1216        for icon in ALL_ICON_NAMES {
1217            let name = material_name_for_gpui_icon(icon.clone());
1218            assert!(
1219                !name.is_empty(),
1220                "Empty Material mapping for an IconName variant",
1221            );
1222        }
1223    }
1224
1225    // --- icon_name tests ---
1226
1227    #[test]
1228    fn icon_name_dialog_warning_maps_to_triangle_alert() {
1229        assert!(matches!(
1230            icon_name(IconRole::DialogWarning),
1231            Some(IconName::TriangleAlert)
1232        ));
1233    }
1234
1235    #[test]
1236    fn icon_name_dialog_error_maps_to_circle_x() {
1237        assert!(matches!(
1238            icon_name(IconRole::DialogError),
1239            Some(IconName::CircleX)
1240        ));
1241    }
1242
1243    #[test]
1244    fn icon_name_dialog_info_maps_to_info() {
1245        assert!(matches!(
1246            icon_name(IconRole::DialogInfo),
1247            Some(IconName::Info)
1248        ));
1249    }
1250
1251    #[test]
1252    fn icon_name_dialog_success_maps_to_circle_check() {
1253        assert!(matches!(
1254            icon_name(IconRole::DialogSuccess),
1255            Some(IconName::CircleCheck)
1256        ));
1257    }
1258
1259    #[test]
1260    fn icon_name_window_close_maps() {
1261        assert!(matches!(
1262            icon_name(IconRole::WindowClose),
1263            Some(IconName::WindowClose)
1264        ));
1265    }
1266
1267    #[test]
1268    fn icon_name_action_copy_maps_to_copy() {
1269        assert!(matches!(
1270            icon_name(IconRole::ActionCopy),
1271            Some(IconName::Copy)
1272        ));
1273    }
1274
1275    #[test]
1276    fn icon_name_nav_back_maps_to_chevron_left() {
1277        assert!(matches!(
1278            icon_name(IconRole::NavBack),
1279            Some(IconName::ChevronLeft)
1280        ));
1281    }
1282
1283    #[test]
1284    fn icon_name_file_generic_maps_to_file() {
1285        assert!(matches!(
1286            icon_name(IconRole::FileGeneric),
1287            Some(IconName::File)
1288        ));
1289    }
1290
1291    #[test]
1292    fn icon_name_status_check_maps_to_check() {
1293        assert!(matches!(
1294            icon_name(IconRole::StatusCheck),
1295            Some(IconName::Check)
1296        ));
1297    }
1298
1299    #[test]
1300    fn icon_name_user_account_maps_to_user() {
1301        assert!(matches!(
1302            icon_name(IconRole::UserAccount),
1303            Some(IconName::User)
1304        ));
1305    }
1306
1307    #[test]
1308    fn icon_name_notification_maps_to_bell() {
1309        assert!(matches!(
1310            icon_name(IconRole::Notification),
1311            Some(IconName::Bell)
1312        ));
1313    }
1314
1315    // None cases
1316    #[test]
1317    fn icon_name_shield_returns_none() {
1318        assert!(icon_name(IconRole::Shield).is_none());
1319    }
1320
1321    #[test]
1322    fn icon_name_lock_returns_none() {
1323        assert!(icon_name(IconRole::Lock).is_none());
1324    }
1325
1326    #[test]
1327    fn icon_name_action_save_returns_none() {
1328        assert!(icon_name(IconRole::ActionSave).is_none());
1329    }
1330
1331    #[test]
1332    fn icon_name_help_returns_none() {
1333        assert!(icon_name(IconRole::Help).is_none());
1334    }
1335
1336    #[test]
1337    fn icon_name_dialog_question_returns_none() {
1338        assert!(icon_name(IconRole::DialogQuestion).is_none());
1339    }
1340
1341    // Count test: at least 28 roles map to Some
1342    #[test]
1343    fn icon_name_maps_at_least_28_roles() {
1344        let some_count = IconRole::ALL
1345            .iter()
1346            .filter(|r| icon_name(**r).is_some())
1347            .count();
1348        assert!(
1349            some_count >= 28,
1350            "Expected at least 28 mappings, got {}",
1351            some_count
1352        );
1353    }
1354
1355    #[test]
1356    fn icon_name_maps_exactly_30_roles() {
1357        let some_count = IconRole::ALL
1358            .iter()
1359            .filter(|r| icon_name(**r).is_some())
1360            .count();
1361        assert_eq!(
1362            some_count, 30,
1363            "Expected exactly 30 mappings, got {some_count}"
1364        );
1365    }
1366
1367    // --- to_image_source tests ---
1368
1369    #[test]
1370    fn to_image_source_svg_returns_bmp_rasterized() {
1371        // Valid SVG that resvg can parse
1372        let svg = IconData::Svg(
1373            b"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><circle cx='12' cy='12' r='10' fill='red'/></svg>".to_vec(),
1374        );
1375        let source = to_image_source(&svg, None, None).expect("valid SVG should convert");
1376        // SVGs are rasterized to BMP to work around gpui's RGBA/BGRA bug
1377        match source {
1378            ImageSource::Image(arc) => {
1379                assert_eq!(arc.format, ImageFormat::Bmp);
1380                assert!(arc.bytes.starts_with(b"BM"), "BMP should start with 'BM'");
1381            }
1382            _ => panic!("Expected ImageSource::Image for SVG data"),
1383        }
1384    }
1385
1386    #[test]
1387    fn to_image_source_rgba_returns_bmp_image_source() {
1388        let rgba = IconData::Rgba {
1389            width: 2,
1390            height: 2,
1391            data: vec![
1392                255, 0, 0, 255, // red
1393                0, 255, 0, 255, // green
1394                0, 0, 255, 255, // blue
1395                255, 255, 0, 255, // yellow
1396            ],
1397        };
1398        let source = to_image_source(&rgba, None, None).expect("RGBA should convert");
1399        match source {
1400            ImageSource::Image(arc) => {
1401                assert_eq!(arc.format, ImageFormat::Bmp);
1402                // BMP header starts with "BM"
1403                assert_eq!(&arc.bytes[0..2], b"BM");
1404            }
1405            _ => panic!("Expected ImageSource::Image for RGBA data"),
1406        }
1407    }
1408
1409    #[test]
1410    fn to_image_source_with_color() {
1411        let svg = IconData::Svg(
1412            b"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M0 0' stroke='currentColor'/></svg>".to_vec(),
1413        );
1414        let color = gpui::hsla(0.0, 1.0, 0.5, 1.0);
1415        let result = to_image_source(&svg, Some(color), None);
1416        assert!(result.is_some(), "colorized SVG should convert");
1417    }
1418
1419    #[test]
1420    fn to_image_source_with_custom_size() {
1421        let svg = IconData::Svg(
1422            b"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><circle cx='12' cy='12' r='10' fill='red'/></svg>".to_vec(),
1423        );
1424        let result = to_image_source(&svg, None, Some(32));
1425        assert!(result.is_some(), "custom size SVG should convert");
1426    }
1427
1428    // --- BMP encoding tests ---
1429
1430    #[test]
1431    fn encode_rgba_as_bmp_correct_file_size() {
1432        let rgba = vec![0u8; 4 * 4 * 4]; // 4x4 image
1433        let bmp = encode_rgba_as_bmp(4, 4, &rgba).expect("valid input");
1434        let expected_size = 14 + 108 + (4 * 4 * 4); // header + dib + pixels
1435        assert_eq!(bmp.len(), expected_size);
1436    }
1437
1438    #[test]
1439    fn encode_rgba_as_bmp_starts_with_bm() {
1440        let rgba = vec![0u8; 4]; // 1x1 image
1441        let bmp = encode_rgba_as_bmp(1, 1, &rgba).expect("valid input");
1442        assert_eq!(&bmp[0..2], b"BM");
1443    }
1444
1445    #[test]
1446    fn encode_rgba_as_bmp_pixel_order_is_bgra() {
1447        // Input RGBA: R=0xAA, G=0xBB, B=0xCC, A=0xDD
1448        let rgba = vec![0xAA, 0xBB, 0xCC, 0xDD];
1449        let bmp = encode_rgba_as_bmp(1, 1, &rgba).expect("valid input");
1450        let pixel_offset = (14 + 108) as usize;
1451        // BMP stores as BGRA
1452        assert_eq!(bmp[pixel_offset], 0xCC); // B
1453        assert_eq!(bmp[pixel_offset + 1], 0xBB); // G
1454        assert_eq!(bmp[pixel_offset + 2], 0xAA); // R
1455        assert_eq!(bmp[pixel_offset + 3], 0xDD); // A
1456    }
1457
1458    #[test]
1459    fn encode_rgba_as_bmp_zero_width_returns_none() {
1460        let rgba = vec![0u8; 4];
1461        assert!(encode_rgba_as_bmp(0, 1, &rgba).is_none());
1462    }
1463
1464    #[test]
1465    fn encode_rgba_as_bmp_zero_height_returns_none() {
1466        let rgba = vec![0u8; 4];
1467        assert!(encode_rgba_as_bmp(1, 0, &rgba).is_none());
1468    }
1469
1470    #[test]
1471    fn encode_rgba_as_bmp_mismatched_length_returns_none() {
1472        // 2x2 image expects 16 bytes, provide 12
1473        let rgba = vec![0u8; 12];
1474        assert!(encode_rgba_as_bmp(2, 2, &rgba).is_none());
1475    }
1476
1477    #[test]
1478    fn encode_rgba_as_bmp_oversized_length_returns_none() {
1479        // 2x2 image expects 16 bytes, provide 20
1480        let rgba = vec![0u8; 20];
1481        assert!(encode_rgba_as_bmp(2, 2, &rgba).is_none());
1482    }
1483    // --- colorize_svg tests ---
1484
1485    #[test]
1486    fn colorize_svg_replaces_fill_black() {
1487        let svg = b"<svg><path fill=\"black\" d=\"M0 0h24v24H0z\"/></svg>";
1488        let color = gpui::hsla(0.6, 0.7, 0.5, 1.0); // a blue-ish color
1489        let result = colorize_svg(svg, color);
1490        let result_str = String::from_utf8(result).unwrap();
1491        assert!(
1492            !result_str.contains("fill=\"black\""),
1493            "fill=\"black\" should be replaced, got: {}",
1494            result_str
1495        );
1496        assert!(
1497            result_str.contains("fill=\"#"),
1498            "should contain hex fill, got: {}",
1499            result_str
1500        );
1501    }
1502
1503    #[test]
1504    fn colorize_svg_replaces_fill_hex_black() {
1505        let svg = b"<svg><rect fill=\"#000000\" width=\"24\" height=\"24\"/></svg>";
1506        let color = gpui::hsla(0.0, 1.0, 0.5, 1.0); // red
1507        let result = colorize_svg(svg, color);
1508        let result_str = String::from_utf8(result).unwrap();
1509        assert!(
1510            !result_str.contains("#000000"),
1511            "fill=\"#000000\" should be replaced, got: {}",
1512            result_str
1513        );
1514    }
1515
1516    #[test]
1517    fn colorize_svg_replaces_fill_short_hex_black() {
1518        let svg = b"<svg><rect fill=\"#000\" width=\"24\" height=\"24\"/></svg>";
1519        let color = gpui::hsla(0.3, 0.8, 0.4, 1.0); // green
1520        let result = colorize_svg(svg, color);
1521        let result_str = String::from_utf8(result).unwrap();
1522        assert!(
1523            !result_str.contains("fill=\"#000\""),
1524            "fill=\"#000\" should be replaced, got: {}",
1525            result_str
1526        );
1527    }
1528
1529    #[test]
1530    fn colorize_svg_current_color_still_works() {
1531        let svg = b"<svg><path stroke=\"currentColor\" d=\"M0 0\"/></svg>";
1532        let color = gpui::hsla(0.0, 1.0, 0.5, 1.0);
1533        let result = colorize_svg(svg, color);
1534        let result_str = String::from_utf8(result).unwrap();
1535        assert!(
1536            !result_str.contains("currentColor"),
1537            "currentColor should be replaced"
1538        );
1539        assert!(result_str.contains('#'), "should contain hex color");
1540    }
1541
1542    #[test]
1543    fn colorize_svg_implicit_black_still_works() {
1544        // SVG with no fill attribute at all (Material-style)
1545        let svg = b"<svg xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M0 0\"/></svg>";
1546        let color = gpui::hsla(0.0, 1.0, 0.5, 1.0);
1547        let result = colorize_svg(svg, color);
1548        let result_str = String::from_utf8(result).unwrap();
1549        assert!(
1550            result_str.contains("fill=\"#"),
1551            "should inject fill into root svg tag, got: {}",
1552            result_str
1553        );
1554    }
1555
1556    #[test]
1557    fn colorize_svg_non_utf8_returns_original() {
1558        // Non-UTF-8 bytes: valid SVG prefix followed by invalid byte sequence
1559        let mut svg = b"<svg><path fill=\"black\" d=\"M0 0\"/>".to_vec();
1560        svg.push(0xFF); // invalid UTF-8 byte
1561        svg.extend_from_slice(b"</svg>");
1562        let color = gpui::hsla(0.0, 1.0, 0.5, 1.0);
1563        let result = colorize_svg(&svg, color);
1564        assert_eq!(result, svg, "non-UTF-8 input should be returned unchanged");
1565    }
1566
1567    #[test]
1568    fn colorize_self_closing_svg_produces_valid_xml() {
1569        // Self-closing <svg .../> tag — fill must be injected before '/'
1570        let svg = b"<svg xmlns=\"http://www.w3.org/2000/svg\" />";
1571        let color = gpui::hsla(0.0, 1.0, 0.5, 1.0);
1572        let result = colorize_svg(svg, color);
1573        let result_str = String::from_utf8(result).unwrap();
1574        assert!(
1575            result_str.contains("fill=\"#"),
1576            "should inject fill, got: {}",
1577            result_str
1578        );
1579        // Must NOT produce '/ fill=' (broken XML)
1580        assert!(
1581            !result_str.contains("/ fill="),
1582            "fill must be before '/', got: {}",
1583            result_str
1584        );
1585        // Must end with '/>' (valid self-closing)
1586        assert!(
1587            result_str.trim().ends_with("/>"),
1588            "should remain self-closing, got: {}",
1589            result_str
1590        );
1591    }
1592
1593    // --- into_image_source tests ---
1594
1595    #[test]
1596    fn into_image_source_svg_returns_some() {
1597        let svg = IconData::Svg(
1598            b"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><circle cx='12' cy='12' r='10' fill='red'/></svg>".to_vec(),
1599        );
1600        let result = into_image_source(svg, None, None);
1601        assert!(result.is_some(), "valid SVG should convert");
1602    }
1603
1604    #[test]
1605    fn into_image_source_rgba_returns_some() {
1606        let rgba = IconData::Rgba {
1607            width: 2,
1608            height: 2,
1609            data: vec![
1610                255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 0, 255,
1611            ],
1612        };
1613        let result = into_image_source(rgba, None, None);
1614        assert!(result.is_some(), "RGBA should convert");
1615    }
1616
1617    #[test]
1618    fn into_image_source_with_color() {
1619        let svg = IconData::Svg(
1620            b"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M0 0' stroke='currentColor'/></svg>".to_vec(),
1621        );
1622        let color = gpui::hsla(0.0, 1.0, 0.5, 1.0);
1623        let result = into_image_source(svg, Some(color), None);
1624        assert!(result.is_some(), "colorized SVG should convert");
1625    }
1626
1627    // --- custom_icon tests ---
1628
1629    // Test helper: minimal IconProvider that returns a bundled SVG
1630    #[derive(Debug)]
1631    struct TestCustomIcon;
1632
1633    impl native_theme::IconProvider for TestCustomIcon {
1634        fn icon_name(&self, _set: native_theme::IconSet) -> Option<&str> {
1635            None // No system name -- forces bundled SVG path
1636        }
1637        fn icon_svg(&self, _set: native_theme::IconSet) -> Option<&'static [u8]> {
1638            Some(b"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><circle cx='12' cy='12' r='10'/></svg>")
1639        }
1640    }
1641
1642    // Provider with no mappings at all
1643    #[derive(Debug)]
1644    struct EmptyProvider;
1645
1646    impl native_theme::IconProvider for EmptyProvider {
1647        fn icon_name(&self, _set: native_theme::IconSet) -> Option<&str> {
1648            None
1649        }
1650        fn icon_svg(&self, _set: native_theme::IconSet) -> Option<&'static [u8]> {
1651            None
1652        }
1653    }
1654
1655    #[test]
1656    fn custom_icon_to_image_source_with_svg_provider_returns_some() {
1657        let result = custom_icon_to_image_source(
1658            &TestCustomIcon,
1659            native_theme::IconSet::Material,
1660            None,
1661            None,
1662        );
1663        assert!(result.is_some());
1664    }
1665
1666    #[test]
1667    fn custom_icon_to_image_source_with_empty_provider_returns_none() {
1668        let result = custom_icon_to_image_source(
1669            &EmptyProvider,
1670            native_theme::IconSet::Material,
1671            None,
1672            None,
1673        );
1674        assert!(result.is_none());
1675    }
1676
1677    #[test]
1678    fn custom_icon_to_image_source_with_color() {
1679        let color = Hsla {
1680            h: 0.0,
1681            s: 1.0,
1682            l: 0.5,
1683            a: 1.0,
1684        };
1685        let result = custom_icon_to_image_source(
1686            &TestCustomIcon,
1687            native_theme::IconSet::Material,
1688            Some(color),
1689            None,
1690        );
1691        assert!(result.is_some());
1692    }
1693
1694    #[test]
1695    fn custom_icon_to_image_source_accepts_dyn_provider() {
1696        let boxed: Box<dyn native_theme::IconProvider> = Box::new(TestCustomIcon);
1697        let result =
1698            custom_icon_to_image_source(&*boxed, native_theme::IconSet::Material, None, None);
1699        assert!(result.is_some());
1700    }
1701
1702    // --- bundled_icon_to_image_source tests ---
1703
1704    #[test]
1705    fn bundled_icon_lucide_returns_some() {
1706        let result = bundled_icon_to_image_source(
1707            IconName::Search,
1708            native_theme::IconSet::Lucide,
1709            None,
1710            None,
1711        );
1712        assert!(result.is_some(), "Lucide search icon should convert");
1713    }
1714
1715    #[test]
1716    fn bundled_icon_material_returns_some() {
1717        let result = bundled_icon_to_image_source(
1718            IconName::Search,
1719            native_theme::IconSet::Material,
1720            None,
1721            None,
1722        );
1723        assert!(result.is_some(), "Material search icon should convert");
1724    }
1725
1726    #[test]
1727    fn bundled_icon_freedesktop_returns_none() {
1728        let result = bundled_icon_to_image_source(
1729            IconName::Search,
1730            native_theme::IconSet::Freedesktop,
1731            None,
1732            None,
1733        );
1734        assert!(
1735            result.is_none(),
1736            "Freedesktop is not bundled -- should return None"
1737        );
1738    }
1739
1740    #[test]
1741    fn bundled_icon_with_color() {
1742        let color = Hsla {
1743            h: 0.0,
1744            s: 1.0,
1745            l: 0.5,
1746            a: 1.0,
1747        };
1748        let result = bundled_icon_to_image_source(
1749            IconName::Check,
1750            native_theme::IconSet::Lucide,
1751            Some(color),
1752            None,
1753        );
1754        assert!(result.is_some(), "colorized bundled icon should convert");
1755    }
1756
1757    // --- animated icon tests ---
1758
1759    #[test]
1760    fn animated_frames_returns_sources() {
1761        let anim = AnimatedIcon::Frames {
1762            frames: vec![
1763                IconData::Svg(b"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><circle cx='12' cy='12' r='10' fill='red'/></svg>".to_vec()),
1764                IconData::Svg(b"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><circle cx='12' cy='12' r='8' fill='blue'/></svg>".to_vec()),
1765                IconData::Svg(b"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><circle cx='12' cy='12' r='6' fill='green'/></svg>".to_vec()),
1766            ],
1767            frame_duration_ms: 80,
1768        };
1769        let result = animated_frames_to_image_sources(&anim, None, None);
1770        let ais = result.expect("Frames variant should return Some");
1771        assert_eq!(ais.sources.len(), 3);
1772        assert_eq!(ais.frame_duration_ms, 80);
1773    }
1774
1775    #[test]
1776    fn animated_frames_transform_returns_none() {
1777        let anim = AnimatedIcon::Transform {
1778            icon: IconData::Svg(
1779                b"<svg xmlns='http://www.w3.org/2000/svg'><circle cx='12' cy='12' r='10'/></svg>"
1780                    .to_vec(),
1781            ),
1782            animation: native_theme::TransformAnimation::Spin { duration_ms: 1000 },
1783        };
1784        let result = animated_frames_to_image_sources(&anim, None, None);
1785        assert!(result.is_none());
1786    }
1787
1788    #[test]
1789    fn animated_frames_empty_returns_none() {
1790        let anim = AnimatedIcon::Frames {
1791            frames: vec![],
1792            frame_duration_ms: 80,
1793        };
1794        let result = animated_frames_to_image_sources(&anim, None, None);
1795        assert!(result.is_none());
1796    }
1797
1798    #[test]
1799    fn spin_animation_constructs_without_context() {
1800        let svg_element = gpui::svg();
1801        // with_spin_animation wraps an Svg element with continuous rotation.
1802        // This is pure construction -- no gpui render context needed.
1803        let _animated = with_spin_animation(svg_element, "test-spin", 1000);
1804    }
1805}
1806
1807#[cfg(test)]
1808#[cfg(target_os = "linux")]
1809#[allow(clippy::unwrap_used, clippy::expect_used)]
1810mod freedesktop_mapping_tests {
1811    use super::tests::ALL_ICON_NAMES;
1812    use super::*;
1813    use native_theme::LinuxDesktop;
1814
1815    #[test]
1816    fn all_86_gpui_icons_have_mapping_on_kde() {
1817        for name in ALL_ICON_NAMES {
1818            let fd_name = freedesktop_name_for_gpui_icon(name.clone(), LinuxDesktop::Kde);
1819            assert!(
1820                !fd_name.is_empty(),
1821                "Empty KDE freedesktop mapping for an IconName variant",
1822            );
1823        }
1824    }
1825
1826    #[test]
1827    fn eye_differs_by_de() {
1828        assert_eq!(
1829            freedesktop_name_for_gpui_icon(IconName::Eye, LinuxDesktop::Kde),
1830            "view-visible",
1831        );
1832        assert_eq!(
1833            freedesktop_name_for_gpui_icon(IconName::Eye, LinuxDesktop::Gnome),
1834            "view-reveal",
1835        );
1836    }
1837
1838    #[test]
1839    fn freedesktop_standard_ignores_de() {
1840        // edit-copy is freedesktop standard — same for all DEs
1841        assert_eq!(
1842            freedesktop_name_for_gpui_icon(IconName::Copy, LinuxDesktop::Kde),
1843            freedesktop_name_for_gpui_icon(IconName::Copy, LinuxDesktop::Gnome),
1844        );
1845    }
1846
1847    #[test]
1848    fn all_86_gpui_icons_have_mapping_on_gnome() {
1849        for name in ALL_ICON_NAMES {
1850            let fd_name = freedesktop_name_for_gpui_icon(name.clone(), LinuxDesktop::Gnome);
1851            assert!(
1852                !fd_name.is_empty(),
1853                "Empty GNOME freedesktop mapping for an IconName variant",
1854            );
1855        }
1856    }
1857
1858    #[test]
1859    fn xfce_uses_gnome_names() {
1860        // XFCE is GTK-based and should use GNOME naming convention
1861        assert_eq!(
1862            freedesktop_name_for_gpui_icon(IconName::Eye, LinuxDesktop::Xfce),
1863            "view-reveal",
1864        );
1865        assert_eq!(
1866            freedesktop_name_for_gpui_icon(IconName::Bell, LinuxDesktop::Xfce),
1867            "alarm",
1868        );
1869    }
1870
1871    #[test]
1872    fn all_kde_names_resolve_in_breeze() {
1873        let theme = native_theme::system_icon_theme();
1874        // Only meaningful on a KDE system with Breeze installed
1875        if !theme.to_lowercase().contains("breeze") {
1876            eprintln!("Skipping: system theme is '{}', not Breeze", theme);
1877            return;
1878        }
1879
1880        let mut missing = Vec::new();
1881        for name in ALL_ICON_NAMES {
1882            let fd_name = freedesktop_name_for_gpui_icon(name.clone(), LinuxDesktop::Kde);
1883            if native_theme::load_freedesktop_icon_by_name(fd_name, theme, 24).is_none() {
1884                missing.push(format!("{} (not found)", fd_name));
1885            }
1886        }
1887        assert!(
1888            missing.is_empty(),
1889            "These gpui icons did not resolve in Breeze:\n  {}",
1890            missing.join("\n  "),
1891        );
1892    }
1893
1894    #[test]
1895    fn gnome_names_resolve_in_adwaita() {
1896        // Verify GNOME mappings resolve against installed Adwaita theme.
1897        // Only runs when Adwaita is installed (it usually is on any Linux).
1898        let mut missing = Vec::new();
1899        for name in ALL_ICON_NAMES {
1900            let fd_name = freedesktop_name_for_gpui_icon(name.clone(), LinuxDesktop::Gnome);
1901            if native_theme::load_freedesktop_icon_by_name(fd_name, "Adwaita", 24).is_none() {
1902                missing.push(format!("{} (not found)", fd_name));
1903            }
1904        }
1905        assert!(
1906            missing.is_empty(),
1907            "These GNOME mappings did not resolve in Adwaita:\n  {}",
1908            missing.join("\n  "),
1909        );
1910    }
1911}