Skip to main content

egui_lucide/
lib.rs

1//! Lucide SVG icons for [`egui`].
2//!
3//! Apps must register the SVG loader once:
4//!
5//! ```ignore
6//! egui_extras::install_image_loaders(&ctx);
7//! ```
8//!
9//! ```
10//! # egui::__run_test_ui(|ui| {
11//! use egui_lucide::Lucide;
12//! ui.add(Lucide::House.size(18.0).stroke_width(1.75));
13//! # });
14//! ```
15//!
16//! [`LucideIcon::source`] / [`LucideIcon::image`] bake color and stroke into the SVG
17//! bytes. Display size is applied on the [`egui::Image`], not in the path data.
18//! The texture cache key includes color and stroke so restyling is not stale.
19
20mod generated;
21
22use std::borrow::Cow;
23use std::collections::HashMap;
24use std::fmt;
25use std::sync::{Mutex, OnceLock};
26
27use egui::load::Bytes;
28use egui::{Color32, Image, ImageSource, Response, Ui, Vec2, Widget};
29
30pub use generated::{ICON_COUNT, LUCIDE_VERSION, Lucide};
31
32use generated::{INNERS, NAMES};
33
34/// Lucide defaults: 24×24 viewBox, stroke width 2.
35pub const DEFAULT_SIZE: f32 = 24.0;
36/// Lucide default `stroke-width`.
37pub const DEFAULT_STROKE_WIDTH: f32 = 2.0;
38
39impl Lucide {
40    /// Kebab-case Lucide name, e.g. `"house"`.
41    #[inline]
42    pub const fn name(self) -> &'static str {
43        NAMES[self as usize]
44    }
45
46    /// SVG children only (no root `<svg>`). Used when wrapping with style.
47    #[inline]
48    pub const fn inner(self) -> &'static str {
49        INNERS[self as usize]
50    }
51
52    /// Lookup by kebab-case name (`"house"`).
53    pub fn from_name(name: &str) -> Option<Self> {
54        let index = NAMES.iter().position(|&n| n == name)?;
55        Some(Self::ALL[index])
56    }
57
58    /// Start a styled icon. Same as [`LucideIcon::new`].
59    #[inline]
60    pub const fn icon(self) -> LucideIcon {
61        LucideIcon::new(self)
62    }
63
64    /// Display size in points (the rasterized square).
65    #[inline]
66    pub const fn size(self, size: f32) -> LucideIcon {
67        LucideIcon::new(self).size(size)
68    }
69
70    /// Stroke color. When omitted, [`Widget`] uses `ui.visuals().text_color()`
71    /// and [`LucideIcon::source`] / [`LucideIcon::image`] use [`Color32::WHITE`].
72    #[inline]
73    pub const fn color(self, color: Color32) -> LucideIcon {
74        LucideIcon::new(self).color(color)
75    }
76
77    /// SVG `stroke-width` in viewBox units (Lucide default is 2).
78    #[inline]
79    pub const fn stroke_width(self, width: f32) -> LucideIcon {
80        LucideIcon::new(self).stroke_width(width)
81    }
82
83    /// [`ImageSource::Bytes`] with default size/stroke and white stroke color.
84    #[inline]
85    pub fn source(self) -> ImageSource<'static> {
86        LucideIcon::new(self).source()
87    }
88
89    /// Ready-to-add [`Image`] with default size/stroke and white stroke color.
90    #[inline]
91    pub fn image(self) -> Image<'static> {
92        LucideIcon::new(self).image()
93    }
94}
95
96impl fmt::Display for Lucide {
97    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98        f.write_str(self.name())
99    }
100}
101
102impl Widget for Lucide {
103    fn ui(self, ui: &mut Ui) -> Response {
104        LucideIcon::new(self).ui(ui)
105    }
106}
107
108/// A Lucide icon with color, stroke width, and display size.
109#[derive(Clone, Copy, Debug, PartialEq)]
110pub struct LucideIcon {
111    icon: Lucide,
112    size: f32,
113    color: Option<Color32>,
114    stroke_width: f32,
115}
116
117impl LucideIcon {
118    /// Lucide defaults: size 24, stroke width 2, color unset.
119    #[inline]
120    pub const fn new(icon: Lucide) -> Self {
121        Self {
122            icon,
123            size: DEFAULT_SIZE,
124            color: None,
125            stroke_width: DEFAULT_STROKE_WIDTH,
126        }
127    }
128
129    #[inline]
130    pub const fn size(mut self, size: f32) -> Self {
131        self.size = size;
132        self
133    }
134
135    #[inline]
136    pub const fn color(mut self, color: Color32) -> Self {
137        self.color = Some(color);
138        self
139    }
140
141    #[inline]
142    pub const fn stroke_width(mut self, width: f32) -> Self {
143        self.stroke_width = width;
144        self
145    }
146
147    #[inline]
148    pub const fn icon_id(self) -> Lucide {
149        self.icon
150    }
151
152    fn resolved_color(self) -> Color32 {
153        self.color.unwrap_or(Color32::WHITE)
154    }
155
156    /// Unique `bytes://` URI. Color and stroke are part of the key so egui
157    /// does not reuse a texture from a previous style.
158    ///
159    /// The path **ends with `.svg`** — `egui_extras::SvgLoader` only matches
160    /// that suffix, and ignores query strings.
161    pub fn uri(&self) -> String {
162        uri_for(self.icon, self.resolved_color(), self.stroke_width)
163    }
164
165    /// Styled SVG document (root attributes + icon inner).
166    pub fn svg(&self) -> String {
167        styled_svg(self.icon, self.resolved_color(), self.stroke_width)
168    }
169
170    /// [`ImageSource::Bytes`] for this style. Display size is not baked in.
171    pub fn source(&self) -> ImageSource<'static> {
172        let color = self.resolved_color();
173        let bytes = cached_bytes(self.icon, color, self.stroke_width);
174        ImageSource::Bytes {
175            uri: Cow::Owned(uri_for(self.icon, color, self.stroke_width)),
176            bytes,
177        }
178    }
179
180    /// [`Image`] fitted to [`LucideIcon::size`].
181    pub fn image(&self) -> Image<'static> {
182        Image::new(self.source())
183            .fit_to_exact_size(Vec2::splat(self.size))
184            .alt_text(self.icon.name())
185            .show_loading_spinner(false)
186    }
187}
188
189impl Widget for LucideIcon {
190    fn ui(self, ui: &mut Ui) -> Response {
191        let color = self.color.unwrap_or_else(|| ui.visuals().text_color());
192        self.color(color).image().ui(ui)
193    }
194}
195
196#[derive(Clone, Copy, Hash, PartialEq, Eq)]
197struct CacheKey {
198    id: u16,
199    rgba: u32,
200    stroke_bits: u32,
201}
202
203fn color_rgba(color: Color32) -> u32 {
204    u32::from_be_bytes([color.r(), color.g(), color.b(), color.a()])
205}
206
207fn cache() -> &'static Mutex<HashMap<CacheKey, Bytes>> {
208    static CACHE: OnceLock<Mutex<HashMap<CacheKey, Bytes>>> = OnceLock::new();
209    CACHE.get_or_init(|| Mutex::new(HashMap::new()))
210}
211
212fn cached_bytes(icon: Lucide, color: Color32, stroke_width: f32) -> Bytes {
213    let key = CacheKey {
214        id: icon as u16,
215        rgba: color_rgba(color),
216        stroke_bits: stroke_width.to_bits(),
217    };
218    if let Ok(guard) = cache().lock() {
219        if let Some(bytes) = guard.get(&key) {
220            return bytes.clone();
221        }
222    }
223    let bytes = Bytes::from(styled_svg(icon, color, stroke_width).into_bytes());
224    if let Ok(mut guard) = cache().lock() {
225        guard.entry(key).or_insert_with(|| bytes.clone());
226    }
227    bytes
228}
229
230fn uri_for(icon: Lucide, color: Color32, stroke_width: f32) -> String {
231    // SvgLoader matches `uri.ends_with(".svg")` only — no query string.
232    format!(
233        "bytes://lucide/{}.c={:08x}.sw={:08x}.svg",
234        icon.name(),
235        color_rgba(color),
236        stroke_width.to_bits()
237    )
238}
239
240fn svg_color(color: Color32) -> String {
241    if color.a() == 255 {
242        format!("#{:02x}{:02x}{:02x}", color.r(), color.g(), color.b())
243    } else {
244        format!(
245            "rgba({},{},{},{:.4})",
246            color.r(),
247            color.g(),
248            color.b(),
249            f32::from(color.a()) / 255.0
250        )
251    }
252}
253
254fn styled_svg(icon: Lucide, color: Color32, stroke_width: f32) -> String {
255    let inner = icon.inner();
256    let color = svg_color(color);
257    let mut out = String::with_capacity(inner.len() + 192);
258    out.push_str(
259        "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"",
260    );
261    out.push_str(&color);
262    out.push_str("\" stroke-width=\"");
263    out.push_str(&format_stroke(stroke_width));
264    out.push_str("\" stroke-linecap=\"round\" stroke-linejoin=\"round\">");
265    out.push_str(inner);
266    out.push_str("</svg>");
267    out
268}
269
270fn format_stroke(width: f32) -> String {
271    let rounded = (width * 1000.0).round() / 1000.0;
272    if (rounded - rounded.round()).abs() < f32::EPSILON {
273        format!("{}", rounded as i32)
274    } else {
275        format!("{rounded}")
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    #[test]
284    fn counts_match() {
285        assert_eq!(Lucide::ALL.len(), ICON_COUNT);
286        assert_eq!(NAMES.len(), ICON_COUNT);
287        assert_eq!(INNERS.len(), ICON_COUNT);
288        assert!(!LUCIDE_VERSION.is_empty());
289    }
290
291    #[test]
292    fn house_roundtrip() {
293        let icon = Lucide::from_name("house").expect("house");
294        assert_eq!(icon, Lucide::House);
295        assert_eq!(icon.name(), "house");
296        assert_eq!(icon.to_string(), "house");
297        assert!(icon.inner().contains("<path"));
298    }
299
300    #[test]
301    fn names_are_unique_and_nonempty() {
302        let mut seen = std::collections::HashSet::new();
303        for icon in Lucide::ALL {
304            assert!(!icon.name().is_empty());
305            assert!(!icon.inner().is_empty());
306            assert!(seen.insert(icon.name()));
307            assert_eq!(icon as usize, seen.len() - 1);
308        }
309    }
310
311    #[test]
312    fn svg_bakes_color_and_stroke() {
313        let icon = Lucide::House
314            .color(Color32::from_rgb(0x12, 0x34, 0x56))
315            .stroke_width(1.75);
316        let svg = icon.svg();
317        assert!(svg.contains("stroke=\"#123456\""));
318        assert!(svg.contains("stroke-width=\"1.75\""));
319        assert!(svg.contains(Lucide::House.inner()));
320        assert!(icon.uri().contains("house."));
321        assert!(icon.uri().ends_with(".svg"));
322        assert_ne!(
323            icon.uri(),
324            Lucide::House.color(Color32::WHITE).uri()
325        );
326        assert_ne!(
327            icon.uri(),
328            Lucide::House
329                .color(Color32::from_rgb(0x12, 0x34, 0x56))
330                .stroke_width(2.0)
331                .uri()
332        );
333    }
334
335    #[test]
336    fn source_is_bytes_with_svg_suffix() {
337        let source = Lucide::House.source();
338        match source {
339            ImageSource::Bytes { uri, bytes } => {
340                assert!(uri.ends_with(".svg"));
341                assert!(uri.starts_with("bytes://lucide/"));
342                assert!(!bytes.as_ref().is_empty());
343            }
344            other => panic!("expected Bytes, got {other:?}"),
345        }
346    }
347}