frus_widgets/icons/mod.rs
1//! The **bundled icon set**: the whole material icon vocabulary, reachable by name as
2//! [`Icons`] constants — `Icons::ADD`, `Icons::STAR`, `Icons::ARROW_BACK` — and an
3//! escape hatch, [`IconData::custom`], for a mark the set does not have.
4//!
5//! # What an icon is here
6//!
7//! An [`IconData`] is a **value**, not a widget: it says *which* icon, and nothing about
8//! size or colour. [`crate::Icon`] is what draws one, scaling its path onto the box the
9//! layout gave it and colouring it from the theme. The same `IconData` therefore serves
10//! an app bar action, a list tile's leading slot and a button's glyph without any of
11//! them agreeing on anything but the name.
12//!
13//! Every icon is a **filled** silhouette on a `24 × 24` grid, y downwards, drawn by the
14//! non-zero rule — a ring is an outer contour plus an inner one wound the other way, not
15//! a stroke.
16//!
17//! # The four styles
18//!
19//! `Icons::ADD` is the **filled** icon, and it is always there. The set also draws each
20//! icon outlined, rounded and sharp, and those sit behind a cargo feature apiece —
21//! `icons-outlined`, `icons-rounded`, `icons-sharp` — because four times the artwork is
22//! 1.3 MB and most applications want one of them at most:
23//!
24//! ```toml
25//! frus = { version = "0.3", features = ["icons-outlined"] }
26//! ```
27//!
28//! With the feature on, `Icons::ADD_OUTLINED` exists and [`Icons::by_name`] answers
29//! `"add_outlined"`. Without it neither does — `by_name` returns `None` rather than
30//! falling back to a different drawing, because an application that asked for the
31//! outlined set and silently got the filled one would ship looking wrong.
32//!
33//! The pairing is what the styles are for: an outlined icon at rest and its filled twin
34//! when selected is how a navigation bar says which destination you are on.
35//!
36//! # Where the paths live
37//!
38//! The outlines are not Rust source. They sit in `assets/material-icons.bin` and one
39//! file per variant style — a compact blob of about 300 KiB for the 2 233 filled icons,
40//! roughly 140 bytes each — decoded to a [`Path`] on demand. Two reasons for a blob
41//! rather than generated code: 2 233 constant path expressions is a megabyte of source
42//! that slows every build down, and the coordinates are exact integers in font units, so
43//! nothing is lost by storing them as integers.
44//!
45//! Decoding is cheap (a linear walk over a few hundred bytes) but it is not free. A
46//! caller that paints the same icon thousands of times per frame should hold the [`Path`]
47//! rather than call [`IconData::path`] in a loop.
48//!
49//! # Your own icons
50//!
51//! ```
52//! use frus_widgets::{IconData, Icon};
53//! use frus_core::{Path, Point, Rect};
54//!
55//! // A `24 × 24` path, y downwards, filled by the non-zero rule — the same contract the
56//! // bundled ones honour.
57//! fn lozenge() -> Path {
58//! Path::rect(Rect::new(4.0, 8.0, 16.0, 8.0))
59//! }
60//!
61//! const LOZENGE: IconData = IconData::custom(lozenge);
62//! let _ = Icon::new(LOZENGE).size(32.0);
63//! ```
64//!
65//! The artwork is the material icon set; see `assets/README.md` for its licence.
66
67use frus_core::{Path, Point, TextDirection};
68
69mod animated;
70mod names;
71
72pub use animated::{AnimatedIconData, AnimatedIcons};
73
74const MAGIC: &[u8; 8] = b"FRUSICO1";
75
76/// One style's outline blob: a signature, a header, an offset table, and one byte-code
77/// stream per icon. See the module docs for the format, and `scripts/gen_icons.py` for
78/// the generator that writes it.
79#[derive(Clone, Copy)]
80struct Blob(&'static [u8]);
81
82impl Blob {
83 /// Reads a little-endian `u32`.
84 const fn le_u32(self, at: usize) -> u32 {
85 let b = self.0;
86 u32::from_le_bytes([b[at], b[at + 1], b[at + 2], b[at + 3]])
87 }
88
89 /// How many icons this style holds.
90 const fn count(self) -> usize {
91 self.le_u32(8) as usize
92 }
93
94 /// The font em the coordinates are expressed in.
95 const fn upem(self) -> f32 {
96 self.le_u32(12) as f32
97 }
98
99 /// The side of the square an icon is drawn on, before the widget scales it.
100 const fn grid(self) -> f32 {
101 self.le_u32(16) as f32
102 }
103
104 /// Where the offset table ends and the streams begin.
105 const fn data(self) -> usize {
106 8 + 12 + 4 * (self.count() + 1)
107 }
108
109 /// The offsets of icon `index`'s stream.
110 const fn stream(self, index: usize) -> (usize, usize) {
111 let table = 8 + 12 + 4 * index;
112 (
113 self.data() + self.le_u32(table) as usize,
114 self.data() + self.le_u32(table + 4) as usize,
115 )
116 }
117
118 /// Every blob is checked **at compile time**: its signature, the grid and em it was
119 /// generated on, and that its offset table ends exactly where the file does. A build
120 /// that picked up a stale or foreign file would otherwise decode it into nonsense,
121 /// silently, in every icon on screen.
122 const fn check(self) -> bool {
123 assert!(self.0.len() >= 20, "the icon blob is truncated");
124 let mut i = 0;
125 while i < 8 {
126 assert!(
127 self.0[i] == MAGIC[i],
128 "the icon blob is not a frus icon blob"
129 );
130 i += 1;
131 }
132 assert!(
133 self.0.len() == self.data() + self.le_u32(20 + 4 * self.count()) as usize,
134 "the icon blob's offset table and its data disagree"
135 );
136 assert!(
137 self.upem() == FILLED.upem() && self.grid() == FILLED.grid(),
138 "the styles were generated on different grids"
139 );
140 true
141 }
142}
143
144const FILLED: Blob = Blob(include_bytes!("../../assets/material-icons.bin"));
145#[cfg(feature = "icons-outlined")]
146const OUTLINED: Blob = Blob(include_bytes!("../../assets/material-icons-outlined.bin"));
147#[cfg(feature = "icons-rounded")]
148const ROUNDED: Blob = Blob(include_bytes!("../../assets/material-icons-rounded.bin"));
149#[cfg(feature = "icons-sharp")]
150const SHARP: Blob = Blob(include_bytes!("../../assets/material-icons-sharp.bin"));
151
152const _: () = {
153 assert!(FILLED.check());
154 #[cfg(feature = "icons-outlined")]
155 assert!(OUTLINED.check());
156 #[cfg(feature = "icons-rounded")]
157 assert!(ROUNDED.check());
158 #[cfg(feature = "icons-sharp")]
159 assert!(SHARP.check());
160};
161
162/// The side of the square an icon is drawn on, before the widget scales it. Read from
163/// the blob rather than written here, so the grid the paths were generated on and the
164/// grid the widgets scale from cannot drift apart.
165pub(crate) const GRID: f32 = FILLED.grid();
166
167/// **How an icon is drawn.** The filled set is always bundled; the other three sit
168/// behind a cargo feature apiece, and asking for one that is not compiled in yields
169/// nothing rather than a different drawing.
170#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
171pub enum IconStyle {
172 /// The solid silhouette — the default, and the only one always bundled.
173 Filled,
174 /// The line drawing. Needs the `icons-outlined` feature.
175 Outlined,
176 /// Softened corners. Needs the `icons-rounded` feature.
177 Rounded,
178 /// Squared-off corners. Needs the `icons-sharp` feature.
179 Sharp,
180}
181
182impl IconStyle {
183 /// This style's blob, or `None` when the feature that carries it is off. Nothing can
184 /// reach that case through the generated names — a style's constants are compiled
185 /// out with its blob — but [`Icons::by_name`] can be handed the name of one.
186 fn blob(self) -> Option<Blob> {
187 match self {
188 IconStyle::Filled => Some(FILLED),
189 #[cfg(feature = "icons-outlined")]
190 IconStyle::Outlined => Some(OUTLINED),
191 #[cfg(feature = "icons-rounded")]
192 IconStyle::Rounded => Some(ROUNDED),
193 #[cfg(feature = "icons-sharp")]
194 IconStyle::Sharp => Some(SHARP),
195 #[cfg(not(all(
196 feature = "icons-outlined",
197 feature = "icons-rounded",
198 feature = "icons-sharp"
199 )))]
200 _ => None,
201 }
202 }
203
204 /// The suffix a caller writes after an icon's name to ask for this style — what
205 /// [`Icons::by_name`] recognises, and what the constants are named with:
206 /// `Icons::ADD_OUTLINED` is `"add"` plus `IconStyle::Outlined.suffix()`.
207 pub const fn suffix(self) -> &'static str {
208 match self {
209 IconStyle::Filled => "",
210 IconStyle::Outlined => "_outlined",
211 IconStyle::Rounded => "_rounded",
212 IconStyle::Sharp => "_sharp",
213 }
214 }
215}
216
217// The opcodes. A `_D` op carries signed byte deltas from the current point; an `_A` op
218// carries absolute `i16` coordinates, for the rare jump a byte cannot express.
219const OP_CLOSE: u8 = 0;
220const OP_MOVE_D: u8 = 1;
221const OP_MOVE_A: u8 = 2;
222const OP_LINE_D: u8 = 3;
223const OP_LINE_A: u8 = 4;
224const OP_CUBIC_D: u8 = 5;
225const OP_CUBIC_A: u8 = 6;
226
227/// **Which icon** — one of the bundled set, or a path of the caller's own.
228///
229/// It is `Copy` and cheap to pass around; the path behind it is built only when
230/// [`IconData::path`] or [`IconData::placed`] is called.
231///
232/// An icon also says whether it **carries a direction**. An arrow, an indent, a reply
233/// and a chevron all point somewhere, and where they point is relative to the reading
234/// order: in a right-to-left order, *back* is to the right. A tick, a star and a
235/// magnifying glass point nowhere and must not be touched. Which is which is not a
236/// judgement a widget can make, so it is a property of the icon —
237/// [`IconData::matches_text_direction`] — and 76 of the bundled 2 233 carry it.
238#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
239pub struct IconData {
240 source: Source,
241 /// Whether the icon is mirrored in a right-to-left reading order.
242 directional: bool,
243}
244
245/// Where an icon's outline comes from. Private: a caller reaches the bundled ones
246/// through [`Icons`] and supplies its own through [`IconData::custom`], so there is no
247/// third case to name.
248#[derive(Clone, Copy, Debug)]
249enum Source {
250 /// A style, and an index into that style's blob.
251 Bundled(IconStyle, u16),
252 /// A function that draws the outline on the `24 × 24` grid.
253 Custom(fn() -> Path),
254 /// A function that draws the outline **at a point in an animation**, and where in it.
255 /// Reached through [`AnimatedIconData::at`](crate::AnimatedIconData::at) — an icon
256 /// caught at one `t` is an ordinary icon, which is what lets a morph be painted by
257 /// every widget that paints an icon without any of them being told about morphs.
258 Morph(fn(f32) -> Path, f32),
259}
260
261/// Equality is hand-written because one of the two cases is a function pointer, and the
262/// derived comparison of those is not meaningful: the compiler may give two identical
263/// functions the same address, or the same function two addresses across codegen units.
264/// [`std::ptr::fn_addr_eq`] is the sanctioned comparison — it can answer `true` for two
265/// functions that were merged, and never `false` for one function compared with itself,
266/// which is the direction that matters for keying a cache on an icon.
267impl PartialEq for Source {
268 fn eq(&self, other: &Self) -> bool {
269 match (self, other) {
270 (Source::Bundled(sa, a), Source::Bundled(sb, b)) => sa == sb && a == b,
271 (Source::Custom(a), Source::Custom(b)) => std::ptr::fn_addr_eq(*a, *b),
272 // The position is compared by **bits**, so that it agrees with `Hash` and so
273 // that `Eq` holds: a NaN would otherwise make an icon unequal to itself.
274 // `AnimatedIconData::at` never produces one, and this is the reason.
275 (Source::Morph(a, ta), Source::Morph(b, tb)) => {
276 std::ptr::fn_addr_eq(*a, *b) && ta.to_bits() == tb.to_bits()
277 }
278 _ => false,
279 }
280 }
281}
282
283impl Eq for Source {}
284
285impl std::hash::Hash for Source {
286 fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
287 match self {
288 Source::Bundled(style, index) => (0u8, *style, *index as usize).hash(state),
289 // Equal pointers hash alike, which is what `Hash` and `Eq` have to agree on.
290 Source::Custom(draw) => (1u8, *draw as usize).hash(state),
291 Source::Morph(draw, t) => (2u8, *draw as usize, t.to_bits()).hash(state),
292 }
293 }
294}
295
296impl IconData {
297 /// The `n`th icon of one style's blob. Only the generated names call this, which is
298 /// why it is not public: an index is meaningless outside the file that wrote it.
299 pub(crate) const fn bundled(style: IconStyle, index: u16) -> Self {
300 Self {
301 source: Source::Bundled(style, index),
302 directional: false,
303 }
304 }
305
306 /// An icon of the caller's own, drawn by `draw` on the same `24 × 24` grid, y
307 /// downwards, and filled by the non-zero rule.
308 ///
309 /// A function pointer and not a `Path`, so that an icon stays a `const` — a caller's
310 /// icon is declared exactly where a bundled one would be, and costs nothing until it
311 /// is painted.
312 pub const fn custom(draw: fn() -> Path) -> Self {
313 Self {
314 source: Source::Custom(draw),
315 directional: false,
316 }
317 }
318
319 /// One frame of an animated pair. Not public: a caller reaches it through
320 /// [`AnimatedIconData::at`](crate::AnimatedIconData::at), which is what clamps the
321 /// position and so what makes the equality above total.
322 pub(crate) const fn morphing(draw: fn(f32) -> Path, t: f32, directional: bool) -> Self {
323 Self {
324 source: Source::Morph(draw, t),
325 directional,
326 }
327 }
328
329 /// The same icon, declared to **carry a direction**: [`IconData::placed`] mirrors it
330 /// when the reading order is right-to-left.
331 ///
332 /// `const`, so a caller's directional icon is still a constant:
333 ///
334 /// ```
335 /// # use frus_widgets::IconData;
336 /// # use frus_core::{Path, Rect};
337 /// # fn arrow() -> Path { Path::rect(Rect::new(4.0, 10.0, 16.0, 4.0)) }
338 /// const BACK: IconData = IconData::custom(arrow).mirrored();
339 /// assert!(BACK.matches_text_direction());
340 /// ```
341 pub const fn mirrored(self) -> Self {
342 Self {
343 source: self.source,
344 directional: true,
345 }
346 }
347
348 /// Whether this icon is turned round for a right-to-left reading order.
349 pub const fn matches_text_direction(self) -> bool {
350 self.directional
351 }
352
353 /// The icon's outline on the `24 × 24` grid, ready to be scaled and filled.
354 ///
355 /// The raw artwork, in the direction it was drawn. [`IconData::placed`] is what a
356 /// widget wants: it is this, sized, positioned, and turned round where it should be.
357 pub fn path(self) -> Path {
358 match self.source {
359 Source::Bundled(style, index) => decode(style, index as usize),
360 Source::Custom(draw) => draw(),
361 Source::Morph(draw, t) => draw(t),
362 }
363 }
364
365 /// How this icon is drawn, or `None` for a caller's own — which is drawn however its
366 /// function draws it.
367 pub fn style(self) -> Option<IconStyle> {
368 match self.source {
369 Source::Bundled(style, _) => Some(style),
370 Source::Custom(_) | Source::Morph(..) => None,
371 }
372 }
373
374 /// The icon's outline **scaled to `side` and placed at `(x, y)`** — the top-left of
375 /// the square it occupies — and mirrored within that square when it carries a
376 /// direction and `direction` is right-to-left.
377 ///
378 /// Every widget that paints an icon goes through here. That is the point: the rule
379 /// about which icons turn round is stated once, in the icon, and applied once, here,
380 /// rather than remembered at each of the sixteen places that fill an icon's path.
381 pub fn placed(self, side: f32, x: f32, y: f32, direction: TextDirection) -> Path {
382 let path = self.path().scaled(side / GRID).translated(x, y);
383 if self.directional && direction == TextDirection::Rtl {
384 // Reflected in the vertical centre line of the square it was just placed in,
385 // so a mirrored icon occupies exactly the box an unmirrored one would.
386 path.mirrored_x(x + side * 0.5)
387 } else {
388 path
389 }
390 }
391
392 /// Whether this icon comes from the bundled set rather than from a caller.
393 pub fn is_bundled(self) -> bool {
394 matches!(self.source, Source::Bundled(..))
395 }
396}
397
398/// The **bundled icon set**, as one constant per icon: `Icons::ADD`, `Icons::STAR`,
399/// `Icons::ARROW_BACK`, and 2 230 more — four times over, where the variant styles
400/// are compiled in.
401///
402/// The names are the icon set's own, in Rust's constant case — `arrow_back` is
403/// `Icons::ARROW_BACK`. Names that begin with a digit are spelled out, because an
404/// identifier cannot: `Icons::TEN_K`, `Icons::FOUR_K_PLUS`.
405///
406/// ```
407/// use frus_widgets::Icons;
408///
409/// let icon = Icons::FAVORITE;
410/// assert!(!icon.path().is_empty());
411/// assert_eq!(Icons::by_name("favorite"), Some(icon));
412/// ```
413#[derive(Clone, Copy, Debug)]
414pub struct Icons;
415
416impl Icons {
417 /// How many **filled** icons the bundled set holds — the styles behind a feature
418 /// have a few less each, since the set does not draw quite every icon four times.
419 pub const COUNT: usize = FILLED.count();
420
421 /// The icon called `name`, or `None` if the set has no such icon **compiled in**.
422 ///
423 /// The name is the set's own spelling — lower case with underscores, as in
424 /// `"arrow_back"` — which is what a configuration file or a design tool exports. A
425 /// style is asked for by its suffix, `"arrow_back_outlined"`, and answers `None`
426 /// unless that style's feature is on: an application that asked for the outlined set
427 /// and silently got the filled one would ship looking wrong, and a `None` it can
428 /// report is better than a drawing nobody chose.
429 pub fn by_name(name: &str) -> Option<IconData> {
430 fn find(table: &'static [(&'static str, IconData)], name: &str) -> Option<IconData> {
431 table
432 .binary_search_by(|(known, _)| (*known).cmp(name))
433 .ok()
434 .map(|i| table[i].1)
435 }
436 // The suffixes cannot collide with a filled name: `insert_chart_outlined` is a
437 // real icon, and it is looked for in the filled table first, where it is.
438 if let Some(icon) = find(&names::FILLED, name) {
439 return Some(icon);
440 }
441 #[cfg(feature = "icons-outlined")]
442 if let Some(icon) = find(&names::OUTLINED, name) {
443 return Some(icon);
444 }
445 #[cfg(feature = "icons-rounded")]
446 if let Some(icon) = find(&names::ROUNDED, name) {
447 return Some(icon);
448 }
449 #[cfg(feature = "icons-sharp")]
450 if let Some(icon) = find(&names::SHARP, name) {
451 return Some(icon);
452 }
453 None
454 }
455
456 /// Every bundled icon as `(name, icon)` — for a picker, a gallery, or anything else
457 /// that has to show the whole set. Sorted within each style, filled first, and the
458 /// styles that are not compiled in are simply not there.
459 pub fn all() -> impl Iterator<Item = (&'static str, IconData)> {
460 let styles: [&'static [(&'static str, IconData)]; 4] = [
461 &names::FILLED,
462 #[cfg(feature = "icons-outlined")]
463 &names::OUTLINED,
464 #[cfg(not(feature = "icons-outlined"))]
465 &[],
466 #[cfg(feature = "icons-rounded")]
467 &names::ROUNDED,
468 #[cfg(not(feature = "icons-rounded"))]
469 &[],
470 #[cfg(feature = "icons-sharp")]
471 &names::SHARP,
472 #[cfg(not(feature = "icons-sharp"))]
473 &[],
474 ];
475 styles.into_iter().flatten().copied()
476 }
477
478 /// The icons of one style, or an empty run when that style is not compiled in.
479 pub fn of_style(style: IconStyle) -> impl Iterator<Item = (&'static str, IconData)> {
480 Self::all().filter(move |(_, icon)| icon.style() == Some(style))
481 }
482}
483
484/// Walks one icon's byte stream into a [`Path`] on the `24 × 24` grid.
485///
486/// The font's y axis points up and the grid's points down, so every y is mirrored on the
487/// way out. Doing it here, once, is what lets the rest of the framework treat an icon
488/// path like any other path it drew itself.
489fn decode(style: IconStyle, index: usize) -> Path {
490 // A style whose feature is off, or an index past the end: an empty path, not a
491 // panic and not somebody else's bytes read as coordinates.
492 let Some(blob) = style.blob() else {
493 return Path::new();
494 };
495 if index >= blob.count() {
496 return Path::new();
497 }
498 let (mut p, end) = blob.stream(index);
499 let bytes = blob.0;
500 let scale = blob.grid() / blob.upem();
501 let grid = blob.grid();
502 let (mut cx, mut cy) = (0i32, 0i32);
503 let mut path = Path::new();
504 // Font units to grid, y mirrored.
505 let point = |x: i32, y: i32| Point::new(x as f32 * scale, grid - y as f32 * scale);
506 let i8_at = |at: usize| bytes[at] as i8 as i32;
507 let i16_at = |at: usize| i16::from_le_bytes([bytes[at], bytes[at + 1]]) as i32;
508
509 while p < end {
510 let op = bytes[p];
511 p += 1;
512 match op {
513 OP_CLOSE => path = path.close(),
514 OP_MOVE_D | OP_LINE_D => {
515 cx += i8_at(p);
516 cy += i8_at(p + 1);
517 p += 2;
518 let to = point(cx, cy);
519 path = if op == OP_MOVE_D {
520 path.move_to(to)
521 } else {
522 path.line_to(to)
523 };
524 }
525 OP_MOVE_A | OP_LINE_A => {
526 cx = i16_at(p);
527 cy = i16_at(p + 2);
528 p += 4;
529 let to = point(cx, cy);
530 path = if op == OP_MOVE_A {
531 path.move_to(to)
532 } else {
533 path.line_to(to)
534 };
535 }
536 OP_CUBIC_D => {
537 let d = [
538 i8_at(p),
539 i8_at(p + 1),
540 i8_at(p + 2),
541 i8_at(p + 3),
542 i8_at(p + 4),
543 i8_at(p + 5),
544 ];
545 p += 6;
546 let (x1, y1) = (cx + d[0], cy + d[1]);
547 let (x2, y2) = (x1 + d[2], y1 + d[3]);
548 cx = x2 + d[4];
549 cy = y2 + d[5];
550 path = path.cubic_to(point(x1, y1), point(x2, y2), point(cx, cy));
551 }
552 OP_CUBIC_A => {
553 let (x1, y1) = (i16_at(p), i16_at(p + 2));
554 let (x2, y2) = (i16_at(p + 4), i16_at(p + 6));
555 cx = i16_at(p + 8);
556 cy = i16_at(p + 10);
557 p += 12;
558 path = path.cubic_to(point(x1, y1), point(x2, y2), point(cx, cy));
559 }
560 // Unreachable for a blob this build shipped with; a stream that goes wrong
561 // stops here rather than reading the next icon's bytes as coordinates.
562 _ => break,
563 }
564 }
565 path
566}
567
568#[cfg(test)]
569mod tests {
570 use super::*;
571 use frus_core::PathVerb;
572
573 /// The blobs a build was compiled with, and the styles it names, are two files that
574 /// have to agree. The signature, the grid and the offset table are checked at compile
575 /// time (see the `const` block above); what a compiler cannot see is the generated
576 /// names agreeing with the blobs they were written from.
577 #[test]
578 fn every_compiled_style_agrees_with_its_blob() {
579 assert_eq!(FILLED.upem(), 512.0);
580 assert_eq!(GRID, 24.0);
581 for (style, table) in compiled_styles() {
582 let blob = style.blob().expect("a compiled style has a blob");
583 assert_eq!(
584 blob.count(),
585 table.len(),
586 "{style:?}: blob and names disagree on the count"
587 );
588 // The last offset lands exactly on the end of the file, or the table and the
589 // data have drifted apart.
590 let (_, end) = blob.stream(blob.count() - 1);
591 assert_eq!(end, blob.0.len(), "{style:?}");
592 }
593 assert_eq!(Icons::COUNT, names::FILLED.len());
594 }
595
596 /// A style's names all carry its suffix, and only its own: an entry filed under the
597 /// wrong table would be reachable by a name that draws something else.
598 #[test]
599 fn each_table_carries_its_own_suffix() {
600 for (style, table) in compiled_styles() {
601 for (name, icon) in table {
602 assert_eq!(icon.style(), Some(style), "{name} is filed under {style:?}");
603 if !style.suffix().is_empty() {
604 assert!(
605 name.ends_with(style.suffix()),
606 "{name} should end with {}",
607 style.suffix()
608 );
609 }
610 }
611 }
612 }
613
614 /// Every style compiled into this build, with its name table. A style whose feature
615 /// is off has no blob, and drops out here.
616 fn compiled_styles() -> Vec<(IconStyle, &'static [(&'static str, IconData)])> {
617 let styles: [(IconStyle, &'static [(&'static str, IconData)]); 4] = [
618 (IconStyle::Filled, &names::FILLED),
619 #[cfg(feature = "icons-outlined")]
620 (IconStyle::Outlined, &names::OUTLINED),
621 #[cfg(not(feature = "icons-outlined"))]
622 (IconStyle::Outlined, &[]),
623 #[cfg(feature = "icons-rounded")]
624 (IconStyle::Rounded, &names::ROUNDED),
625 #[cfg(not(feature = "icons-rounded"))]
626 (IconStyle::Rounded, &[]),
627 #[cfg(feature = "icons-sharp")]
628 (IconStyle::Sharp, &names::SHARP),
629 #[cfg(not(feature = "icons-sharp"))]
630 (IconStyle::Sharp, &[]),
631 ];
632 styles
633 .into_iter()
634 .filter(|(style, _)| style.blob().is_some())
635 .collect()
636 }
637
638 /// Every single icon, walked. This is the test that a corrupt or truncated blob
639 /// cannot survive, and it is why the blob is checked in with its generator.
640 #[test]
641 fn every_icon_decodes_to_a_closed_filled_outline() {
642 for (name, icon) in Icons::all() {
643 let path = icon.path();
644 assert!(!path.is_empty(), "{name} decoded to nothing");
645 assert!(
646 matches!(path.verbs().first(), Some(PathVerb::MoveTo(_))),
647 "{name} should start with a MoveTo"
648 );
649 assert!(
650 matches!(path.verbs().last(), Some(PathVerb::Close)),
651 "{name} should end closed — an unclosed contour fills wrong"
652 );
653 }
654 }
655
656 /// Every icon stays on its grid. An icon that spilled would not be clipped, it would
657 /// be *drawn over its neighbours*, which is the kind of thing that only shows up in a
658 /// screenshot months later.
659 ///
660 /// The two kinds of point are held to different bounds on purpose. **On-curve points
661 /// are within the box to a single font unit** — the whole filled, outlined and
662 /// rounded sets sit exactly inside it, and a handful of sharp ones overhang by one
663 /// unit of the 512 the artwork was drawn on, which is the artwork's own doing and a
664 /// thirtieth of a pixel at the size an icon is used. Anything more is a mirrored axis
665 /// or a lost scale. A **control** point may sit further out: that is how a curve is
666 /// given its bend, and it says nothing about where the ink lands. It is still
667 /// bounded, because a control point a long way out is a decoding error, not a design.
668 #[test]
669 fn every_icon_stays_within_its_grid() {
670 let unit = GRID / FILLED.upem();
671 let control_slack = 2.0;
672 for (name, icon) in Icons::all() {
673 for verb in icon.path().verbs() {
674 let (controls, ends): (Vec<Point>, Vec<Point>) = match verb {
675 PathVerb::MoveTo(p) | PathVerb::LineTo(p) => (vec![], vec![*p]),
676 PathVerb::QuadTo { ctrl, to } => (vec![*ctrl], vec![*to]),
677 PathVerb::CubicTo { c1, c2, to } => (vec![*c1, *c2], vec![*to]),
678 PathVerb::Close => (vec![], vec![]),
679 };
680 for p in ends {
681 assert!(
682 p.x >= -unit && p.x <= GRID + unit && p.y >= -unit && p.y <= GRID + unit,
683 "{name} has an on-curve point off the grid at {p:?}"
684 );
685 }
686 for p in controls {
687 assert!(
688 p.x >= -control_slack && p.x <= GRID + control_slack,
689 "{name} has a control point far off the grid at {p:?}"
690 );
691 assert!(
692 p.y >= -control_slack && p.y <= GRID + control_slack,
693 "{name} has a control point far off the grid at {p:?}"
694 );
695 }
696 }
697 }
698 }
699
700 /// `by_name` is a binary search, so each table has to be sorted — and a duplicate
701 /// name would make one of the two unreachable.
702 #[test]
703 fn each_name_table_is_sorted_and_unique() {
704 for (style, table) in compiled_styles() {
705 let names: Vec<&str> = table.iter().map(|(n, _)| *n).collect();
706 assert!(
707 names.windows(2).all(|w| w[0] < w[1]),
708 "{style:?}: the name table must ascend with no duplicates"
709 );
710 }
711 assert_eq!(names::FILLED.len(), Icons::COUNT);
712 }
713
714 /// The two names that read like a style suffix but are not one. `insert_chart_outlined`
715 /// is a filled icon in its own right, and its outlined variant is
716 /// `insert_chart_outlined_outlined` — which is exactly the pair a suffix-stripping
717 /// lookup gets wrong, so the lookup does not strip suffixes.
718 #[test]
719 fn a_name_that_ends_like_a_suffix_is_still_its_own_icon() {
720 let chart = Icons::by_name("insert_chart_outlined").expect("a filled icon");
721 assert_eq!(chart, Icons::INSERT_CHART_OUTLINED);
722 assert_eq!(chart.style(), Some(IconStyle::Filled));
723 let wifi = Icons::by_name("wifi_tethering_error_rounded").expect("a filled icon");
724 assert_eq!(wifi.style(), Some(IconStyle::Filled));
725 // And the keyword-shaped one keeps the set's spelling, not a language's escape.
726 assert_eq!(Icons::by_name("class"), Some(Icons::CLASS));
727 }
728
729 #[test]
730 fn a_name_resolves_to_the_same_icon_as_its_constant() {
731 assert_eq!(Icons::by_name("add"), Some(Icons::ADD));
732 assert_eq!(Icons::by_name("arrow_back"), Some(Icons::ARROW_BACK));
733 assert_eq!(
734 Icons::by_name("visibility_off"),
735 Some(Icons::VISIBILITY_OFF)
736 );
737 assert_eq!(Icons::by_name("no_such_icon"), None);
738 // The constants are the set's own names, not ours: the digit-leading ones are
739 // spelled out because an identifier cannot start with a digit.
740 assert_eq!(Icons::by_name("ten_k"), Some(Icons::TEN_K));
741 }
742
743 /// A spot-check with a shape simple enough to state exactly: `add` is a plus, four
744 /// units thick, centred, spanning 5 to 19 on the grid. If the decoder mirrored the
745 /// wrong axis or lost the scale, this is where it shows.
746 #[test]
747 fn add_is_a_centred_plus() {
748 let path = Icons::ADD.path();
749 let points: Vec<Point> = path
750 .verbs()
751 .iter()
752 .filter_map(|v| match v {
753 PathVerb::MoveTo(p) | PathVerb::LineTo(p) => Some(*p),
754 _ => None,
755 })
756 .collect();
757 let (min_x, max_x) = bounds(points.iter().map(|p| p.x));
758 let (min_y, max_y) = bounds(points.iter().map(|p| p.y));
759 assert!((min_x - 5.0).abs() < 0.1, "left edge at {min_x}");
760 assert!((max_x - 19.0).abs() < 0.1, "right edge at {max_x}");
761 assert!((min_y - 5.0).abs() < 0.1, "top edge at {min_y}");
762 assert!((max_y - 19.0).abs() < 0.1, "bottom edge at {max_y}");
763 // Twelve corners, and the closing repeat of the first: a plus and nothing else.
764 assert_eq!(points.len(), 13);
765 }
766
767 fn bounds(values: impl Iterator<Item = f32>) -> (f32, f32) {
768 values.fold((f32::MAX, f32::MIN), |(lo, hi), v| (lo.min(v), hi.max(v)))
769 }
770
771 /// An icon of the caller's own goes through the same door as a bundled one.
772 #[test]
773 fn a_custom_icon_draws_its_own_path() {
774 fn bar() -> Path {
775 Path::rect(frus_core::Rect::new(4.0, 10.0, 16.0, 4.0))
776 }
777 const BAR: IconData = IconData::custom(bar);
778 assert!(!BAR.is_bundled());
779 assert!(Icons::ADD.is_bundled());
780 assert_eq!(BAR.path().verbs().len(), bar().verbs().len());
781 // Two icons drawn by the same function are the same icon; by a different one,
782 // not — which is what lets a caller key a cache on an `IconData`.
783 assert_eq!(BAR, IconData::custom(bar));
784 assert_ne!(BAR, Icons::ADD);
785 }
786
787 /// An index past the end, or a style this build does not carry, yields an empty path
788 /// rather than reading whatever bytes follow the table.
789 #[test]
790 fn an_index_past_the_end_is_empty() {
791 assert!(IconData::bundled(IconStyle::Filled, u16::MAX)
792 .path()
793 .is_empty());
794 for style in [IconStyle::Outlined, IconStyle::Rounded, IconStyle::Sharp] {
795 if style.blob().is_none() {
796 assert!(IconData::bundled(style, 0).path().is_empty());
797 assert_eq!(Icons::by_name(&format!("add{}", style.suffix())), None);
798 }
799 }
800 }
801
802 /// The filled set is always there, whatever features are on — the framework's own
803 /// widgets reach for it, so it cannot be optional.
804 #[test]
805 fn the_filled_style_is_never_optional() {
806 assert!(IconStyle::Filled.blob().is_some());
807 assert!(!Icons::ADD.path().is_empty());
808 assert_eq!(Icons::ADD.style(), Some(IconStyle::Filled));
809 }
810
811 /// Which icons carry a direction is the set's own answer, not ours. The count is
812 /// asserted because a generator that lost the flag would otherwise leave every arrow
813 /// pointing the wrong way in Arabic, and nothing would fail.
814 #[test]
815 fn the_directional_icons_are_the_ones_that_point_somewhere() {
816 for icon in [
817 Icons::ARROW_BACK,
818 Icons::ARROW_FORWARD,
819 Icons::CHEVRON_LEFT,
820 Icons::CHEVRON_RIGHT,
821 Icons::REPLY,
822 Icons::UNDO,
823 Icons::FORMAT_INDENT_INCREASE,
824 Icons::TRENDING_UP,
825 ] {
826 assert!(icon.matches_text_direction(), "{icon:?} points somewhere");
827 }
828 // A tick, a star and a magnifying glass point nowhere: mirroring them is a bug.
829 for icon in [Icons::CHECK, Icons::STAR, Icons::SEARCH, Icons::FAVORITE] {
830 assert!(!icon.matches_text_direction(), "{icon:?} points nowhere");
831 }
832 // 76 of the filled set. A variant style carries the same flags on the same
833 // names, so its count only differs where the set has no icon in that style —
834 // which is what this asserts rather than four hand-written numbers.
835 let filled: Vec<&str> = names::FILLED
836 .iter()
837 .filter(|(_, icon)| icon.matches_text_direction())
838 .map(|(name, _)| *name)
839 .collect();
840 assert_eq!(filled.len(), 76);
841 for (style, table) in compiled_styles() {
842 if style == IconStyle::Filled {
843 continue;
844 }
845 for base in &filled {
846 let spelled = format!("{base}{}", style.suffix());
847 if let Some(icon) = Icons::by_name(&spelled) {
848 assert!(
849 icon.matches_text_direction(),
850 "{spelled} should carry the direction its filled twin does"
851 );
852 }
853 }
854 let turning = table
855 .iter()
856 .filter(|(_, icon)| icon.matches_text_direction())
857 .count();
858 assert!(
859 turning <= filled.len() && turning + 2 >= filled.len(),
860 "{style:?} turns {turning} icons round, the filled set {}",
861 filled.len()
862 );
863 }
864 }
865
866 /// In a left-to-right reading order `placed` is exactly the scale-and-translate every
867 /// call site used to write by hand — which is what makes the refactor a no-op there.
868 #[test]
869 fn placed_is_the_old_scale_and_translate_in_ltr() {
870 for icon in [Icons::CHECK, Icons::ARROW_BACK] {
871 let placed = icon.placed(16.0, 3.0, 7.0, TextDirection::Ltr);
872 let by_hand = icon.path().scaled(16.0 / GRID).translated(3.0, 7.0);
873 assert_eq!(placed.verbs(), by_hand.verbs(), "{icon:?}");
874 }
875 }
876
877 /// Right-to-left turns a directional icon round **inside its own box**: every point
878 /// is reflected, and the box it occupies does not move. An icon that shifted while
879 /// mirroring would break every layout that reserved a square for it.
880 #[test]
881 fn rtl_mirrors_a_directional_icon_within_its_box() {
882 let (side, x, y) = (16.0, 3.0, 7.0);
883 let ltr = Icons::ARROW_BACK.placed(side, x, y, TextDirection::Ltr);
884 let rtl = Icons::ARROW_BACK.placed(side, x, y, TextDirection::Rtl);
885 assert_ne!(ltr.verbs(), rtl.verbs(), "a back arrow should turn round");
886
887 let axis = x + side * 0.5;
888 for (before, after) in points(<r).into_iter().zip(points(&rtl)) {
889 assert!((after.x - (2.0 * axis - before.x)).abs() < 1e-4);
890 assert!((after.y - before.y).abs() < 1e-4, "only x is reflected");
891 }
892 // The same square, before and after.
893 let (lo_ltr, hi_ltr) = bounds(points(<r).into_iter().map(|p| p.x));
894 let (lo_rtl, hi_rtl) = bounds(points(&rtl).into_iter().map(|p| p.x));
895 assert!((lo_ltr - (2.0 * axis - hi_rtl)).abs() < 1e-4);
896 assert!((hi_ltr - (2.0 * axis - lo_rtl)).abs() < 1e-4);
897 }
898
899 #[test]
900 fn rtl_leaves_an_icon_that_points_nowhere_alone() {
901 let ltr = Icons::CHECK.placed(16.0, 3.0, 7.0, TextDirection::Ltr);
902 let rtl = Icons::CHECK.placed(16.0, 3.0, 7.0, TextDirection::Rtl);
903 assert_eq!(ltr.verbs(), rtl.verbs());
904 }
905
906 /// A caller's own icon can say it points somewhere, and is then treated exactly as a
907 /// bundled one — the flag is on the icon, not in a table only the generator writes.
908 #[test]
909 fn a_custom_icon_can_carry_a_direction() {
910 fn wedge() -> Path {
911 Path::new()
912 .move_to(Point::new(4.0, 12.0))
913 .line_to(Point::new(20.0, 4.0))
914 .line_to(Point::new(20.0, 20.0))
915 .close()
916 }
917 const BACK: IconData = IconData::custom(wedge).mirrored();
918 const MARK: IconData = IconData::custom(wedge);
919 assert!(BACK.matches_text_direction());
920 assert!(!MARK.matches_text_direction());
921 let rtl = BACK.placed(24.0, 0.0, 0.0, TextDirection::Rtl);
922 // The tip was at x = 4; reflected about x = 12 it lands at x = 20.
923 assert!(
924 matches!(rtl.verbs().first(), Some(PathVerb::MoveTo(p)) if (p.x - 20.0).abs() < 1e-4)
925 );
926 assert_eq!(
927 MARK.placed(24.0, 0.0, 0.0, TextDirection::Rtl).verbs(),
928 MARK.placed(24.0, 0.0, 0.0, TextDirection::Ltr).verbs()
929 );
930 }
931
932 fn points(path: &Path) -> Vec<Point> {
933 path.verbs()
934 .iter()
935 .flat_map(|v| match v {
936 PathVerb::MoveTo(p) | PathVerb::LineTo(p) => vec![*p],
937 PathVerb::QuadTo { ctrl, to } => vec![*ctrl, *to],
938 PathVerb::CubicTo { c1, c2, to } => vec![*c1, *c2, *to],
939 PathVerb::Close => vec![],
940 })
941 .collect()
942 }
943}