Skip to main content

herogpui_components/
icon.rs

1//! A named, embedded Lucide icon set (HeroGPUI extension, not a HeroUI v3
2//! API).
3//!
4//! [`IconName`] names a curated subset of [Lucide](https://lucide.dev)
5//! icons, and [`Icon`] draws one at a size and colour. The SVGs are copied
6//! verbatim from the official `lucide-static` [`LUCIDE_VERSION`] release
7//! into this crate's `assets/lucide/` and compiled in with `include_bytes!`,
8//! so [`HeroGpuiAssets`](crate::HeroGpuiAssets) serves every variant with no
9//! file or feature to set up:
10//!
11//! ```
12//! use herogpui_components::{Icon, IconName, IconSize};
13//! use gpui::{px, rgb};
14//!
15//! let _search = Icon::new(IconName::Search);
16//! let _big_red = Icon::new(IconName::Heart).size(px(24.)).color(rgb(0xe11d48));
17//! // A size step (`Sizable`), and Lucide's stroke controls.
18//! let _thin = Icon::new(IconName::Star)
19//!     .size(IconSize::Lg)
20//!     .stroke_width(1.5)
21//!     .absolute_stroke_width(true);
22//!
23//! // Any builder that takes an icon path takes a name too.
24//! let path: gpui::SharedString = IconName::Folder.into();
25//! assert_eq!(path.as_ref(), "herogpui/icons/lucide/folder.svg");
26//! ```
27//!
28//! The chrome paths in [`icons`](crate::icons) are separate and unchanged:
29//! those are HeroUI's own glyphs, drawn by the components, and keep their
30//! `herogpui/icons/<name>.svg` paths. Lucide icons live one level down, under
31//! [`LUCIDE_ICON_PREFIX`], so the two sets never collide.
32//!
33//! Lucide is ISC-licensed, and a few of its icons derive from Feather (MIT);
34//! the full text ships beside the SVGs as `assets/lucide/LICENSE` and the
35//! attribution is in this crate's `NOTICE`.
36//!
37//! # Stroke width
38//!
39//! gpui rasterises an SVG as one alpha mask painted in a single colour, so
40//! a stroke width cannot be styled after the fact. [`Icon::stroke_width`]
41//! therefore picks a different asset: [`IconName::path_with_stroke_width`]
42//! appends `?stroke-width=<w>` to the icon's path, and
43//! [`HeroGpuiAssets`](crate::HeroGpuiAssets) serves that path as the same
44//! Lucide file with its root `stroke-width` rewritten. gpui caches the
45//! rasterised mask per path and size, so each width is rendered once. It
46//! applies to the Lucide set only: an [`Icon::from_path`] SVG is drawn as its
47//! asset source serves it, and an application that serves Lucide paths from
48//! its own asset source first must forward the query to `HeroGpuiAssets`.
49//!
50//! # Adding an icon
51//!
52//! The set is the list in `.shots/lucide-icons.txt`. Add a name there and run
53//! `python3 .shots/sync-lucide.py`: it downloads the pinned `lucide-static`
54//! tarball (checking its pinned SHA-512), copies the listed icons byte for
55//! byte into `assets/lucide/` with the license, and regenerates the variant
56//! list below and [`LUCIDE_VERSION`]. `python3 .shots/sync-lucide.py --check`
57//! (run by CI's parity job) fails when the list, the files and this module
58//! disagree; `tests/icon.rs` fails for a variant without a file and for a
59//! file without a variant.
60
61use gpui::{prelude::*, px, svg, App, Hsla, IntoElement, Pixels, RenderOnce, SharedString, Window};
62use herogpui_theme::ActiveTheme;
63
64/// The `lucide-static` release the embedded SVGs were copied from.
65pub const LUCIDE_VERSION: &str = "1.31.0";
66
67/// The asset-path directory every [`IconName::path`] lives in.
68pub const LUCIDE_ICON_PREFIX: &str = "herogpui/icons/lucide";
69
70/// The size [`Icon`] draws at unless told otherwise — the 16px the
71/// components' own chrome icons use.
72pub const DEFAULT_ICON_SIZE: Pixels = px(16.);
73
74/// The stroke width, in the icons' 24-unit `viewBox`, that every Lucide SVG
75/// ships with (Lucide's `strokeWidth` default).
76pub const DEFAULT_STROKE_WIDTH: f32 = 2.;
77
78/// An [`Icon`] size step, for [`Sizable`](crate::Sizable) (HeroGPUI
79/// extension). The steps are the Tailwind `size-*` squares icons take in
80/// HeroUI's own markup: `Xs` 12px, `Sm` 14px, `Md` 16px (the default,
81/// [`DEFAULT_ICON_SIZE`]), `Lg` 20px and `Xl` 24px. It converts into
82/// [`Pixels`], so [`Icon::size`] takes a step or any pixel length.
83#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
84#[non_exhaustive]
85pub enum IconSize {
86    /// 12px (`size-3`).
87    Xs,
88    /// 14px (`size-3.5`).
89    Sm,
90    /// 16px (`size-4`), the default.
91    #[default]
92    Md,
93    /// 20px (`size-5`).
94    Lg,
95    /// 24px (`size-6`), Lucide's own default.
96    Xl,
97}
98
99impl IconSize {
100    /// Every step, smallest first.
101    pub const ALL: [IconSize; 5] = [
102        IconSize::Xs,
103        IconSize::Sm,
104        IconSize::Md,
105        IconSize::Lg,
106        IconSize::Xl,
107    ];
108
109    /// The step's width and height.
110    pub const fn pixels(self) -> Pixels {
111        match self {
112            IconSize::Xs => px(12.),
113            IconSize::Sm => px(14.),
114            IconSize::Md => px(16.),
115            IconSize::Lg => px(20.),
116            IconSize::Xl => px(24.),
117        }
118    }
119}
120
121impl From<IconSize> for Pixels {
122    fn from(size: IconSize) -> Self {
123        size.pixels()
124    }
125}
126
127macro_rules! lucide_icons {
128    ($($variant:ident => $name:literal),* $(,)?) => {
129        /// A Lucide icon embedded in this crate (HeroGPUI extension).
130        ///
131        /// Each variant is the Lucide icon of the same name in PascalCase
132        /// (`circle-check` is [`IconName::CircleCheck`]). The set is
133        /// curated, not all of Lucide, and grows in minor releases, which is
134        /// why the enum is `#[non_exhaustive]`.
135        #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
136        #[non_exhaustive]
137        pub enum IconName {
138            $(
139                #[doc = concat!("Lucide `", $name, "`.")]
140                $variant,
141            )*
142        }
143
144        impl IconName {
145            /// Every icon, in name order.
146            pub const ALL: &'static [IconName] = &[$(IconName::$variant),*];
147
148            /// Lucide's own kebab-case name, e.g. `"circle-check"`.
149            pub const fn name(self) -> &'static str {
150                match self {
151                    $(IconName::$variant => $name,)*
152                }
153            }
154
155            /// The asset path [`HeroGpuiAssets`](crate::HeroGpuiAssets)
156            /// serves this icon under, e.g.
157            /// `"herogpui/icons/lucide/circle-check.svg"`.
158            pub const fn path(self) -> &'static str {
159                match self {
160                    $(IconName::$variant => concat!("herogpui/icons/lucide/", $name, ".svg"),)*
161                }
162            }
163
164            /// The embedded SVG document, byte-identical to `lucide-static`.
165            pub const fn svg(self) -> &'static [u8] {
166                match self {
167                    $(IconName::$variant => include_bytes!(concat!("../assets/lucide/", $name, ".svg")),)*
168                }
169            }
170        }
171    };
172}
173
174lucide_icons! {
175    Activity => "activity",
176    AlarmClock => "alarm-clock",
177    Archive => "archive",
178    ArrowDown => "arrow-down",
179    ArrowDownLeft => "arrow-down-left",
180    ArrowDownRight => "arrow-down-right",
181    ArrowDownToLine => "arrow-down-to-line",
182    ArrowLeft => "arrow-left",
183    ArrowLeftRight => "arrow-left-right",
184    ArrowRight => "arrow-right",
185    ArrowUp => "arrow-up",
186    ArrowUpDown => "arrow-up-down",
187    ArrowUpLeft => "arrow-up-left",
188    ArrowUpRight => "arrow-up-right",
189    ArrowUpToLine => "arrow-up-to-line",
190    AtSign => "at-sign",
191    Ban => "ban",
192    Battery => "battery",
193    Bell => "bell",
194    BellOff => "bell-off",
195    Bold => "bold",
196    Book => "book",
197    BookOpen => "book-open",
198    Bookmark => "bookmark",
199    Bot => "bot",
200    Box => "box",
201    Briefcase => "briefcase",
202    Bug => "bug",
203    Building => "building",
204    Calendar => "calendar",
205    CalendarDays => "calendar-days",
206    Camera => "camera",
207    ChartBar => "chart-bar",
208    ChartColumn => "chart-column",
209    ChartLine => "chart-line",
210    ChartPie => "chart-pie",
211    Check => "check",
212    CheckCheck => "check-check",
213    ChevronDown => "chevron-down",
214    ChevronLeft => "chevron-left",
215    ChevronRight => "chevron-right",
216    ChevronUp => "chevron-up",
217    ChevronsDown => "chevrons-down",
218    ChevronsLeft => "chevrons-left",
219    ChevronsRight => "chevrons-right",
220    ChevronsUp => "chevrons-up",
221    ChevronsUpDown => "chevrons-up-down",
222    Circle => "circle",
223    CircleAlert => "circle-alert",
224    CircleCheck => "circle-check",
225    CircleDot => "circle-dot",
226    CircleHelp => "circle-help",
227    CircleMinus => "circle-minus",
228    CirclePlus => "circle-plus",
229    CircleUser => "circle-user",
230    CircleX => "circle-x",
231    Clipboard => "clipboard",
232    ClipboardCheck => "clipboard-check",
233    ClipboardCopy => "clipboard-copy",
234    Clock => "clock",
235    Cloud => "cloud",
236    CloudDownload => "cloud-download",
237    CloudUpload => "cloud-upload",
238    Code => "code",
239    CodeXml => "code-xml",
240    Columns2 => "columns-2",
241    Command => "command",
242    Compass => "compass",
243    Contrast => "contrast",
244    Copy => "copy",
245    CornerDownLeft => "corner-down-left",
246    Cpu => "cpu",
247    CreditCard => "credit-card",
248    Crop => "crop",
249    Database => "database",
250    DollarSign => "dollar-sign",
251    Download => "download",
252    Droplet => "droplet",
253    Ellipsis => "ellipsis",
254    EllipsisVertical => "ellipsis-vertical",
255    Expand => "expand",
256    ExternalLink => "external-link",
257    Eye => "eye",
258    EyeOff => "eye-off",
259    File => "file",
260    FileCode => "file-code",
261    FilePlus => "file-plus",
262    FileText => "file-text",
263    Files => "files",
264    Film => "film",
265    Flag => "flag",
266    Folder => "folder",
267    FolderOpen => "folder-open",
268    FolderPlus => "folder-plus",
269    Funnel => "funnel",
270    Gift => "gift",
271    GitBranch => "git-branch",
272    GitCommitHorizontal => "git-commit-horizontal",
273    GitMerge => "git-merge",
274    GitPullRequest => "git-pull-request",
275    Globe => "globe",
276    GraduationCap => "graduation-cap",
277    Grid2x2 => "grid-2x2",
278    GripHorizontal => "grip-horizontal",
279    GripVertical => "grip-vertical",
280    HardDrive => "hard-drive",
281    Hash => "hash",
282    Headphones => "headphones",
283    Heart => "heart",
284    History => "history",
285    House => "house",
286    Image => "image",
287    Inbox => "inbox",
288    Info => "info",
289    Italic => "italic",
290    Key => "key",
291    Keyboard => "keyboard",
292    Languages => "languages",
293    Laptop => "laptop",
294    Layers => "layers",
295    LayoutDashboard => "layout-dashboard",
296    LayoutGrid => "layout-grid",
297    LayoutList => "layout-list",
298    Lightbulb => "lightbulb",
299    Link => "link",
300    Link2 => "link-2",
301    List => "list",
302    ListChecks => "list-checks",
303    ListFilter => "list-filter",
304    ListOrdered => "list-ordered",
305    Loader => "loader",
306    LoaderCircle => "loader-circle",
307    Lock => "lock",
308    LockOpen => "lock-open",
309    LogIn => "log-in",
310    LogOut => "log-out",
311    Mail => "mail",
312    Map => "map",
313    MapPin => "map-pin",
314    Maximize => "maximize",
315    Maximize2 => "maximize-2",
316    Menu => "menu",
317    MessageCircle => "message-circle",
318    MessageSquare => "message-square",
319    Mic => "mic",
320    MicOff => "mic-off",
321    Minimize => "minimize",
322    Minimize2 => "minimize-2",
323    Minus => "minus",
324    Monitor => "monitor",
325    Moon => "moon",
326    MousePointer => "mouse-pointer",
327    Move => "move",
328    OctagonAlert => "octagon-alert",
329    Package => "package",
330    Palette => "palette",
331    PanelLeft => "panel-left",
332    PanelRight => "panel-right",
333    Paperclip => "paperclip",
334    Pause => "pause",
335    Pencil => "pencil",
336    Phone => "phone",
337    Pin => "pin",
338    Pipette => "pipette",
339    Play => "play",
340    Plus => "plus",
341    Power => "power",
342    Printer => "printer",
343    Puzzle => "puzzle",
344    Quote => "quote",
345    Redo2 => "redo-2",
346    RefreshCw => "refresh-cw",
347    Repeat => "repeat",
348    Rocket => "rocket",
349    RotateCcw => "rotate-ccw",
350    RotateCw => "rotate-cw",
351    Rows2 => "rows-2",
352    Rss => "rss",
353    Save => "save",
354    Scissors => "scissors",
355    Search => "search",
356    Send => "send",
357    Server => "server",
358    Settings => "settings",
359    Settings2 => "settings-2",
360    Share => "share",
361    Share2 => "share-2",
362    Shield => "shield",
363    ShieldCheck => "shield-check",
364    ShoppingBag => "shopping-bag",
365    ShoppingCart => "shopping-cart",
366    Shrink => "shrink",
367    Shuffle => "shuffle",
368    SkipBack => "skip-back",
369    SkipForward => "skip-forward",
370    SlidersHorizontal => "sliders-horizontal",
371    Smartphone => "smartphone",
372    Smile => "smile",
373    Sparkles => "sparkles",
374    Square => "square",
375    SquareCheck => "square-check",
376    SquarePen => "square-pen",
377    Star => "star",
378    Strikethrough => "strikethrough",
379    Sun => "sun",
380    SunMoon => "sun-moon",
381    Table => "table",
382    Tablet => "tablet",
383    Tag => "tag",
384    Tags => "tags",
385    Terminal => "terminal",
386    TextAlignCenter => "text-align-center",
387    TextAlignEnd => "text-align-end",
388    TextAlignStart => "text-align-start",
389    ThumbsDown => "thumbs-down",
390    ThumbsUp => "thumbs-up",
391    Timer => "timer",
392    Trash => "trash",
393    Trash2 => "trash-2",
394    TrendingDown => "trending-down",
395    TrendingUp => "trending-up",
396    TriangleAlert => "triangle-alert",
397    Truck => "truck",
398    Type => "type",
399    Underline => "underline",
400    Undo2 => "undo-2",
401    Unlink => "unlink",
402    Upload => "upload",
403    User => "user",
404    UserCheck => "user-check",
405    UserMinus => "user-minus",
406    UserPlus => "user-plus",
407    Users => "users",
408    Video => "video",
409    Volume2 => "volume-2",
410    VolumeX => "volume-x",
411    Wallet => "wallet",
412    WandSparkles => "wand-sparkles",
413    Wifi => "wifi",
414    WifiOff => "wifi-off",
415    Wrench => "wrench",
416    X => "x",
417    Zap => "zap",
418    ZoomIn => "zoom-in",
419    ZoomOut => "zoom-out",
420}
421
422impl IconName {
423    /// The icon Lucide calls `name` (kebab-case, as in [`IconName::name`]).
424    pub fn from_name(name: &str) -> Option<Self> {
425        Self::ALL
426            .binary_search_by(|icon| icon.name().cmp(name))
427            .ok()
428            .map(|index| Self::ALL[index])
429    }
430
431    /// The asset path of this icon drawn with `width` as its stroke width
432    /// (in the 24-unit `viewBox`; Lucide's default is
433    /// [`DEFAULT_STROKE_WIDTH`]): [`path`](Self::path) with a
434    /// `?stroke-width=<w>` query that
435    /// [`HeroGpuiAssets`](crate::HeroGpuiAssets) serves as the rewritten
436    /// file. The width is rounded to three decimals and negative widths
437    /// count as zero; the default width, and a non-finite one, give the
438    /// plain path.
439    pub fn path_with_stroke_width(self, width: f32) -> SharedString {
440        match stroke_width_query(width) {
441            Some(width) => format!("{}?stroke-width={width}", self.path()).into(),
442            None => self.into(),
443        }
444    }
445
446    /// The icon whose [`IconName::path`] is `path`.
447    pub fn from_path(path: &str) -> Option<Self> {
448        path.strip_prefix(LUCIDE_ICON_PREFIX)?
449            .strip_prefix('/')?
450            .strip_suffix(".svg")
451            .and_then(Self::from_name)
452    }
453}
454
455/// `width` spelled for a stroke-width query: rounded to three decimals with
456/// trailing zeros trimmed, or `None` for the default or a non-finite width.
457fn stroke_width_query(width: f32) -> Option<String> {
458    if !width.is_finite() {
459        return None;
460    }
461    let rounded = (width.max(0.) * 1000.).round() / 1000.;
462    if (rounded - DEFAULT_STROKE_WIDTH).abs() < 0.0005 {
463        return None;
464    }
465    let text = format!("{rounded:.3}");
466    Some(text.trim_end_matches('0').trim_end_matches('.').to_owned())
467}
468
469/// The SVG [`HeroGpuiAssets`](crate::HeroGpuiAssets) serves for `path`
470/// when it is a Lucide path with a `?stroke-width=` query: the icon's file
471/// with its root `stroke-width` replaced.
472pub(crate) fn stroked_svg(path: &str) -> Option<Vec<u8>> {
473    let (base, width) = path.split_once("?stroke-width=")?;
474    let width: f32 = width
475        .parse()
476        .ok()
477        .filter(|w: &f32| w.is_finite() && *w >= 0.)?;
478    let icon = IconName::from_path(base)?;
479    let svg = std::str::from_utf8(icon.svg()).ok()?;
480    let from = format!("stroke-width=\"{DEFAULT_STROKE_WIDTH}\"");
481    let to = format!("stroke-width=\"{width}\"");
482    svg.contains(&from)
483        .then(|| svg.replacen(&from, &to, 1).into_bytes())
484}
485
486impl From<IconName> for SharedString {
487    fn from(icon: IconName) -> Self {
488        SharedString::new_static(icon.path())
489    }
490}
491
492impl From<IconName> for Icon {
493    fn from(icon: IconName) -> Self {
494        Icon::new(icon)
495    }
496}
497
498/// One icon, drawn as a GPUI `svg()` (HeroGPUI extension).
499///
500/// Sized [`DEFAULT_ICON_SIZE`] and painted in the theme's `foreground`
501/// unless told otherwise — gpui's `svg()` does not inherit the surrounding
502/// text colour, so an icon on an accent or danger fill needs `.color(..)`.
503/// The icon is decorative and reports no accessibility node, as
504/// `lucide-react` marks its `<svg>` `aria-hidden` when it is given no label:
505/// name the control that holds it instead.
506#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
507#[derive(IntoElement)]
508pub struct Icon {
509    path: SharedString,
510    size: Pixels,
511    color: Option<Hsla>,
512    stroke_width: Option<f32>,
513    absolute_stroke_width: bool,
514}
515
516impl Icon {
517    /// An embedded Lucide icon.
518    pub fn new(icon: IconName) -> Self {
519        Self::from_path(icon)
520    }
521
522    /// Any SVG the registered asset source serves: one of the
523    /// [`icons`](crate::icons) chrome paths, or an application's own.
524    pub fn from_path(path: impl Into<SharedString>) -> Self {
525        Self {
526            path: path.into(),
527            size: DEFAULT_ICON_SIZE,
528            color: None,
529            stroke_width: None,
530            absolute_stroke_width: false,
531        }
532    }
533
534    /// The width and height of the square the icon draws in: a pixel length
535    /// or an [`IconSize`] step (also through [`Sizable`](crate::Sizable)).
536    pub fn size(mut self, size: impl Into<Pixels>) -> Self {
537        self.size = size.into();
538        self
539    }
540
541    /// Lucide's `strokeWidth`: the stroke width in the icon's 24-unit
542    /// `viewBox` (default [`DEFAULT_STROKE_WIDTH`]), so it scales with the
543    /// icon. Lucide icons only; see the [module docs](self#stroke-width).
544    pub fn stroke_width(mut self, width: f32) -> Self {
545        self.stroke_width = Some(width);
546        self
547    }
548
549    /// Lucide's `absoluteStrokeWidth`: keep the stroke
550    /// [`stroke_width`](Self::stroke_width) pixels wide at any size, as
551    /// Lucide does by scaling it by `24 / size`.
552    pub fn absolute_stroke_width(mut self, absolute: bool) -> Self {
553        self.absolute_stroke_width = absolute;
554        self
555    }
556
557    /// The asset path to draw: the Lucide path for the effective stroke
558    /// width, or the path as given.
559    fn served_path(&self) -> SharedString {
560        match IconName::from_path(&self.path) {
561            Some(icon) if self.stroke_width.is_some() || self.absolute_stroke_width => {
562                icon.path_with_stroke_width(self.effective_stroke_width())
563            }
564            _ => self.path.clone(),
565        }
566    }
567
568    /// The stroke width to serve, in `viewBox` units.
569    fn effective_stroke_width(&self) -> f32 {
570        let width = self.stroke_width.unwrap_or(DEFAULT_STROKE_WIDTH);
571        let size = f32::from(self.size);
572        if self.absolute_stroke_width && size > 0. {
573            width * 24. / size
574        } else {
575            width
576        }
577    }
578
579    /// The paint colour (default: the theme's `foreground`).
580    pub fn color(mut self, color: impl Into<Hsla>) -> Self {
581        self.color = Some(color.into());
582        self
583    }
584}
585
586impl RenderOnce for Icon {
587    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
588        let color = self.color.unwrap_or(cx.colors().foreground);
589        let path = self.served_path();
590        svg()
591            .path(path)
592            .size(self.size)
593            .flex_none()
594            .text_color(color)
595    }
596}
597
598#[cfg(test)]
599mod tests {
600    use super::*;
601
602    #[test]
603    fn stroke_width_selects_the_served_path() {
604        let plain = Icon::new(IconName::Star);
605        assert_eq!(plain.served_path().as_ref(), IconName::Star.path());
606        let thin = Icon::new(IconName::Star).stroke_width(1.5);
607        assert_eq!(
608            thin.served_path().as_ref(),
609            "herogpui/icons/lucide/star.svg?stroke-width=1.5"
610        );
611        // The default width needs no rewrite.
612        let two = Icon::new(IconName::Star).stroke_width(2.);
613        assert_eq!(two.served_path().as_ref(), IconName::Star.path());
614        // Absolute: 2px at 48px is 1 viewBox unit, at 12px it is 4.
615        let big = Icon::new(IconName::Star)
616            .size(px(48.))
617            .absolute_stroke_width(true);
618        assert_eq!(
619            big.served_path().as_ref(),
620            "herogpui/icons/lucide/star.svg?stroke-width=1"
621        );
622        let small = Icon::new(IconName::Star)
623            .size(IconSize::Xs)
624            .stroke_width(2.)
625            .absolute_stroke_width(true);
626        assert_eq!(
627            small.served_path().as_ref(),
628            "herogpui/icons/lucide/star.svg?stroke-width=4"
629        );
630        // A path that is not a Lucide icon is drawn as given.
631        let chrome = Icon::from_path(crate::icons::CHECK).stroke_width(1.);
632        assert_eq!(chrome.served_path().as_ref(), crate::icons::CHECK);
633    }
634
635    #[test]
636    fn stroke_width_queries_round_and_reject_bad_widths() {
637        assert_eq!(stroke_width_query(1.23456).as_deref(), Some("1.235"));
638        assert_eq!(stroke_width_query(3.).as_deref(), Some("3"));
639        assert_eq!(stroke_width_query(-1.).as_deref(), Some("0"));
640        assert_eq!(stroke_width_query(2.0001), None);
641        assert_eq!(stroke_width_query(f32::NAN), None);
642        assert!(stroked_svg("herogpui/icons/lucide/star.svg?stroke-width=abc").is_none());
643        assert!(stroked_svg("herogpui/icons/lucide/nope.svg?stroke-width=1").is_none());
644        assert!(stroked_svg("herogpui/icons/check.svg?stroke-width=1").is_none());
645    }
646}