use gpui::{prelude::*, px, svg, App, Hsla, IntoElement, Pixels, RenderOnce, SharedString, Window};
use herogpui_theme::ActiveTheme;
pub const LUCIDE_VERSION: &str = "1.31.0";
pub const LUCIDE_ICON_PREFIX: &str = "herogpui/icons/lucide";
pub const DEFAULT_ICON_SIZE: Pixels = px(16.);
pub const DEFAULT_STROKE_WIDTH: f32 = 2.;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum IconSize {
Xs,
Sm,
#[default]
Md,
Lg,
Xl,
}
impl IconSize {
pub const ALL: [IconSize; 5] = [
IconSize::Xs,
IconSize::Sm,
IconSize::Md,
IconSize::Lg,
IconSize::Xl,
];
pub const fn pixels(self) -> Pixels {
match self {
IconSize::Xs => px(12.),
IconSize::Sm => px(14.),
IconSize::Md => px(16.),
IconSize::Lg => px(20.),
IconSize::Xl => px(24.),
}
}
}
impl From<IconSize> for Pixels {
fn from(size: IconSize) -> Self {
size.pixels()
}
}
macro_rules! lucide_icons {
($($variant:ident => $name:literal),* $(,)?) => {
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[non_exhaustive]
pub enum IconName {
$(
#[doc = concat!("Lucide `", $name, "`.")]
$variant,
)*
}
impl IconName {
pub const ALL: &'static [IconName] = &[$(IconName::$variant),*];
pub const fn name(self) -> &'static str {
match self {
$(IconName::$variant => $name,)*
}
}
pub const fn path(self) -> &'static str {
match self {
$(IconName::$variant => concat!("herogpui/icons/lucide/", $name, ".svg"),)*
}
}
pub const fn svg(self) -> &'static [u8] {
match self {
$(IconName::$variant => include_bytes!(concat!("../assets/lucide/", $name, ".svg")),)*
}
}
}
};
}
lucide_icons! {
Activity => "activity",
AlarmClock => "alarm-clock",
Archive => "archive",
ArrowDown => "arrow-down",
ArrowDownLeft => "arrow-down-left",
ArrowDownRight => "arrow-down-right",
ArrowDownToLine => "arrow-down-to-line",
ArrowLeft => "arrow-left",
ArrowLeftRight => "arrow-left-right",
ArrowRight => "arrow-right",
ArrowUp => "arrow-up",
ArrowUpDown => "arrow-up-down",
ArrowUpLeft => "arrow-up-left",
ArrowUpRight => "arrow-up-right",
ArrowUpToLine => "arrow-up-to-line",
AtSign => "at-sign",
Ban => "ban",
Battery => "battery",
Bell => "bell",
BellOff => "bell-off",
Bold => "bold",
Book => "book",
BookOpen => "book-open",
Bookmark => "bookmark",
Bot => "bot",
Box => "box",
Briefcase => "briefcase",
Bug => "bug",
Building => "building",
Calendar => "calendar",
CalendarDays => "calendar-days",
Camera => "camera",
ChartBar => "chart-bar",
ChartColumn => "chart-column",
ChartLine => "chart-line",
ChartPie => "chart-pie",
Check => "check",
CheckCheck => "check-check",
ChevronDown => "chevron-down",
ChevronLeft => "chevron-left",
ChevronRight => "chevron-right",
ChevronUp => "chevron-up",
ChevronsDown => "chevrons-down",
ChevronsLeft => "chevrons-left",
ChevronsRight => "chevrons-right",
ChevronsUp => "chevrons-up",
ChevronsUpDown => "chevrons-up-down",
Circle => "circle",
CircleAlert => "circle-alert",
CircleCheck => "circle-check",
CircleDot => "circle-dot",
CircleHelp => "circle-help",
CircleMinus => "circle-minus",
CirclePlus => "circle-plus",
CircleUser => "circle-user",
CircleX => "circle-x",
Clipboard => "clipboard",
ClipboardCheck => "clipboard-check",
ClipboardCopy => "clipboard-copy",
Clock => "clock",
Cloud => "cloud",
CloudDownload => "cloud-download",
CloudUpload => "cloud-upload",
Code => "code",
CodeXml => "code-xml",
Columns2 => "columns-2",
Command => "command",
Compass => "compass",
Contrast => "contrast",
Copy => "copy",
CornerDownLeft => "corner-down-left",
Cpu => "cpu",
CreditCard => "credit-card",
Crop => "crop",
Database => "database",
DollarSign => "dollar-sign",
Download => "download",
Droplet => "droplet",
Ellipsis => "ellipsis",
EllipsisVertical => "ellipsis-vertical",
Expand => "expand",
ExternalLink => "external-link",
Eye => "eye",
EyeOff => "eye-off",
File => "file",
FileCode => "file-code",
FilePlus => "file-plus",
FileText => "file-text",
Files => "files",
Film => "film",
Flag => "flag",
Folder => "folder",
FolderOpen => "folder-open",
FolderPlus => "folder-plus",
Funnel => "funnel",
Gift => "gift",
GitBranch => "git-branch",
GitCommitHorizontal => "git-commit-horizontal",
GitMerge => "git-merge",
GitPullRequest => "git-pull-request",
Globe => "globe",
GraduationCap => "graduation-cap",
Grid2x2 => "grid-2x2",
GripHorizontal => "grip-horizontal",
GripVertical => "grip-vertical",
HardDrive => "hard-drive",
Hash => "hash",
Headphones => "headphones",
Heart => "heart",
History => "history",
House => "house",
Image => "image",
Inbox => "inbox",
Info => "info",
Italic => "italic",
Key => "key",
Keyboard => "keyboard",
Languages => "languages",
Laptop => "laptop",
Layers => "layers",
LayoutDashboard => "layout-dashboard",
LayoutGrid => "layout-grid",
LayoutList => "layout-list",
Lightbulb => "lightbulb",
Link => "link",
Link2 => "link-2",
List => "list",
ListChecks => "list-checks",
ListFilter => "list-filter",
ListOrdered => "list-ordered",
Loader => "loader",
LoaderCircle => "loader-circle",
Lock => "lock",
LockOpen => "lock-open",
LogIn => "log-in",
LogOut => "log-out",
Mail => "mail",
Map => "map",
MapPin => "map-pin",
Maximize => "maximize",
Maximize2 => "maximize-2",
Menu => "menu",
MessageCircle => "message-circle",
MessageSquare => "message-square",
Mic => "mic",
MicOff => "mic-off",
Minimize => "minimize",
Minimize2 => "minimize-2",
Minus => "minus",
Monitor => "monitor",
Moon => "moon",
MousePointer => "mouse-pointer",
Move => "move",
OctagonAlert => "octagon-alert",
Package => "package",
Palette => "palette",
PanelLeft => "panel-left",
PanelRight => "panel-right",
Paperclip => "paperclip",
Pause => "pause",
Pencil => "pencil",
Phone => "phone",
Pin => "pin",
Pipette => "pipette",
Play => "play",
Plus => "plus",
Power => "power",
Printer => "printer",
Puzzle => "puzzle",
Quote => "quote",
Redo2 => "redo-2",
RefreshCw => "refresh-cw",
Repeat => "repeat",
Rocket => "rocket",
RotateCcw => "rotate-ccw",
RotateCw => "rotate-cw",
Rows2 => "rows-2",
Rss => "rss",
Save => "save",
Scissors => "scissors",
Search => "search",
Send => "send",
Server => "server",
Settings => "settings",
Settings2 => "settings-2",
Share => "share",
Share2 => "share-2",
Shield => "shield",
ShieldCheck => "shield-check",
ShoppingBag => "shopping-bag",
ShoppingCart => "shopping-cart",
Shrink => "shrink",
Shuffle => "shuffle",
SkipBack => "skip-back",
SkipForward => "skip-forward",
SlidersHorizontal => "sliders-horizontal",
Smartphone => "smartphone",
Smile => "smile",
Sparkles => "sparkles",
Square => "square",
SquareCheck => "square-check",
SquarePen => "square-pen",
Star => "star",
Strikethrough => "strikethrough",
Sun => "sun",
SunMoon => "sun-moon",
Table => "table",
Tablet => "tablet",
Tag => "tag",
Tags => "tags",
Terminal => "terminal",
TextAlignCenter => "text-align-center",
TextAlignEnd => "text-align-end",
TextAlignStart => "text-align-start",
ThumbsDown => "thumbs-down",
ThumbsUp => "thumbs-up",
Timer => "timer",
Trash => "trash",
Trash2 => "trash-2",
TrendingDown => "trending-down",
TrendingUp => "trending-up",
TriangleAlert => "triangle-alert",
Truck => "truck",
Type => "type",
Underline => "underline",
Undo2 => "undo-2",
Unlink => "unlink",
Upload => "upload",
User => "user",
UserCheck => "user-check",
UserMinus => "user-minus",
UserPlus => "user-plus",
Users => "users",
Video => "video",
Volume2 => "volume-2",
VolumeX => "volume-x",
Wallet => "wallet",
WandSparkles => "wand-sparkles",
Wifi => "wifi",
WifiOff => "wifi-off",
Wrench => "wrench",
X => "x",
Zap => "zap",
ZoomIn => "zoom-in",
ZoomOut => "zoom-out",
}
impl IconName {
pub fn from_name(name: &str) -> Option<Self> {
Self::ALL
.binary_search_by(|icon| icon.name().cmp(name))
.ok()
.map(|index| Self::ALL[index])
}
pub fn path_with_stroke_width(self, width: f32) -> SharedString {
match stroke_width_query(width) {
Some(width) => format!("{}?stroke-width={width}", self.path()).into(),
None => self.into(),
}
}
pub fn from_path(path: &str) -> Option<Self> {
path.strip_prefix(LUCIDE_ICON_PREFIX)?
.strip_prefix('/')?
.strip_suffix(".svg")
.and_then(Self::from_name)
}
}
fn stroke_width_query(width: f32) -> Option<String> {
if !width.is_finite() {
return None;
}
let rounded = (width.max(0.) * 1000.).round() / 1000.;
if (rounded - DEFAULT_STROKE_WIDTH).abs() < 0.0005 {
return None;
}
let text = format!("{rounded:.3}");
Some(text.trim_end_matches('0').trim_end_matches('.').to_owned())
}
pub(crate) fn stroked_svg(path: &str) -> Option<Vec<u8>> {
let (base, width) = path.split_once("?stroke-width=")?;
let width: f32 = width
.parse()
.ok()
.filter(|w: &f32| w.is_finite() && *w >= 0.)?;
let icon = IconName::from_path(base)?;
let svg = std::str::from_utf8(icon.svg()).ok()?;
let from = format!("stroke-width=\"{DEFAULT_STROKE_WIDTH}\"");
let to = format!("stroke-width=\"{width}\"");
svg.contains(&from)
.then(|| svg.replacen(&from, &to, 1).into_bytes())
}
impl From<IconName> for SharedString {
fn from(icon: IconName) -> Self {
SharedString::new_static(icon.path())
}
}
impl From<IconName> for Icon {
fn from(icon: IconName) -> Self {
Icon::new(icon)
}
}
#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
#[derive(IntoElement)]
pub struct Icon {
path: SharedString,
size: Pixels,
color: Option<Hsla>,
stroke_width: Option<f32>,
absolute_stroke_width: bool,
}
impl Icon {
pub fn new(icon: IconName) -> Self {
Self::from_path(icon)
}
pub fn from_path(path: impl Into<SharedString>) -> Self {
Self {
path: path.into(),
size: DEFAULT_ICON_SIZE,
color: None,
stroke_width: None,
absolute_stroke_width: false,
}
}
pub fn size(mut self, size: impl Into<Pixels>) -> Self {
self.size = size.into();
self
}
pub fn stroke_width(mut self, width: f32) -> Self {
self.stroke_width = Some(width);
self
}
pub fn absolute_stroke_width(mut self, absolute: bool) -> Self {
self.absolute_stroke_width = absolute;
self
}
fn served_path(&self) -> SharedString {
match IconName::from_path(&self.path) {
Some(icon) if self.stroke_width.is_some() || self.absolute_stroke_width => {
icon.path_with_stroke_width(self.effective_stroke_width())
}
_ => self.path.clone(),
}
}
fn effective_stroke_width(&self) -> f32 {
let width = self.stroke_width.unwrap_or(DEFAULT_STROKE_WIDTH);
let size = f32::from(self.size);
if self.absolute_stroke_width && size > 0. {
width * 24. / size
} else {
width
}
}
pub fn color(mut self, color: impl Into<Hsla>) -> Self {
self.color = Some(color.into());
self
}
}
impl RenderOnce for Icon {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let color = self.color.unwrap_or(cx.colors().foreground);
let path = self.served_path();
svg()
.path(path)
.size(self.size)
.flex_none()
.text_color(color)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stroke_width_selects_the_served_path() {
let plain = Icon::new(IconName::Star);
assert_eq!(plain.served_path().as_ref(), IconName::Star.path());
let thin = Icon::new(IconName::Star).stroke_width(1.5);
assert_eq!(
thin.served_path().as_ref(),
"herogpui/icons/lucide/star.svg?stroke-width=1.5"
);
let two = Icon::new(IconName::Star).stroke_width(2.);
assert_eq!(two.served_path().as_ref(), IconName::Star.path());
let big = Icon::new(IconName::Star)
.size(px(48.))
.absolute_stroke_width(true);
assert_eq!(
big.served_path().as_ref(),
"herogpui/icons/lucide/star.svg?stroke-width=1"
);
let small = Icon::new(IconName::Star)
.size(IconSize::Xs)
.stroke_width(2.)
.absolute_stroke_width(true);
assert_eq!(
small.served_path().as_ref(),
"herogpui/icons/lucide/star.svg?stroke-width=4"
);
let chrome = Icon::from_path(crate::icons::CHECK).stroke_width(1.);
assert_eq!(chrome.served_path().as_ref(), crate::icons::CHECK);
}
#[test]
fn stroke_width_queries_round_and_reject_bad_widths() {
assert_eq!(stroke_width_query(1.23456).as_deref(), Some("1.235"));
assert_eq!(stroke_width_query(3.).as_deref(), Some("3"));
assert_eq!(stroke_width_query(-1.).as_deref(), Some("0"));
assert_eq!(stroke_width_query(2.0001), None);
assert_eq!(stroke_width_query(f32::NAN), None);
assert!(stroked_svg("herogpui/icons/lucide/star.svg?stroke-width=abc").is_none());
assert!(stroked_svg("herogpui/icons/lucide/nope.svg?stroke-width=1").is_none());
assert!(stroked_svg("herogpui/icons/check.svg?stroke-width=1").is_none());
}
}