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