1mod 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
34pub const DEFAULT_SIZE: f32 = 24.0;
36pub const DEFAULT_STROKE_WIDTH: f32 = 2.0;
38
39impl Lucide {
40 #[inline]
42 pub const fn name(self) -> &'static str {
43 NAMES[self as usize]
44 }
45
46 #[inline]
48 pub const fn inner(self) -> &'static str {
49 INNERS[self as usize]
50 }
51
52 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 #[inline]
60 pub const fn icon(self) -> LucideIcon {
61 LucideIcon::new(self)
62 }
63
64 #[inline]
66 pub const fn size(self, size: f32) -> LucideIcon {
67 LucideIcon::new(self).size(size)
68 }
69
70 #[inline]
73 pub const fn color(self, color: Color32) -> LucideIcon {
74 LucideIcon::new(self).color(color)
75 }
76
77 #[inline]
79 pub const fn stroke_width(self, width: f32) -> LucideIcon {
80 LucideIcon::new(self).stroke_width(width)
81 }
82
83 #[inline]
85 pub fn source(self) -> ImageSource<'static> {
86 LucideIcon::new(self).source()
87 }
88
89 #[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#[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 #[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 pub fn uri(&self) -> String {
162 uri_for(self.icon, self.resolved_color(), self.stroke_width)
163 }
164
165 pub fn svg(&self) -> String {
167 styled_svg(self.icon, self.resolved_color(), self.stroke_width)
168 }
169
170 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 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 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}