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