Skip to main content

brep_app/
icons.rs

1//! The inline-icon catalog: every `assets/glyphs/*.svg` as SVG source, keyed by
2//! the character it draws.
3//!
4//! This is the lookup half of the SVG-icon feature; [`crate::icon_text`] is the
5//! drawing half. The table itself is generated by `build.rs` at compile time
6//! from the glyph SVGs, so an icon is catalogued by existing, with no list here
7//! to fall out of step. See `build.rs` for the two transforms it applies (the
8//! `#111` ink sentinel, and the viewBox-derived aspect ratio).
9//!
10//! Why both a font and an SVG catalog: the font is what egui lays out inside
11//! ordinary text, and it stays the fallback for every glyph that is drawn as
12//! text (toolbar buttons, `ui.label`s not yet converted). The catalog is what
13//! lets the same character be drawn as a real image — which is what makes
14//! genuine multi-colour icons possible, since a TrueType glyph can only be one
15//! colour. The stacked private-use layers in
16//! [`crate::panels::toolbar_button`] exist only to work around that limit.
17//!
18//! Three icons already take that route — the bug (U+1F41E), the book
19//! (U+1F4DA) and the pencil (U+270D). Each was composed from the very layers
20//! `toolbar_button` stacks by hand, in the same palette, so they are the same
21//! artwork expressed once instead of assembled at paint time. Their glyph SVGs
22//! mark the ink layer `brep:font-outline="1"`, so the TTF is byte-for-byte what
23//! it was and a text use of those characters still renders exactly as before —
24//! the colour is additive, visible only through this catalog.
25
26// The generated `pub static ICONS: &[Icon]`, sorted by codepoint.
27include!(concat!(env!("OUT_DIR"), "/icon_catalog.rs"));
28
29/// One catalogued icon: the SVG source for a character, plus what the renderer
30/// needs to place and colour it.
31#[derive(Debug, Clone, Copy, PartialEq)]
32pub struct Icon {
33    /// The character this icon draws — what a string must contain to select it.
34    pub ch: char,
35    /// PostScript glyph name (e.g. `icon_2699`), for debugging and docs.
36    pub name: &'static str,
37    /// A stable, per-icon `bytes://…svg` URI. egui caches the decoded texture
38    /// under it, so it must be constant across frames — and it must end in
39    /// `.svg`, which is how `egui_extras`' loader recognises the format.
40    pub uri: &'static str,
41    /// The SVG source, with `mono` artwork already rewritten to white.
42    pub svg: &'static str,
43    /// width / height of the viewBox — the proportion the font draws it at.
44    pub aspect: f32,
45    /// Monochrome artwork that should take the colour of the surrounding text.
46    /// `false` means the SVG carries its own colours and must not be tinted.
47    pub mono: bool,
48}
49
50/// The icon for `ch`, or `None` if the catalog has none — in which case the
51/// character stays ordinary text and the font draws it.
52pub fn lookup(ch: char) -> Option<&'static Icon> {
53    let i = ICONS.binary_search_by_key(&ch, |icon| icon.ch).ok()?;
54    Some(&ICONS[i])
55}
56
57/// Whether `ch` has an icon. Cheaper to read at a call site than `lookup().is_some()`.
58pub fn has(ch: char) -> bool {
59    lookup(ch).is_some()
60}
61
62/// The catalogued artwork for a label that is exactly one character.
63///
64/// This is the test every icon-drawing site makes — toolbar buttons, tree rows,
65/// palette rows — so they all agree on what is an icon. `None` for a
66/// multi-character label (a composite, not an icon) and for an uncatalogued
67/// character; both are drawn as ordinary text.
68///
69/// MONOCHROME artwork is included. It used to be excluded, on the grounds that
70/// the icon font already drew it in the live text colour for free — but that
71/// exclusion was the last thing keeping a font in the binary. Callers tint a
72/// `mono` icon to their text colour (it is white in the catalog, so a multiply
73/// lands it exactly) and leave a colour one alone.
74pub fn artwork(glyph: &str) -> Option<&'static Icon> {
75    let mut chars = glyph.chars();
76    let icon = lookup(chars.next()?)?;
77    chars.next().is_none().then_some(icon)
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    /// The colour icons OUTSIDE the feature block — each composed by hand from
85    /// the layered private-use artwork the toolbar used to stack. Used by the
86    /// tests below from both directions, so adding a colour icon without saying
87    /// so here fails loudly rather than silently widening what is allowed.
88    /// [`is_colour`] is what the tests actually ask; the feature block is a
89    /// range rather than 42 more lines.
90    const COLOUR: &[char] = &[
91        '\u{270D}',  // pencil    — freehand sketch
92        '\u{1F41E}', // bug       — Submit Bug
93        '\u{1F4DA}', // book      — step.parts library
94        '\u{E010}',  // document  — New
95        '\u{E011}',  // folder    — Open
96        '\u{E012}',  // disk      — Save
97        '\u{E013}',  // disk+     — Save As
98        '\u{E014}',  // in-tray   — Import
99        '\u{E015}',  // out-tray  — Export
100        '\u{E016}',  // arrow     — Undo
101        '\u{E017}',  // arrow     — Redo
102        '\u{E018}',  // camera    — Projection
103    ];
104
105    /// Every kernel feature, U+E030-E059, is colour artwork — see
106    /// [`FEATURE_PALETTE`]. They are a range rather than 42 entries in
107    /// [`COLOUR`] because the rule is the block, not the individual icon.
108    const FEATURES: std::ops::RangeInclusive<char> = '\u{E030}'..='\u{E059}';
109
110    /// Whether an icon is expected to carry its own colours.
111    fn is_colour(ch: char) -> bool {
112        COLOUR.contains(&ch) || FEATURES.contains(&ch)
113    }
114
115    /// Every `fill="…"` value in an SVG. `build.rs` has the same three lines and
116    /// decides `mono` with them; this is the test side of the same question.
117    fn fills(svg: &str) -> impl Iterator<Item = &str> {
118        svg.match_indices("fill=\"").filter_map(|(i, m)| {
119            let rest = &svg[i + m.len()..];
120            rest.find('"').map(|end| &rest[..end])
121        })
122    }
123
124    #[test]
125    fn catalog_is_populated_and_sorted() {
126        // A floor, not the exact count: it catches `build.rs` emitting nothing
127        // or a half-scanned directory, without breaking every time a glyph is
128        // added or (as the colour conversions do) several are merged away.
129        assert!(ICONS.len() >= 50, "catalog looks truncated: {}", ICONS.len());
130        assert!(
131            ICONS.windows(2).all(|w| w[0].ch < w[1].ch),
132            "ICONS must be sorted by codepoint for `lookup` to binary-search it"
133        );
134    }
135
136    #[test]
137    fn lookup_finds_every_entry() {
138        for icon in ICONS {
139            assert_eq!(lookup(icon.ch), Some(icon), "lookup missed {}", icon.name);
140        }
141    }
142
143    #[test]
144    fn notdef_is_not_catalogued() {
145        // `.notdef` has no codepoint, so nothing in a string can select it.
146        assert!(ICONS.iter().all(|i| i.name != ".notdef"));
147    }
148
149    #[test]
150    fn uncatalogued_characters_stay_text() {
151        for ch in ['a', ' ', '1', '\n', 'é'] {
152            assert!(!has(ch), "{ch:?} must not be an icon");
153        }
154    }
155
156    /// The ink sentinel, both ways round: a monochrome glyph must have been
157    /// rewritten to white (so the widget's tint lands exactly), and a glyph with
158    /// authored colour must have been left completely alone (tinting it would
159    /// wash it out).
160    #[test]
161    fn the_ink_sentinel_rewrites_mono_glyphs_and_spares_coloured_ones() {
162        for icon in ICONS {
163            if icon.mono {
164                assert!(!icon.svg.contains("#111"), "{}: ink sentinel not rewritten", icon.name);
165                assert!(icon.svg.contains("#fff"), "{}: no white fill after rewrite", icon.name);
166            } else {
167                assert!(
168                    icon.svg.contains("fill=\""),
169                    "{}: a non-mono glyph must carry its own fills",
170                    icon.name
171                );
172            }
173        }
174    }
175
176    /// The colour lane is live, not just possible. Each of these was composed
177    /// from the layered private-use artwork the toolbar used to paint by hand,
178    /// so it carries that exact palette; a regression that flattened one back to
179    /// ink would otherwise pass every other test.
180    #[test]
181    fn the_composed_colour_icons_are_catalogued_in_colour() {
182        for (ch, fills) in [
183            ('\u{1F41E}', &["#e1544a", "#5e1a14"][..]), // bug        — Submit Bug
184            ('\u{1F4DA}', &["#2e9e8f", "#124840"][..]), // book       — step.parts
185            ('\u{270D}', &["#f2b33d", "#6e4a12"][..]),  // pencil     — freehand
186            ('\u{E010}', &["#70b7f6", "#1c5384"][..]),  // document   — New
187            ('\u{E011}', &["#cd8b19", "#fac437"][..]),  // folder     — Open
188            ('\u{E012}', &["#4597e5", "#13416c"][..]),  // disk       — Save
189            ('\u{E014}', &["#2fa469", "#7be8ae"][..]),  // in-tray    — Import
190            ('\u{E015}', &["#da762a", "#ffbb69"][..]),  // out-tray   — Export
191            ('\u{E018}', &["#40abda", "#d3f5ff"][..]),  // camera     — Projection
192        ] {
193            let icon = lookup(ch).unwrap_or_else(|| panic!("U+{:04X} is not catalogued", ch as u32));
194            assert!(!icon.mono, "{} must not be tinted — it has authored colour", icon.name);
195            for fill in fills {
196                assert!(icon.svg.contains(fill), "{}: lost its {fill} layer", icon.name);
197            }
198        }
199    }
200
201    /// The COLOUR list is exactly right, both ways round: every icon on it is
202    /// colour, and no icon off it is. That is what catches a stray fill edit
203    /// making an icon un-tintable, and equally a conversion that forgot to be
204    /// declared here.
205    #[test]
206    fn the_colour_set_is_exactly_the_declared_one() {
207        for ch in COLOUR.iter().copied().chain(FEATURES) {
208            let icon = lookup(ch).unwrap_or_else(|| panic!("U+{:04X} missing", ch as u32));
209            assert!(!icon.mono, "{} is declared colour but catalogued as ink", icon.name);
210        }
211        for icon in ICONS.iter().filter(|i| !is_colour(i.ch)) {
212            assert!(icon.mono, "{} carries colour but is not declared colour", icon.name);
213        }
214    }
215
216    /// Every feature the kernel catalogues must have real ARTWORK here, not just
217    /// a codepoint mapping.
218    ///
219    /// `brep_render` already tests that `feature_icon` returns a glyph for every
220    /// catalogue entry — and that test passed for a long time while NOTHING was
221    /// drawn: all 42 feature codepoints (U+E030-E059) mapped to a private-use
222    /// slot that had no glyph behind it, so every feature in the palette and the
223    /// history tree rendered as tofu. A mapping test cannot catch that; only
224    /// checking the catalog can.
225    /// The shared palette the 42 feature icons are drawn from, documented in
226    /// `BREP_app/assets/glyphs/README.md`. Hue says what a thing IS — gold solid
227    /// material, blue reference and placement, green a curve or added material,
228    /// steel sheet metal, violet a second instance, red material removed.
229    ///
230    /// `_HI`/`_LO` tones are bodies that sit UNDER ink and pair with the deep
231    /// `_INK`; `_MID` is ink drawn on NO body, dark enough to read on a light
232    /// panel and light enough on a dark one, which a deep tone is not.
233    const FEATURE_PALETTE: &[&str] = &[
234        "#ffd977", "#f0b840", "#d4901f", "#c9871a", "#7a4d10", // gold   — solid material
235        "#a8d4f7", "#3d86c4", "#1c5384", // blue   — reference & placement
236        "#a5e8c4", "#2fa469", "#125236", // green  — curves & added material
237        "#bcd7e8", "#5c86a6", "#2b4c63", // steel  — sheet metal
238        "#c9b6f0", "#8b6fd4", "#3d2a6b", // violet — a second instance
239        "#d94438",                       // red    — material removed
240    ];
241
242    /// The 42 feature icons are ONE set, not 42 drawings: every fill in every one
243    /// of them comes from [`FEATURE_PALETTE`].
244    ///
245    /// This is what keeps them consistent as they are edited. A glyph hand-tuned
246    /// in an SVG editor picks up whatever colour the editor's swatch had, and
247    /// nothing else here would notice — `mono` only asks whether a fill is the
248    /// `#111` sentinel, so any off-system colour passes every other test in this
249    /// file while quietly breaking the set.
250    #[test]
251    fn every_feature_icon_is_colour_from_the_shared_palette() {
252        for ch in FEATURES {
253            let icon =
254                lookup(ch).unwrap_or_else(|| panic!("U+{:04X} is not catalogued", ch as u32));
255            assert!(!icon.mono, "{}: feature artwork must carry its own colours", icon.name);
256            for fill in fills(icon.svg) {
257                assert!(
258                    FEATURE_PALETTE.contains(&fill),
259                    "{}: {fill} is not in the feature palette",
260                    icon.name
261                );
262            }
263        }
264    }
265
266    #[test]
267    fn every_feature_type_has_catalogued_artwork() {
268        let catalogue = brep_render::features::feature_catalogue();
269        let features = catalogue["features"].as_array().expect("catalogue features");
270        assert!(!features.is_empty(), "empty feature catalogue");
271
272        let missing: Vec<String> = features
273            .iter()
274            .filter_map(|feature| {
275                let ty = feature["type"].as_str()?;
276                match brep_render::features::feature_icon(ty) {
277                    Some(ch) if has(ch) => None,
278                    Some(ch) => Some(format!("{ty}: U+{:04X} has no glyph", ch as u32)),
279                    None => Some(format!("{ty}: no icon mapping at all")),
280                }
281            })
282            .collect();
283
284        assert!(missing.is_empty(), "features without artwork: {missing:#?}");
285    }
286
287    #[test]
288    fn aspects_are_sane() {
289        for icon in ICONS {
290            assert!(
291                icon.aspect > 0.05 && icon.aspect < 5.0,
292                "{}: implausible aspect {}",
293                icon.name,
294                icon.aspect
295            );
296        }
297    }
298
299    #[test]
300    fn uris_are_unique_and_svg_suffixed() {
301        let mut seen = std::collections::HashSet::new();
302        for icon in ICONS {
303            assert!(icon.uri.ends_with(".svg"), "{}: loader needs a .svg URI", icon.name);
304            assert!(seen.insert(icon.uri), "{}: duplicate URI {}", icon.name, icon.uri);
305        }
306    }
307}