Skip to main content

native_theme/model/
bundled.rs

1// Feature-gated bundled SVG icon access via include_bytes!
2//
3// With `material-icons` or `lucide-icons` features enabled, this module
4// embeds SVG files at compile time and makes them available via
5// `bundled_icon_svg()`. Without features, the function always returns None.
6
7use super::icons::{IconRole, IconSet};
8
9/// Returns the raw SVG bytes for a bundled icon, if the corresponding
10/// feature flag is enabled.
11///
12/// Returns `None` when:
13/// - The requested `IconSet` is not `Material` or `Lucide`
14/// - The feature flag for the requested set is not enabled
15///
16/// Returns `Some(&[u8])` containing valid SVG bytes when the feature
17/// is enabled. Callers should wrap in `Cow::Borrowed` for zero-copy
18/// `IconData::Svg(Cow::Borrowed(bytes))` instead of `.to_vec()`.
19///
20/// # Examples
21///
22/// This is a crate-internal helper. The public replacement is the
23/// typed per-set loader API in [`crate::icons`]; external callers should
24/// not reach this function directly. The snippet below demonstrates the
25/// equivalent public-API call:
26///
27/// ```
28/// use native_theme::icons::SfSymbolsLoader;
29/// use native_theme::theme::IconRole;
30///
31/// // SfSymbols is not a bundled set on non-macOS targets; the loader returns None.
32/// let result = SfSymbolsLoader::new(IconRole::ActionCopy).load();
33/// assert!(result.is_none());
34/// ```
35#[must_use]
36#[allow(unreachable_patterns, unused_variables)]
37pub(crate) fn bundled_icon_svg(role: IconRole, set: IconSet) -> Option<&'static [u8]> {
38    match set {
39        #[cfg(feature = "material-icons")]
40        IconSet::Material => material_svg(role),
41
42        #[cfg(feature = "lucide-icons")]
43        IconSet::Lucide => lucide_svg(role),
44
45        _ => None,
46    }
47}
48
49#[cfg(feature = "material-icons")]
50#[allow(unreachable_patterns)]
51fn material_svg(role: IconRole) -> Option<&'static [u8]> {
52    match role {
53        // Dialog / Alert (6)
54        IconRole::DialogWarning => Some(include_bytes!("../../icons/material/warning.svg")),
55        IconRole::DialogError => Some(include_bytes!("../../icons/material/error.svg")),
56        IconRole::DialogInfo => Some(include_bytes!("../../icons/material/info.svg")),
57        IconRole::DialogQuestion => Some(include_bytes!("../../icons/material/help.svg")),
58        IconRole::DialogSuccess => Some(include_bytes!("../../icons/material/check_circle.svg")),
59        IconRole::Shield => Some(include_bytes!("../../icons/material/shield.svg")),
60
61        // Window Controls (4)
62        IconRole::WindowClose => Some(include_bytes!("../../icons/material/close.svg")),
63        IconRole::WindowMinimize => Some(include_bytes!("../../icons/material/minimize.svg")),
64        IconRole::WindowMaximize => Some(include_bytes!("../../icons/material/open_in_full.svg")),
65        IconRole::WindowRestore => {
66            Some(include_bytes!("../../icons/material/close_fullscreen.svg"))
67        }
68
69        // Common Actions (14)
70        IconRole::ActionSave => Some(include_bytes!("../../icons/material/save.svg")),
71        IconRole::ActionDelete => Some(include_bytes!("../../icons/material/delete.svg")),
72        IconRole::ActionCopy => Some(include_bytes!("../../icons/material/content_copy.svg")),
73        IconRole::ActionPaste => Some(include_bytes!("../../icons/material/content_paste.svg")),
74        IconRole::ActionCut => Some(include_bytes!("../../icons/material/content_cut.svg")),
75        IconRole::ActionUndo => Some(include_bytes!("../../icons/material/undo.svg")),
76        IconRole::ActionRedo => Some(include_bytes!("../../icons/material/redo.svg")),
77        IconRole::ActionSearch => Some(include_bytes!("../../icons/material/search.svg")),
78        IconRole::ActionSettings => Some(include_bytes!("../../icons/material/settings.svg")),
79        IconRole::ActionEdit => Some(include_bytes!("../../icons/material/edit.svg")),
80        IconRole::ActionAdd => Some(include_bytes!("../../icons/material/add.svg")),
81        IconRole::ActionRemove => Some(include_bytes!("../../icons/material/remove.svg")),
82        IconRole::ActionRefresh => Some(include_bytes!("../../icons/material/refresh.svg")),
83        IconRole::ActionPrint => Some(include_bytes!("../../icons/material/print.svg")),
84
85        // Navigation (6)
86        IconRole::NavBack => Some(include_bytes!("../../icons/material/arrow_back.svg")),
87        IconRole::NavForward => Some(include_bytes!("../../icons/material/arrow_forward.svg")),
88        IconRole::NavUp => Some(include_bytes!("../../icons/material/arrow_upward.svg")),
89        IconRole::NavDown => Some(include_bytes!("../../icons/material/arrow_downward.svg")),
90        IconRole::NavHome => Some(include_bytes!("../../icons/material/home.svg")),
91        IconRole::NavMenu => Some(include_bytes!("../../icons/material/menu.svg")),
92
93        // Files / Places (5)
94        IconRole::FileGeneric => Some(include_bytes!("../../icons/material/description.svg")),
95        IconRole::FolderClosed => Some(include_bytes!("../../icons/material/folder.svg")),
96        IconRole::FolderOpen => Some(include_bytes!("../../icons/material/folder_open.svg")),
97        IconRole::TrashEmpty => Some(include_bytes!("../../icons/material/delete.svg")),
98        IconRole::TrashFull => Some(include_bytes!("../../icons/material/delete.svg")), // reuse delete
99
100        // Status (3)
101        IconRole::StatusBusy => Some(include_bytes!("../../icons/material/progress_activity.svg")),
102        IconRole::StatusCheck => Some(include_bytes!("../../icons/material/check.svg")),
103        IconRole::StatusError => Some(include_bytes!("../../icons/material/error.svg")), // reuse error
104
105        // System (4)
106        IconRole::UserAccount => Some(include_bytes!("../../icons/material/person.svg")),
107        IconRole::Notification => Some(include_bytes!("../../icons/material/notifications.svg")),
108        IconRole::Help => Some(include_bytes!("../../icons/material/help.svg")), // reuse help
109        IconRole::Lock => Some(include_bytes!("../../icons/material/lock.svg")),
110
111        _ => None, // #[non_exhaustive] forward compat
112    }
113}
114
115#[cfg(feature = "lucide-icons")]
116#[allow(unreachable_patterns)]
117fn lucide_svg(role: IconRole) -> Option<&'static [u8]> {
118    match role {
119        // Dialog / Alert (6)
120        IconRole::DialogWarning => Some(include_bytes!("../../icons/lucide/triangle-alert.svg")),
121        IconRole::DialogError => Some(include_bytes!("../../icons/lucide/circle-x.svg")),
122        IconRole::DialogInfo => Some(include_bytes!("../../icons/lucide/info.svg")),
123        IconRole::DialogQuestion => Some(include_bytes!(
124            "../../icons/lucide/circle-question-mark.svg"
125        )),
126        IconRole::DialogSuccess => Some(include_bytes!("../../icons/lucide/circle-check.svg")),
127        IconRole::Shield => Some(include_bytes!("../../icons/lucide/shield.svg")),
128
129        // Window Controls (4)
130        IconRole::WindowClose => Some(include_bytes!("../../icons/lucide/x.svg")),
131        IconRole::WindowMinimize => Some(include_bytes!("../../icons/lucide/minimize.svg")),
132        IconRole::WindowMaximize => Some(include_bytes!("../../icons/lucide/maximize.svg")),
133        IconRole::WindowRestore => Some(include_bytes!("../../icons/lucide/minimize-2.svg")),
134
135        // Common Actions (14)
136        IconRole::ActionSave => Some(include_bytes!("../../icons/lucide/save.svg")),
137        IconRole::ActionDelete => Some(include_bytes!("../../icons/lucide/trash.svg")),
138        IconRole::ActionCopy => Some(include_bytes!("../../icons/lucide/copy.svg")),
139        IconRole::ActionPaste => Some(include_bytes!("../../icons/lucide/clipboard-paste.svg")),
140        IconRole::ActionCut => Some(include_bytes!("../../icons/lucide/scissors.svg")),
141        IconRole::ActionUndo => Some(include_bytes!("../../icons/lucide/undo-2.svg")),
142        IconRole::ActionRedo => Some(include_bytes!("../../icons/lucide/redo-2.svg")),
143        IconRole::ActionSearch => Some(include_bytes!("../../icons/lucide/search.svg")),
144        IconRole::ActionSettings => Some(include_bytes!("../../icons/lucide/settings.svg")),
145        IconRole::ActionEdit => Some(include_bytes!("../../icons/lucide/pencil.svg")),
146        IconRole::ActionAdd => Some(include_bytes!("../../icons/lucide/plus.svg")),
147        IconRole::ActionRemove => Some(include_bytes!("../../icons/lucide/minus.svg")),
148        IconRole::ActionRefresh => Some(include_bytes!("../../icons/lucide/refresh-cw.svg")),
149        IconRole::ActionPrint => Some(include_bytes!("../../icons/lucide/printer.svg")),
150
151        // Navigation (6)
152        IconRole::NavBack => Some(include_bytes!("../../icons/lucide/chevron-left.svg")),
153        IconRole::NavForward => Some(include_bytes!("../../icons/lucide/chevron-right.svg")),
154        IconRole::NavUp => Some(include_bytes!("../../icons/lucide/chevron-up.svg")),
155        IconRole::NavDown => Some(include_bytes!("../../icons/lucide/chevron-down.svg")),
156        IconRole::NavHome => Some(include_bytes!("../../icons/lucide/house.svg")),
157        IconRole::NavMenu => Some(include_bytes!("../../icons/lucide/menu.svg")),
158
159        // Files / Places (5)
160        IconRole::FileGeneric => Some(include_bytes!("../../icons/lucide/file.svg")),
161        IconRole::FolderClosed => Some(include_bytes!("../../icons/lucide/folder-closed.svg")),
162        IconRole::FolderOpen => Some(include_bytes!("../../icons/lucide/folder-open.svg")),
163        IconRole::TrashEmpty => Some(include_bytes!("../../icons/lucide/trash.svg")),
164        IconRole::TrashFull => Some(include_bytes!("../../icons/lucide/trash.svg")), // reuse trash
165
166        // Status (3)
167        IconRole::StatusBusy => Some(include_bytes!("../../icons/lucide/loader.svg")),
168        IconRole::StatusCheck => Some(include_bytes!("../../icons/lucide/check.svg")),
169        IconRole::StatusError => Some(include_bytes!("../../icons/lucide/circle-x.svg")), // reuse circle-x
170
171        // System (4)
172        IconRole::UserAccount => Some(include_bytes!("../../icons/lucide/user.svg")),
173        IconRole::Notification => Some(include_bytes!("../../icons/lucide/bell.svg")),
174        IconRole::Help => Some(include_bytes!(
175            "../../icons/lucide/circle-question-mark.svg"
176        )), // reuse
177        IconRole::Lock => Some(include_bytes!("../../icons/lucide/lock.svg")),
178
179        _ => None, // #[non_exhaustive] forward compat
180    }
181}
182
183/// Returns raw SVG bytes for a bundled icon looked up by its canonical name
184/// within the icon set.
185///
186/// Names use each set's canonical format:
187/// - Lucide: kebab-case (e.g., `"arrow-down"`, `"circle-check"`)
188/// - Material: snake_case (e.g., `"arrow_downward"`, `"check_circle"`)
189///
190/// Returns `None` for non-bundled sets, disabled features, or unknown names.
191///
192/// # Examples
193///
194/// This is a crate-internal helper. The public replacement is a typed
195/// per-set loader from [`crate::icons`] constructed with a string name:
196///
197/// ```
198/// use native_theme::icons::SfSymbolsLoader;
199///
200/// // SfSymbols is not a bundled set; no in-crate name lookup succeeds.
201/// let result = SfSymbolsLoader::new("check").load();
202/// assert!(result.is_none());
203/// ```
204#[must_use]
205#[allow(unreachable_patterns, unused_variables)]
206#[cfg_attr(
207    not(any(feature = "material-icons", feature = "lucide-icons")),
208    allow(dead_code)
209)]
210pub(crate) fn bundled_icon_by_name(name: &str, set: IconSet) -> Option<&'static [u8]> {
211    match set {
212        #[cfg(feature = "material-icons")]
213        IconSet::Material => material_svg_by_name(name),
214
215        #[cfg(feature = "lucide-icons")]
216        IconSet::Lucide => lucide_svg_by_name(name),
217
218        _ => None,
219    }
220}
221
222#[cfg(feature = "lucide-icons")]
223include!(concat!(env!("OUT_DIR"), "/lucide_svg_by_name.rs"));
224
225#[cfg(feature = "material-icons")]
226include!(concat!(env!("OUT_DIR"), "/material_svg_by_name.rs"));
227
228#[cfg(test)]
229#[allow(clippy::unwrap_used, clippy::expect_used)]
230mod tests {
231    use super::*;
232
233    // === Feature-gated tests: Material ===
234
235    #[test]
236    #[cfg(feature = "material-icons")]
237    fn material_icons_cover_all_roles() {
238        for role in IconRole::ALL {
239            let svg = bundled_icon_svg(role, IconSet::Material);
240            assert!(svg.is_some(), "Material icons missing SVG for {:?}", role);
241            let bytes = svg.unwrap();
242            let content = std::str::from_utf8(bytes).expect("SVG should be valid UTF-8");
243            assert!(
244                content.contains("<svg"),
245                "Material {:?} does not contain <svg tag",
246                role
247            );
248        }
249    }
250
251    #[test]
252    #[cfg(feature = "material-icons")]
253    fn material_icons_total_size_under_200kb() {
254        let total: usize = IconRole::ALL
255            .iter()
256            .filter_map(|role| bundled_icon_svg(*role, IconSet::Material))
257            .map(|svg| svg.len())
258            .sum();
259        assert!(
260            total < 400 * 1024,
261            "Material icons total size {} bytes exceeds 400KB budget",
262            total
263        );
264    }
265
266    // === Feature-gated tests: Lucide ===
267
268    #[test]
269    #[cfg(feature = "lucide-icons")]
270    fn lucide_icons_cover_all_roles() {
271        for role in IconRole::ALL {
272            let svg = bundled_icon_svg(role, IconSet::Lucide);
273            assert!(svg.is_some(), "Lucide icons missing SVG for {:?}", role);
274            let bytes = svg.unwrap();
275            let content = std::str::from_utf8(bytes).expect("SVG should be valid UTF-8");
276            assert!(
277                content.contains("<svg"),
278                "Lucide {:?} does not contain <svg tag",
279                role
280            );
281        }
282    }
283
284    #[test]
285    #[cfg(feature = "lucide-icons")]
286    fn lucide_icons_total_size_under_100kb() {
287        let total: usize = IconRole::ALL
288            .iter()
289            .filter_map(|role| bundled_icon_svg(*role, IconSet::Lucide))
290            .map(|svg| svg.len())
291            .sum();
292        assert!(
293            total < 200 * 1024,
294            "Lucide icons total size {} bytes exceeds 200KB budget",
295            total
296        );
297    }
298
299    // === Non-feature-gated tests ===
300
301    #[test]
302    fn non_bundled_sets_return_none() {
303        assert!(
304            bundled_icon_svg(IconRole::ActionCopy, IconSet::SfSymbols).is_none(),
305            "SfSymbols should not be a bundled set"
306        );
307        assert!(
308            bundled_icon_svg(IconRole::ActionCopy, IconSet::Freedesktop).is_none(),
309            "Freedesktop should not be a bundled set"
310        );
311        assert!(
312            bundled_icon_svg(IconRole::ActionCopy, IconSet::SegoeIcons).is_none(),
313            "SegoeIcons should not be a bundled set"
314        );
315    }
316
317    // === bundled_icon_by_name tests ===
318
319    /// §7.1: the gpui-component-style names and the two retired files no longer
320    /// resolve; callers get `None`, never a substitute.
321    #[test]
322    #[cfg(all(feature = "lucide-icons", feature = "material-icons"))]
323    fn retired_bundle_names_return_none() {
324        for name in [
325            "close",
326            "dash",
327            "inspect",
328            "resize-corner",
329            "sort-ascending",
330            "sort-descending",
331            "window-close",
332            "window-maximize",
333            "window-minimize",
334            "window-restore",
335            "trash-2",
336        ] {
337            assert!(
338                bundled_icon_by_name(name, IconSet::Lucide).is_none(),
339                "{name} should be gone"
340            );
341        }
342        assert!(bundled_icon_by_name("star_border", IconSet::Material).is_none());
343        assert!(bundled_icon_by_name("font_size", IconSet::Material).is_none());
344        assert!(bundled_icon_by_name("trash", IconSet::Lucide).is_some());
345        assert!(bundled_icon_by_name("star_fill1", IconSet::Material).is_some());
346        assert!(bundled_icon_by_name("format_size", IconSet::Material).is_some());
347    }
348
349    #[test]
350    fn by_name_non_bundled_sets_return_none() {
351        assert!(bundled_icon_by_name("check", IconSet::SfSymbols).is_none());
352        assert!(bundled_icon_by_name("check", IconSet::Freedesktop).is_none());
353        assert!(bundled_icon_by_name("check", IconSet::SegoeIcons).is_none());
354    }
355
356    #[test]
357    fn by_name_unknown_name_returns_none() {
358        assert!(bundled_icon_by_name("nonexistent-icon-xyz", IconSet::Lucide).is_none());
359        assert!(bundled_icon_by_name("nonexistent_icon_xyz", IconSet::Material).is_none());
360    }
361
362    #[cfg(any(feature = "lucide-icons", feature = "material-icons"))]
363    fn bundled_files(dir: &str) -> Vec<String> {
364        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
365            .join("icons")
366            .join(dir);
367        let mut names: Vec<String> = std::fs::read_dir(&path)
368            .expect("icon directory exists")
369            .map(|entry| {
370                entry
371                    .expect("readable entry")
372                    .file_name()
373                    .to_string_lossy()
374                    .into_owned()
375            })
376            .filter_map(|file| file.strip_suffix(".svg").map(str::to_owned))
377            .collect();
378        names.sort();
379        assert!(!names.is_empty(), "icons/{dir} must not be empty");
380        names
381    }
382
383    /// §10.5: the by-name table is generated from the directory, so every
384    /// bundled file resolves and the table cannot drift.
385    #[test]
386    #[cfg(feature = "lucide-icons")]
387    fn generated_lucide_table_covers_every_bundled_file() {
388        for name in bundled_files("lucide") {
389            let svg = bundled_icon_by_name(&name, IconSet::Lucide)
390                .unwrap_or_else(|| panic!("Lucide table misses bundled file {name}.svg"));
391            assert!(std::str::from_utf8(svg).expect("UTF-8").contains("<svg"));
392        }
393    }
394
395    #[test]
396    #[cfg(feature = "material-icons")]
397    fn generated_material_table_covers_every_bundled_file() {
398        for name in bundled_files("material") {
399            let svg = bundled_icon_by_name(&name, IconSet::Material)
400                .unwrap_or_else(|| panic!("Material table misses bundled file {name}.svg"));
401            assert!(std::str::from_utf8(svg).expect("UTF-8").contains("<svg"));
402        }
403    }
404}