Skip to main content

kitchen_ui/
icons.rs

1//! Icons: what an app ships ([`AppAssets`]: gpui-kit's component icons, the few Lucide icons the
2//! app adds, and its own files), and the check that every icon its code names is shipped
3//! ([`assert_icons_bundled`]): an icon missing from the assets draws nothing and logs an error.
4//!
5//! An app lists the icons it adds with gpui-kit's macro, so the binary carries those alone rather
6//! than the whole catalog:
7//!
8//! ```ignore
9//! gpui_kit::assets::icon_assets!(pub ExtraIcons, [AudioWaveform, Workflow, Microscope]);
10//!
11//! let assets = kitchen_ui::icons::AppAssets::new().icons(ExtraIcons).file(LOGO, LOGO_PNG);
12//! gpui_kit::application().with_assets(assets).run(|cx| { /* … */ });
13//!
14//! #[test]
15//! fn every_icon_used_is_bundled() {
16//!     let assets = kitchen_ui::icons::AppAssets::new().icons(ExtraIcons);
17//!     kitchen_ui::icons::assert_icons_bundled(&assets, &[concat!(env!("CARGO_MANIFEST_DIR"), "/src")]);
18//! }
19//! ```
20
21use std::borrow::Cow;
22use std::collections::BTreeSet;
23use std::path::{Path, PathBuf};
24
25use gpui_kit::assets::IconName;
26use gpui_kit::{AssetSource, SharedString};
27
28/// The assets an app ships: its own files (a logo), then the icon sets it adds, then gpui-kit's
29/// component icons (or the whole Lucide catalog, [`AppAssets::all_icons`]).
30#[derive(Default)]
31pub struct AppAssets {
32    files: Vec<(&'static str, &'static [u8])>,
33    extra: Vec<Box<dyn AssetSource>>,
34    all_icons: bool,
35}
36
37impl AppAssets {
38    /// gpui-kit's component icons alone.
39    pub fn new() -> Self {
40        Self::default()
41    }
42
43    /// Adds a set of icons (one made with `gpui_kit::assets::icon_assets!`), or any other source.
44    pub fn icons(mut self, source: impl AssetSource) -> Self {
45        self.extra.push(Box::new(source));
46        self
47    }
48
49    /// Adds a file served at `path` (for `img(path)`: a logo, a picture in the about dialog).
50    pub fn file(mut self, path: &'static str, bytes: &'static [u8]) -> Self {
51        self.files.push((path, bytes));
52        self
53    }
54
55    /// Ships the whole Lucide catalog instead of the component icons (a larger binary; for an app
56    /// that has not listed its icons yet).
57    pub fn all_icons(mut self) -> Self {
58        self.all_icons = true;
59        self
60    }
61
62    fn base(&self) -> &dyn AssetSource {
63        if self.all_icons { &gpui_kit::assets::AllAssets } else { &gpui_kit::assets::Assets }
64    }
65}
66
67impl AssetSource for AppAssets {
68    fn load(&self, path: &str) -> gpui_kit::Result<Option<Cow<'static, [u8]>>> {
69        if let Some((_, bytes)) = self.files.iter().find(|(p, _)| *p == path) {
70            return Ok(Some(Cow::Borrowed(bytes)));
71        }
72        for source in &self.extra {
73            if let Some(bytes) = source.load(path)? {
74                return Ok(Some(bytes));
75            }
76        }
77        self.base().load(path)
78    }
79
80    fn list(&self, path: &str) -> gpui_kit::Result<Vec<SharedString>> {
81        let mut paths = self.base().list(path)?;
82        for source in &self.extra {
83            paths.extend(source.list(path)?);
84        }
85        paths.extend(self.files.iter().filter(|(p, _)| p.starts_with(path)).map(|(p, _)| SharedString::from(*p)));
86        paths.sort();
87        paths.dedup();
88        Ok(paths)
89    }
90}
91
92/// The names of the icons the Rust sources under `roots` name: `IconName::Server`, and the same
93/// through an alias (`use gpui_kit::assets::IconName as I;` then `I::Server`, or
94/// `type Icons = IconName;`). Aliases are collected from every file first, so an alias declared
95/// in one module and used in another is found too. Only real icon names are kept, so another
96/// type behind the same alias adds nothing. A glob import (`use IconName::*`) is not seen.
97pub fn icon_names_used(roots: &[impl AsRef<Path>]) -> BTreeSet<String> {
98    let texts: Vec<String> = rust_files(roots).iter().filter_map(|p| std::fs::read_to_string(p).ok()).collect();
99    let mut markers: BTreeSet<String> = BTreeSet::from(["IconName".to_string()]);
100    for text in &texts {
101        markers.extend(aliases_in(text));
102    }
103    let known: BTreeSet<String> = IconName::ALL.iter().map(|i| format!("{i:?}")).collect();
104    let mut used = BTreeSet::new();
105    for text in &texts {
106        used.extend(names_after(text, &markers).into_iter().filter(|n| known.contains(n)));
107    }
108    used
109}
110
111/// The icons named in `used` that `assets` cannot load.
112pub fn missing_icons(assets: &dyn AssetSource, used: &BTreeSet<String>) -> Vec<String> {
113    let mut missing = Vec::new();
114    for name in used {
115        let Some(icon) = IconName::ALL.iter().find(|i| format!("{i:?}") == *name) else { continue };
116        if assets.load(&icon.path()).ok().flatten().is_none() {
117            missing.push(name.clone());
118        }
119    }
120    missing
121}
122
123/// Panics unless every icon the sources under `roots` name is in `assets` (and at least one icon
124/// is found, so a wrong root fails instead of passing empty). For an app's test.
125pub fn assert_icons_bundled(assets: &dyn AssetSource, roots: &[impl AsRef<Path>]) {
126    let used = icon_names_used(roots);
127    assert!(!used.is_empty(), "no icons found in the sources: are the roots right? {:?}", roots.iter().map(|r| r.as_ref().display().to_string()).collect::<Vec<_>>());
128    let missing = missing_icons(assets, &used);
129    assert!(missing.is_empty(), "icons used but not bundled (add them to the app's icon set): {missing:?}");
130}
131
132/// Every `.rs` file under `roots` (roots that do not exist are skipped).
133fn rust_files(roots: &[impl AsRef<Path>]) -> Vec<PathBuf> {
134    let mut out = Vec::new();
135    let mut stack: Vec<PathBuf> = roots.iter().map(|r| r.as_ref().to_path_buf()).collect();
136    while let Some(path) = stack.pop() {
137        if path.is_dir() {
138            let Ok(entries) = std::fs::read_dir(&path) else { continue };
139            stack.extend(entries.flatten().map(|e| e.path()));
140        } else if path.extension().is_some_and(|x| x == "rs") {
141            out.push(path);
142        }
143    }
144    out.sort();
145    out
146}
147
148/// Names `IconName` goes by in `text`: `IconName as I` (in a `use`, grouped or not) and
149/// `type I = …IconName;`.
150fn aliases_in(text: &str) -> Vec<String> {
151    let mut out = Vec::new();
152    for (at, _) in text.match_indices("IconName") {
153        let rest = text[at + "IconName".len()..].trim_start();
154        if let Some(after) = rest.strip_prefix("as") {
155            if after.starts_with(char::is_whitespace) {
156                let name = ident_at(after.trim_start());
157                if !name.is_empty() && name != "_" {
158                    out.push(name);
159                }
160            }
161        }
162    }
163    for (at, _) in text.match_indices("type ") {
164        if at > 0 && is_ident_char(text[..at].chars().next_back().unwrap_or(' ')) {
165            continue;
166        }
167        let rest = &text[at + 5..];
168        let name = ident_at(rest.trim_start());
169        let Some(eq) = rest.find('=') else { continue };
170        let Some(end) = rest[eq..].find(';') else { continue };
171        let target = rest[eq + 1..eq + end].trim();
172        if !name.is_empty() && (target == "IconName" || target.ends_with("::IconName")) {
173            out.push(name);
174        }
175    }
176    out
177}
178
179/// The capitalised names after `marker::` for each marker, where the marker is a whole word.
180fn names_after(text: &str, markers: &BTreeSet<String>) -> Vec<String> {
181    let mut out = Vec::new();
182    for m in markers {
183        let pat = format!("{m}::");
184        for (at, _) in text.match_indices(&pat) {
185            if text[..at].chars().next_back().is_some_and(is_ident_char) {
186                continue;
187            }
188            let name = ident_at(&text[at + pat.len()..]);
189            if name.starts_with(|c: char| c.is_ascii_uppercase()) {
190                out.push(name);
191            }
192        }
193    }
194    out
195}
196
197fn ident_at(s: &str) -> String {
198    s.chars().take_while(|c| is_ident_char(*c)).collect()
199}
200
201fn is_ident_char(c: char) -> bool {
202    c.is_ascii_alphanumeric() || c == '_'
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    fn markers(text: &str) -> BTreeSet<String> {
210        let mut m = BTreeSet::from(["IconName".to_string()]);
211        m.extend(aliases_in(text));
212        m
213    }
214
215    #[test]
216    fn finds_names_and_aliases() {
217        let text = r#"
218            use gpui_kit::assets::IconName as I;
219            use gpui_kit::{assets::{IconName as Lucide, Assets}, px};
220            type Icons = gpui_kit::assets::IconName;
221            fn f() { (IconName::Plus, I::Server, Lucide::Columns2, Icons::Flame, UI::Nope, I::new()) }
222        "#;
223        let m = markers(text);
224        assert_eq!(m, BTreeSet::from(["IconName", "I", "Lucide", "Icons"].map(String::from)));
225        let mut names = names_after(text, &m);
226        names.sort();
227        // `UI::Nope` is not `I::Nope`; `I::new` is not a name
228        assert_eq!(names, ["Columns2", "Flame", "Plus", "Server"]);
229    }
230
231    #[test]
232    fn an_alias_named_like_a_type_is_filtered_by_real_names() {
233        // dsp-kitchen aliases the enum as `Assets` while `Assets` is also gpui-kit's asset source
234        let text = "use gpui_kit::assets::IconName as Assets; let _ = (Assets::AppWindow, Assets::load);";
235        let names = names_after(text, &markers(text));
236        assert_eq!(names, ["AppWindow"]);
237    }
238
239    /// kitchen-ui's widgets name only icons gpui-kit's component set ships: the check run here as
240    /// an app runs it (over the widget sources; this file's test texts name icons on purpose).
241    #[test]
242    fn every_icon_used_is_bundled() {
243        let root = env!("CARGO_MANIFEST_DIR");
244        assert_icons_bundled(&AppAssets::new(), &[format!("{root}/src/widgets"), format!("{root}/src/param_editor.rs")]);
245    }
246
247    #[test]
248    fn a_missing_icon_is_reported() {
249        let used = BTreeSet::from(["Microscope".to_string(), "Plus".to_string()]);
250        assert_eq!(missing_icons(&AppAssets::new(), &used), ["Microscope"]);
251        gpui_kit::assets::icon_assets!(Extra, [Microscope]);
252        assert!(missing_icons(&AppAssets::new().icons(Extra), &used).is_empty());
253    }
254}