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    /// SVG cropped to painted bounds, for standalone artwork in icon tiles.
46    pub artwork_svg: &'static str,
47    /// Aspect ratio of the painted artwork, excluding canvas padding.
48    pub artwork_aspect: f32,
49    /// Monochrome artwork that should take the colour of the surrounding text.
50    /// `false` means the SVG carries its own colours and must not be tinted.
51    pub mono: bool,
52}
53
54/// The icon for `ch`, or `None` if the catalog has none — in which case the
55/// character stays ordinary text and the font draws it.
56pub fn lookup(ch: char) -> Option<&'static Icon> {
57    let i = ICONS.binary_search_by_key(&ch, |icon| icon.ch).ok()?;
58    Some(&ICONS[i])
59}
60
61/// Whether `ch` has an icon. Cheaper to read at a call site than `lookup().is_some()`.
62pub fn has(ch: char) -> bool {
63    lookup(ch).is_some()
64}
65
66/// The catalogued artwork for a label that is exactly one character.
67///
68/// This is the test every icon-drawing site makes — toolbar buttons, tree rows,
69/// palette rows — so they all agree on what is an icon. `None` for a
70/// multi-character label (a composite, not an icon) and for an uncatalogued
71/// character; both are drawn as ordinary text.
72///
73/// MONOCHROME artwork is included. It used to be excluded, on the grounds that
74/// the icon font already drew it in the live text colour for free — but that
75/// exclusion was the last thing keeping a font in the binary. Callers tint a
76/// `mono` icon to their text colour (it is white in the catalog, so a multiply
77/// lands it exactly) and leave a colour one alone.
78pub fn artwork(glyph: &str) -> Option<&'static Icon> {
79    let mut chars = glyph.chars();
80    let icon = lookup(chars.next()?)?;
81    chars.next().is_none().then_some(icon)
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    #[test]
89    fn tile_artwork_fills_the_requested_medium_and_large_icon_sizes() {
90        // The cube's original font canvas is 2384 units tall, but its ink is
91        // only 955 units tall. Check painted pixels, not the enclosing widget.
92        let cube = lookup('\u{E032}').unwrap();
93        let painted_height = |svg: &str, height: u32| {
94            let image = egui_extras::image::load_svg_bytes_with_size(
95                svg.as_bytes(), egui::load::SizeHint::Height(height), &Default::default(),
96            ).unwrap();
97            let occupied: Vec<_> = image.pixels.chunks(image.width()).enumerate()
98                .filter(|(_, row)| row.iter().any(|pixel| pixel.a() > 127))
99                .map(|(y, _)| y).collect();
100            occupied.last().unwrap() - occupied.first().unwrap() + 1
101        };
102        let medium = painted_height(cube.artwork_svg, 36);
103        let large = painted_height(cube.artwork_svg, 56);
104        assert!(medium >= 34, "medium ink height: {medium}");
105        assert!(large >= 54, "large ink height: {large}");
106        assert!(large > medium);
107        assert!(painted_height(cube.svg, 56) < 28, "fixture must retain font padding");
108        for icon in ICONS {
109            egui_extras::image::load_svg_bytes_with_size(
110                icon.artwork_svg.as_bytes(), egui::load::SizeHint::Height(56), &Default::default(),
111            ).unwrap_or_else(|error| panic!("{}: {error}", icon.name));
112        }
113    }
114
115    /// The colour icons OUTSIDE the feature block — each composed by hand from
116    /// the layered private-use artwork the toolbar used to stack. Used by the
117    /// tests below from both directions, so adding a colour icon without saying
118    /// so here fails loudly rather than silently widening what is allowed.
119    /// [`is_colour`] is what the tests actually ask; the feature block is a
120    /// range rather than 42 more lines.
121    const COLOUR: &[char] = &[
122        '\u{270D}',  // pencil    — freehand sketch
123        '\u{1F41E}', // bug       — Submit Bug
124        '\u{1F4DA}', // book      — step.parts library
125        '\u{E010}',  // document  — New
126        '\u{E011}',  // folder    — Open
127        '\u{E012}',  // disk      — Save
128        '\u{E013}',  // disk+     — Save As
129        '\u{E014}',  // in-tray   — Import
130        '\u{E015}',  // out-tray  — Export
131        '\u{E016}',  // arrow     — Undo
132        '\u{E017}',  // arrow     — Redo
133        '\u{E018}',  // camera    — Projection
134    ];
135
136    /// Every kernel feature, U+E030-E059, is colour artwork — see
137    /// [`FEATURE_PALETTE`]. They are a range rather than 42 entries in
138    /// [`COLOUR`] because the rule is the block, not the individual icon.
139    const FEATURES: std::ops::RangeInclusive<char> = '\u{E030}'..='\u{E059}';
140
141    /// Whether an icon is expected to carry its own colours.
142    fn is_colour(ch: char) -> bool {
143        COLOUR.contains(&ch) || FEATURES.contains(&ch)
144    }
145
146    /// Every `fill="…"` value in an SVG. `build.rs` has the same three lines and
147    /// decides `mono` with them; this is the test side of the same question.
148    fn fills(svg: &str) -> impl Iterator<Item = &str> {
149        svg.match_indices("fill=\"").filter_map(|(i, m)| {
150            let rest = &svg[i + m.len()..];
151            rest.find('"').map(|end| &rest[..end])
152        })
153    }
154
155    #[test]
156    fn catalog_is_populated_and_sorted() {
157        // A floor, not the exact count: it catches `build.rs` emitting nothing
158        // or a half-scanned directory, without breaking every time a glyph is
159        // added or (as the colour conversions do) several are merged away.
160        assert!(ICONS.len() >= 50, "catalog looks truncated: {}", ICONS.len());
161        assert!(
162            ICONS.windows(2).all(|w| w[0].ch < w[1].ch),
163            "ICONS must be sorted by codepoint for `lookup` to binary-search it"
164        );
165    }
166
167    #[test]
168    fn lookup_finds_every_entry() {
169        for icon in ICONS {
170            assert_eq!(lookup(icon.ch), Some(icon), "lookup missed {}", icon.name);
171        }
172    }
173
174    #[test]
175    fn notdef_is_not_catalogued() {
176        // `.notdef` has no codepoint, so nothing in a string can select it.
177        assert!(ICONS.iter().all(|i| i.name != ".notdef"));
178    }
179
180    #[test]
181    fn uncatalogued_characters_stay_text() {
182        for ch in ['a', ' ', '1', '\n', 'é'] {
183            assert!(!has(ch), "{ch:?} must not be an icon");
184        }
185    }
186
187    /// The ink sentinel, both ways round: a monochrome glyph must have been
188    /// rewritten to white (so the widget's tint lands exactly), and a glyph with
189    /// authored colour must have been left completely alone (tinting it would
190    /// wash it out).
191    #[test]
192    fn the_ink_sentinel_rewrites_mono_glyphs_and_spares_coloured_ones() {
193        for icon in ICONS {
194            if icon.mono {
195                assert!(!icon.svg.contains("#111"), "{}: ink sentinel not rewritten", icon.name);
196                assert!(icon.svg.contains("#fff"), "{}: no white fill after rewrite", icon.name);
197            } else {
198                assert!(
199                    icon.svg.contains("fill=\""),
200                    "{}: a non-mono glyph must carry its own fills",
201                    icon.name
202                );
203            }
204        }
205    }
206
207    /// The colour lane is live, not just possible. Each of these was composed
208    /// from the layered private-use artwork the toolbar used to paint by hand,
209    /// so it carries that exact palette; a regression that flattened one back to
210    /// ink would otherwise pass every other test.
211    #[test]
212    fn the_composed_colour_icons_are_catalogued_in_colour() {
213        for (ch, fills) in [
214            ('\u{1F41E}', &["#e1544a", "#5e1a14"][..]), // bug        — Submit Bug
215            ('\u{1F4DA}', &["#2e9e8f", "#124840"][..]), // book       — step.parts
216            ('\u{270D}', &["#f2b33d", "#6e4a12"][..]),  // pencil     — freehand
217            ('\u{E010}', &["#70b7f6", "#1c5384"][..]),  // document   — New
218            ('\u{E011}', &["#cd8b19", "#fac437"][..]),  // folder     — Open
219            ('\u{E012}', &["#4597e5", "#13416c"][..]),  // disk       — Save
220            ('\u{E014}', &["#2fa469", "#7be8ae"][..]),  // in-tray    — Import
221            ('\u{E015}', &["#da762a", "#ffbb69"][..]),  // out-tray   — Export
222            ('\u{E018}', &["#40abda", "#d3f5ff"][..]),  // camera     — Projection
223        ] {
224            let icon = lookup(ch).unwrap_or_else(|| panic!("U+{:04X} is not catalogued", ch as u32));
225            assert!(!icon.mono, "{} must not be tinted — it has authored colour", icon.name);
226            for fill in fills {
227                assert!(icon.svg.contains(fill), "{}: lost its {fill} layer", icon.name);
228            }
229        }
230    }
231
232    /// The COLOUR list is exactly right, both ways round: every icon on it is
233    /// colour, and no icon off it is. That is what catches a stray fill edit
234    /// making an icon un-tintable, and equally a conversion that forgot to be
235    /// declared here.
236    #[test]
237    fn the_colour_set_is_exactly_the_declared_one() {
238        for ch in COLOUR.iter().copied().chain(FEATURES) {
239            let icon = lookup(ch).unwrap_or_else(|| panic!("U+{:04X} missing", ch as u32));
240            assert!(!icon.mono, "{} is declared colour but catalogued as ink", icon.name);
241        }
242        for icon in ICONS.iter().filter(|i| !is_colour(i.ch)) {
243            assert!(icon.mono, "{} carries colour but is not declared colour", icon.name);
244        }
245    }
246
247    /// Every feature the kernel catalogues must have real ARTWORK here, not just
248    /// a codepoint mapping.
249    ///
250    /// `brep_render` already tests that `feature_icon` returns a glyph for every
251    /// catalogue entry — and that test passed for a long time while NOTHING was
252    /// drawn: all 42 feature codepoints (U+E030-E059) mapped to a private-use
253    /// slot that had no glyph behind it, so every feature in the palette and the
254    /// history tree rendered as tofu. A mapping test cannot catch that; only
255    /// checking the catalog can.
256    /// The shared palette the 42 feature icons are drawn from, documented in
257    /// `BREP_app/assets/glyphs/README.md`. Hue says what a thing IS — gold solid
258    /// material, blue reference and placement, green a curve or added material,
259    /// steel sheet metal, violet a second instance, red material removed.
260    ///
261    /// `_HI`/`_LO` tones are bodies that sit UNDER ink and pair with the deep
262    /// `_INK`; `_MID` is ink drawn on NO body, dark enough to read on a light
263    /// panel and light enough on a dark one, which a deep tone is not.
264    const FEATURE_PALETTE: &[&str] = &[
265        "#ffd977", "#f0b840", "#d4901f", "#c9871a", "#7a4d10", // gold   — solid material
266        "#a8d4f7", "#3d86c4", "#1c5384", // blue   — reference & placement
267        "#a5e8c4", "#2fa469", "#125236", // green  — curves & added material
268        "#bcd7e8", "#5c86a6", "#2b4c63", // steel  — sheet metal
269        "#c9b6f0", "#8b6fd4", "#3d2a6b", // violet — a second instance
270        "#d94438",                       // red    — material removed
271    ];
272
273    /// The 42 feature icons are ONE set, not 42 drawings: every fill in every one
274    /// of them comes from [`FEATURE_PALETTE`].
275    ///
276    /// This is what keeps them consistent as they are edited. A glyph hand-tuned
277    /// in an SVG editor picks up whatever colour the editor's swatch had, and
278    /// nothing else here would notice — `mono` only asks whether a fill is the
279    /// `#111` sentinel, so any off-system colour passes every other test in this
280    /// file while quietly breaking the set.
281    #[test]
282    fn every_feature_icon_is_colour_from_the_shared_palette() {
283        for ch in FEATURES {
284            let icon =
285                lookup(ch).unwrap_or_else(|| panic!("U+{:04X} is not catalogued", ch as u32));
286            assert!(!icon.mono, "{}: feature artwork must carry its own colours", icon.name);
287            for fill in fills(icon.svg) {
288                assert!(
289                    FEATURE_PALETTE.contains(&fill),
290                    "{}: {fill} is not in the feature palette",
291                    icon.name
292                );
293            }
294        }
295    }
296
297    #[test]
298    fn every_feature_type_has_catalogued_artwork() {
299        let catalogue = brep_render::features::feature_catalogue();
300        let features = catalogue["features"].as_array().expect("catalogue features");
301        assert!(!features.is_empty(), "empty feature catalogue");
302
303        let missing: Vec<String> = features
304            .iter()
305            .filter_map(|feature| {
306                let ty = feature["type"].as_str()?;
307                match brep_render::features::feature_icon(ty) {
308                    Some(ch) if has(ch) => None,
309                    Some(ch) => Some(format!("{ty}: U+{:04X} has no glyph", ch as u32)),
310                    None => Some(format!("{ty}: no icon mapping at all")),
311                }
312            })
313            .collect();
314
315        assert!(missing.is_empty(), "features without artwork: {missing:#?}");
316    }
317
318    #[test]
319    fn aspects_are_sane() {
320        for icon in ICONS {
321            assert!(
322                icon.aspect > 0.05 && icon.aspect < 5.0,
323                "{}: implausible aspect {}",
324                icon.name,
325                icon.aspect
326            );
327        }
328    }
329
330    #[test]
331    fn uris_are_unique_and_svg_suffixed() {
332        let mut seen = std::collections::HashSet::new();
333        for icon in ICONS {
334            assert!(icon.uri.ends_with(".svg"), "{}: loader needs a .svg URI", icon.name);
335            assert!(seen.insert(icon.uri), "{}: duplicate URI {}", icon.name, icon.uri);
336        }
337    }
338}