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}