1use gpui::{prelude::*, px, svg, App, Hsla, IntoElement, Pixels, RenderOnce, SharedString, Window};
62use herogpui_theme::ActiveTheme;
63
64pub const LUCIDE_VERSION: &str = "1.31.0";
66
67pub const LUCIDE_ICON_PREFIX: &str = "herogpui/icons/lucide";
69
70pub const DEFAULT_ICON_SIZE: Pixels = px(16.);
73
74pub const DEFAULT_STROKE_WIDTH: f32 = 2.;
77
78#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
84#[non_exhaustive]
85pub enum IconSize {
86 Xs,
88 Sm,
90 #[default]
92 Md,
93 Lg,
95 Xl,
97}
98
99impl IconSize {
100 pub const ALL: [IconSize; 5] = [
102 IconSize::Xs,
103 IconSize::Sm,
104 IconSize::Md,
105 IconSize::Lg,
106 IconSize::Xl,
107 ];
108
109 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 #[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 pub const ALL: &'static [IconName] = &[$(IconName::$variant),*];
147
148 pub const fn name(self) -> &'static str {
150 match self {
151 $(IconName::$variant => $name,)*
152 }
153 }
154
155 pub const fn path(self) -> &'static str {
159 match self {
160 $(IconName::$variant => concat!("herogpui/icons/lucide/", $name, ".svg"),)*
161 }
162 }
163
164 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 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 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 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
455fn 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
469pub(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#[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 pub fn new(icon: IconName) -> Self {
519 Self::from_path(icon)
520 }
521
522 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 pub fn size(mut self, size: impl Into<Pixels>) -> Self {
537 self.size = size.into();
538 self
539 }
540
541 pub fn stroke_width(mut self, width: f32) -> Self {
545 self.stroke_width = Some(width);
546 self
547 }
548
549 pub fn absolute_stroke_width(mut self, absolute: bool) -> Self {
553 self.absolute_stroke_width = absolute;
554 self
555 }
556
557 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 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 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 let two = Icon::new(IconName::Star).stroke_width(2.);
613 assert_eq!(two.served_path().as_ref(), IconName::Star.path());
614 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 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}