icons/lib.rs
1//! icons — the Lucide set, ported into typed constants. Reached as `bezel::icons`.
2//!
3//! ```ignore
4//! use ui::icons::{self, navigation};
5//!
6//! icons::icon(navigation::Compass).size(px(16.0)).text_color(theme.text_muted)
7//! ```
8//!
9//! [`icon`] returns a gpui `Svg`, so it colors with `text_color` and sizes like
10//! any element. `ui`'s `theme.icon(..)` is the same builder with both supplied
11//! from the ladder and the palette, which is what app code should reach for.
12//!
13//! # What a component takes
14//!
15//! [`Icon`] is the value, and it erases where the drawing came from — a glyph
16//! compiled in, the app's [`Icon::asset`] source, or [`Icon::file`] off disk.
17//! Components take `impl Into<Icon>`, so `glyph::Search` passes as itself and
18//! neither the component nor its signature learns which it got. SwiftUI's
19//! `Image` erases its sources the same way.
20//!
21//! An `Icon` carries no size and no colour. Those are the environment's, which
22//! here is the component: a menu row's glyph is the row's metric, not the
23//! caller's.
24//!
25//! # The set
26//!
27//! Modules are Lucide's categories and constants are Lucide's names, both
28//! generated by `build.rs` from a pinned release — there is no curated list to
29//! drift from upstream. A glyph Lucide files under two categories is one
30//! constant re-exported twice, so [`glyph`] holds each exactly once.
31//!
32//! A constant is the SVG itself rather than a path into an asset source, which
33//! is what lets the linker drop an icon the app never names: the set costs a
34//! binary only the glyphs it paints. Nothing registers with `with_assets`.
35//!
36//! # Paying for what you paint
37//!
38//! One feature per Lucide category, named as Lucide names it, and `full` is all
39//! 42. Nothing is on by default. Cargo unions features across a graph, so a
40//! category a dependency turns on is the floor for everyone below it — `ui`
41//! needs `arrows`, `notifications` and `text`.
42//!
43//! [`CATEGORIES`] is every enabled category and its icons, for an icon browser.
44//! Naming it is what pins the enabled set, so an app that only paints icons
45//! never mentions it and keeps the linker's dead-code pass.
46
47// Lucide's names are PascalCase, which is not Rust's casing for a constant. The
48// allow sits here rather than in the generated file because an `include!` may
49// not carry an inner attribute, and the file is included rather than declared
50// so `cargo fmt` has no module to resolve before the build script has run.
51#[allow(non_upper_case_globals)]
52mod generated {
53 include!(concat!(env!("OUT_DIR"), "/generated.rs"));
54}
55
56pub use generated::*;
57
58use std::{
59 collections::HashMap,
60 sync::{OnceLock, PoisonError, RwLock},
61};
62
63use gpui::{SharedString, Styled as _, Svg, svg};
64
65/// What to paint: a drawing, and the variant of it. Every component that takes
66/// an icon takes this, so a glyph and an app's own file are one signature.
67#[derive(Clone, Debug, PartialEq, Eq)]
68pub struct Icon {
69 source: Source,
70 fill: bool,
71}
72
73#[derive(Clone, Debug, PartialEq, Eq)]
74enum Source {
75 /// A glyph from the set, or any SVG the binary compiled in.
76 Glyph(&'static [u8]),
77 /// A key the app's own `AssetSource` resolves.
78 Asset(SharedString),
79 /// A file read from disk at paint time.
80 File(SharedString),
81}
82
83impl Icon {
84 /// A glyph from the set — `Icon::glyph(glyph::Search)`. `const`, so a
85 /// component can hold one as a default and the linker still drops the rest.
86 pub const fn glyph(svg: &'static [u8]) -> Self {
87 Self {
88 source: Source::Glyph(svg),
89 fill: false,
90 }
91 }
92
93 /// Art of the app's own, out of the `AssetSource` it registered. The set
94 /// cannot cover a product's marks, and a library that took only its own
95 /// would be one an app cannot put its logo in.
96 pub fn asset(key: impl Into<SharedString>) -> Self {
97 Self {
98 source: Source::Asset(key.into()),
99 fill: false,
100 }
101 }
102
103 /// A file on disk, for art that was not there at build time — one the user
104 /// picked, one a fetch wrote down. gpui loads it off the paint thread and
105 /// caches it, so the first frame or two paint nothing.
106 pub fn file(path: impl Into<SharedString>) -> Self {
107 Self {
108 source: Source::File(path.into()),
109 fill: false,
110 }
111 }
112
113 /// Filled rather than outlined — SwiftUI's `.symbolVariant(.fill)`. Lucide
114 /// draws one weight, so this fills the outline rather than reaching for a
115 /// second drawing: filling *and* stroking keeps the outer edge exactly
116 /// where the outline puts it, so a control swapping between them —
117 /// favourited or not — does not jump.
118 pub fn solid(mut self) -> Self {
119 self.fill = true;
120 self
121 }
122
123 /// The document to paint, for a glyph: the ported bytes, or the filled
124 /// rewrite of them. `None` for art, which only the renderer resolves.
125 pub fn data(&self) -> Option<&'static [u8]> {
126 let Source::Glyph(glyph) = self.source else {
127 return None;
128 };
129 Some(match self.fill {
130 false => glyph,
131 true => filled(glyph),
132 })
133 }
134}
135
136/// The filled rewrite of one glyph, made once and kept for the process.
137///
138/// [`Icon::solid`] is a flag read at paint time rather than a second constant,
139/// so without this the rewrite runs on every frame a solid icon is on screen.
140/// Keyed by the glyph's own address, which is stable and unique per constant —
141/// one filed under two categories is still one constant. Leaked rather than
142/// freed: an entry costs one glyph and is bounded by how many an app fills.
143fn filled(glyph: &'static [u8]) -> &'static [u8] {
144 static FILLED: OnceLock<RwLock<HashMap<usize, &'static [u8]>>> = OnceLock::new();
145
146 let cache = FILLED.get_or_init(RwLock::default);
147 let key = glyph.as_ptr() as usize;
148 // Read first: painting is the hot path, and after the first frame every
149 // glyph an app fills is already here.
150 if let Some(filled) = cache
151 .read()
152 .unwrap_or_else(PoisonError::into_inner)
153 .get(&key)
154 {
155 return filled;
156 }
157
158 let filled: &'static [u8] = Box::leak(
159 String::from_utf8_lossy(glyph)
160 .replace(r#"fill="none""#, r#"fill="currentColor""#)
161 .into_bytes()
162 .into_boxed_slice(),
163 );
164 cache
165 .write()
166 .unwrap_or_else(PoisonError::into_inner)
167 .insert(key, filled);
168 filled
169}
170
171impl From<&'static [u8]> for Icon {
172 fn from(glyph: &'static [u8]) -> Self {
173 Self::glyph(glyph)
174 }
175}
176
177/// An icon element. Size and colour are the caller's (`.size(..)`,
178/// `.text_color(..)`) — an `Svg` with neither paints nothing, which is why
179/// `ui`'s `theme.icon(..)` supplies both and this is the floor under it.
180pub fn icon(icon: impl Into<Icon>) -> Svg {
181 let icon = icon.into();
182 match &icon.source {
183 Source::Glyph(_) => svg().data(icon.data().expect("a glyph carries its own document")),
184 Source::Asset(key) => svg().path(key.clone()),
185 Source::File(path) => svg().external_path(path.clone()),
186 }
187 .flex_none()
188}
189
190/// [`icon`], painted solid — `icons::solid(glyph::Play)`.
191pub fn solid(icon: impl Into<Icon>) -> Svg {
192 self::icon(icon.into().solid())
193}