Skip to main content

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(&ltr).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(&ltr).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}