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}