Skip to main content

qframe/icons/
mod.rs

1//! Icon sets: every icon has a Nerd Font, a Unicode and an ASCII glyph, and the terminal's
2//! capabilities decide which one is drawn.
3//!
4//! ```toml
5//! [meta]
6//! name = "Default"
7//!
8//! [icons]
9//! check = { nerd = "", unicode = "✓", ascii = "v" }
10//!
11//! [animations.blink]
12//! frames = [{ unicode = "●", ascii = "*" }, { unicode = "·", ascii = "." }]
13//! ```
14//!
15//! An application's own icon sets add their new keys to every set: `category.internet` from an
16//! application's file is drawn whatever set the theme chooses, see [`IconSetRegistry`]. A missing
17//! Nerd Font or Unicode glyph is reported with its file, line and column, and a plainer glyph of
18//! the same icon stands in for it.
19//!
20//! An icon set also holds one-cell animations (see [`crate::animation`]); themes
21//! replace single animations the way they replace single icons.
22
23mod detect;
24mod kinds;
25pub mod nerd_font;
26mod sample;
27
28use std::borrow::Cow;
29use std::collections::BTreeMap;
30use std::io;
31use std::path::Path;
32use std::sync::Arc;
33
34use toml::de::DeTable;
35use unicode_segmentation::UnicodeSegmentation;
36
37pub use detect::{default_font_dirs, detect_glyph_mode};
38pub use kinds::{FileKind, KindFamily, UserFolders, file_kind};
39pub use sample::GlyphSample;
40
41use crate::animation::{self, CellAnimation};
42use crate::assets;
43use crate::diagnostics::Diagnostic;
44use crate::doc::{self, Doc, Value};
45
46/// The glyphs of one icon.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct IconGlyphs {
49    /// Glyph for terminals with a Nerd Font.
50    pub nerd: String,
51    /// Glyph for UTF-8 terminals without a Nerd Font.
52    pub unicode: String,
53    /// Glyph for terminals that can only show ASCII.
54    pub ascii: String,
55}
56
57impl IconGlyphs {
58    /// The glyph for `mode`.
59    #[must_use]
60    pub fn for_mode(&self, mode: GlyphMode) -> &str {
61        match mode {
62            GlyphMode::Nerd => &self.nerd,
63            GlyphMode::Unicode => &self.unicode,
64            GlyphMode::Ascii => &self.ascii,
65        }
66    }
67}
68
69/// The icon preference a user or application chooses.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
71pub enum IconMode {
72    /// Detect from the terminal and installed fonts.
73    #[default]
74    Auto,
75    /// Always use Nerd Font glyphs.
76    Nerd,
77    /// Always use Unicode glyphs.
78    Unicode,
79    /// Always use ASCII glyphs.
80    Ascii,
81}
82
83impl IconMode {
84    /// Every mode, in the order a settings screen lists them.
85    pub const ALL: [Self; 4] = [Self::Auto, Self::Nerd, Self::Unicode, Self::Ascii];
86
87    /// The name used in settings and the `QUVYTA_ICONS` environment variable.
88    #[must_use]
89    pub fn name(self) -> &'static str {
90        match self {
91            Self::Auto => "auto",
92            Self::Nerd => "nerd",
93            Self::Unicode => "unicode",
94            Self::Ascii => "ascii",
95        }
96    }
97
98    /// Looks a mode up by name, ignoring letter case.
99    #[must_use]
100    pub fn from_name(name: &str) -> Option<Self> {
101        let name = name.trim().to_ascii_lowercase();
102        Self::ALL.into_iter().find(|mode| mode.name() == name)
103    }
104}
105
106/// The glyph column actually drawn.
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub enum GlyphMode {
109    /// Nerd Font glyphs.
110    Nerd,
111    /// Unicode glyphs.
112    Unicode,
113    /// ASCII glyphs.
114    Ascii,
115}
116
117/// Characters an ASCII glyph may not contain: glyphs must not fake shapes with brackets.
118const BANNED_ASCII: [char; 6] = ['[', ']', '(', ')', '{', '}'];
119
120/// Reads `{ nerd = "…", unicode = "…", ascii = "…" }`.
121///
122/// A missing column is reported where the icon is, and a plainer glyph stands in for it: the
123/// Unicode glyph for a missing Nerd Font glyph, since a Nerd Font draws Unicode too, and the ASCII
124/// glyph for a missing Unicode one. Nothing plainer can stand in for ASCII, so an icon without its
125/// ASCII glyph is an error and is skipped; the warnings for stand-ins go to `report`.
126pub(crate) fn parse_glyphs(
127    doc: &Doc<'_>,
128    key: &str,
129    value: &Value<'_>,
130    report: &mut Vec<Diagnostic>,
131) -> Result<IconGlyphs, Diagnostic> {
132    let table = doc.table(value, &format!("icon `{key}`"))?;
133    let field = |name: &str| -> Result<Option<String>, Diagnostic> {
134        let Some(entry) = doc::get(table, name) else {
135            return Ok(None);
136        };
137        let text = doc.string(entry, &format!("icon `{key}`.{name}"))?;
138        if text.is_empty() {
139            return Err(doc.error(&entry.span(), format!("icon `{key}`.{name} must not be empty")));
140        }
141        Ok(Some(text.to_owned()))
142    };
143    let (nerd, unicode, ascii) = (field("nerd")?, field("unicode")?, field("ascii")?);
144    if let Some((unknown, entry)) =
145        table.iter().find(|(name, _)| !["nerd", "unicode", "ascii"].contains(&name.get_ref().as_ref()))
146    {
147        return Err(doc.error(
148            &entry.span(),
149            format!("icon `{key}` has unknown field `{}`; use nerd, unicode and ascii", unknown.get_ref()),
150        ));
151    }
152    let Some(ascii) = ascii else {
153        return Err(doc.error(
154            &value.span(),
155            format!("icon `{key}` is missing its `ascii` glyph, which every terminal can draw; the icon is skipped"),
156        ));
157    };
158    let ascii_ok = ascii.chars().all(|c| c.is_ascii() && !c.is_ascii_control());
159    if !ascii_ok {
160        return Err(doc.error(&value.span(), format!("icon `{key}`.ascii must contain only printable ASCII")));
161    }
162    if let Some(bad) = ascii.chars().find(|c| BANNED_ASCII.contains(c)) {
163        return Err(
164            doc.error(&value.span(), format!("icon `{key}`.ascii uses `{bad}`; brackets are not allowed as glyphs"))
165        );
166    }
167    let mut stand_in = |missing: &str, used: &str| {
168        report.push(doc.warning(
169            &value.span(),
170            format!("icon `{key}` is missing its `{missing}` glyph; its `{used}` glyph stands in"),
171        ));
172    };
173    let (unicode, plainer) = match unicode {
174        Some(unicode) => (unicode, "unicode"),
175        None => {
176            stand_in("unicode", "ascii");
177            (ascii.clone(), "ascii")
178        }
179    };
180    let nerd = nerd.unwrap_or_else(|| {
181        stand_in("nerd", plainer);
182        unicode.clone()
183    });
184    Ok(IconGlyphs { nerd, unicode, ascii })
185}
186
187/// The icon drawn at the left of hovered, focused and selected rows, tabs, buttons and cards.
188pub const PILLAR: &str = "pillar";
189
190/// A pillar a user can choose at runtime, over whatever the theme draws.
191#[derive(Debug, Clone, Copy, PartialEq, Eq)]
192pub enum PillarStyle {
193    /// A half block, `▌`: the default.
194    Thick,
195    /// A quarter block, `▎`.
196    Thin,
197}
198
199impl PillarStyle {
200    /// Every style, in the order a settings screen lists them.
201    pub const ALL: [Self; 2] = [Self::Thick, Self::Thin];
202
203    /// The name used in settings and theme files.
204    #[must_use]
205    pub fn name(self) -> &'static str {
206        match self {
207            Self::Thick => "thick",
208            Self::Thin => "thin",
209        }
210    }
211
212    /// Looks a style up by name, ignoring letter case.
213    #[must_use]
214    pub fn from_name(name: &str) -> Option<Self> {
215        let name = name.trim().to_ascii_lowercase();
216        Self::ALL.into_iter().find(|style| style.name() == name)
217    }
218
219    /// The glyph drawn for this style; ASCII terminals always draw a coloured cell.
220    #[must_use]
221    pub fn glyphs(self) -> IconGlyphs {
222        pillar_glyphs(match self {
223            Self::Thick => "▌",
224            Self::Thin => "▎",
225        })
226    }
227}
228
229/// A pillar drawn with `glyph` wherever Unicode is available and as a coloured cell in ASCII.
230fn pillar_glyphs(glyph: &str) -> IconGlyphs {
231    IconGlyphs { nerd: glyph.to_owned(), unicode: glyph.to_owned(), ascii: " ".to_owned() }
232}
233
234/// Reads the shorthand of the pillar: `"thick"` (`▌`), `"thin"` (`▎`) or any single-cell
235/// character. ASCII terminals always show the pillar as a coloured cell.
236fn parse_pillar(doc: &Doc<'_>, value: &Value<'_>) -> Result<IconGlyphs, Diagnostic> {
237    let text = doc.string(value, "icon `pillar`")?;
238    if let Some(style) = PillarStyle::from_name(text) {
239        return Ok(style.glyphs());
240    }
241    if crate::text::width(text) == 1 && text.chars().count() == 1 {
242        Ok(pillar_glyphs(text))
243    } else {
244        Err(doc.error(
245            &value.span(),
246            format!("icon `pillar` is `{text}`; use \"thick\", \"thin\" or a single one-cell character"),
247        ))
248    }
249}
250
251/// Reads an `[icons]` table into `glyphs`, reporting and skipping broken entries. The pillar may
252/// also be given in its short form, see [`PILLAR`].
253pub(crate) fn read_icon_table(
254    doc: &Doc<'_>,
255    table: &DeTable<'_>,
256    glyphs: &mut BTreeMap<String, IconGlyphs>,
257    report: &mut Vec<Diagnostic>,
258) {
259    for (key, value) in table {
260        let parsed = if key.get_ref() == PILLAR && value.get_ref().as_str().is_some() {
261            parse_pillar(doc, value)
262        } else {
263            parse_glyphs(doc, key.get_ref(), value, report)
264                .and_then(|glyphs| legacy_glyphs(doc, key.get_ref(), value, glyphs))
265        };
266        match parsed {
267            Ok(parsed) => {
268                glyphs.insert(key.get_ref().to_string(), parsed);
269            }
270            Err(diagnostic) => report.push(diagnostic),
271        }
272    }
273}
274
275/// Icon keys that were renamed, with the key each is now. A former name still gives the same
276/// glyph, so an application that asks for it keeps its icon until it moves to the new name; the
277/// list is not shown among the set's [`keys`](Icons::keys).
278const FORMER_KEYS: &[(&str, &str)] = &[("family", "ecosystem")];
279
280/// Checks the frames of a former spinner icon, which now replaces an animation's glyphs.
281fn legacy_glyphs(doc: &Doc<'_>, key: &str, value: &Value<'_>, glyphs: IconGlyphs) -> Result<IconGlyphs, Diagnostic> {
282    if !animation::LEGACY_ICONS.iter().any(|(icon, _)| *icon == key) {
283        return Ok(glyphs);
284    }
285    animation::check_legacy(&glyphs).map_err(|message| doc.error(&value.span(), format!("icon `{key}`: {message}")))?;
286    Ok(glyphs)
287}
288
289/// Adds one layer of animations over `animations`: first the former spinner icons among `glyphs`,
290/// then the layer's own animations, which win over icons of the same layer.
291fn layer_animations(
292    animations: &mut BTreeMap<String, Arc<CellAnimation>>,
293    glyphs: &BTreeMap<String, IconGlyphs>,
294    own: impl IntoIterator<Item = (String, Arc<CellAnimation>)>,
295) {
296    for (icon, name) in animation::LEGACY_ICONS {
297        if let Some(glyphs) = glyphs.get(icon) {
298            animation::apply_legacy(animations, name, glyphs);
299        }
300    }
301    animations.extend(own);
302}
303
304/// A loaded icon set file.
305#[derive(Debug, Clone)]
306struct IconSetSource {
307    name: String,
308    glyphs: BTreeMap<String, IconGlyphs>,
309    animations: BTreeMap<String, Arc<CellAnimation>>,
310}
311
312/// All icon sets known to an application: the built-in ones plus the application's own.
313///
314/// A set is drawn when a theme names it (`[meta] icon-set`). Keys of the application's sets that
315/// the built-in set does not have, such as `category.internet`, are drawn whatever set is chosen:
316/// they sit under the chosen set, so a theme's set or single icon can still restyle them, and a
317/// set added later wins a key two application sets give. A key the built-in set already has, such
318/// as `check`, is a restyling of the framework's own icon, which every widget draws; it applies
319/// only while its set is the chosen one, so an application set never changes the icons of a set
320/// the user picked.
321#[derive(Debug, Clone)]
322pub struct IconSetRegistry {
323    sets: BTreeMap<String, IconSetSource>,
324    /// Ids of the sets added after the built-in ones, oldest first.
325    added: Vec<String>,
326    diagnostics: Vec<Diagnostic>,
327}
328
329impl IconSetRegistry {
330    /// A registry holding the built-in icon sets.
331    #[must_use]
332    pub fn builtin() -> Self {
333        let mut registry = Self { sets: BTreeMap::new(), added: Vec::new(), diagnostics: Vec::new() };
334        for (id, text) in assets::ICON_SETS {
335            registry.add_source(id, &format!("{id}.toml"), text);
336        }
337        registry.added.clear();
338        registry
339    }
340
341    /// Adds or replaces the icon set `id` from TOML text. Returns whether it was usable.
342    ///
343    /// Its keys the built-in set lacks are drawn in every set from then on; see
344    /// [`IconSetRegistry`] for how it layers with the chosen set.
345    pub fn add_source(&mut self, id: &str, file: &str, text: &str) -> bool {
346        let doc = Doc::new(file, text);
347        let root = match doc.parse() {
348            Ok(root) => root,
349            Err(diagnostic) => {
350                self.diagnostics.push(diagnostic);
351                return false;
352            }
353        };
354        for (key, value) in &root {
355            if !["meta", "icons", "animations"].contains(&key.get_ref().as_ref()) {
356                self.diagnostics.push(doc.error(
357                    &value.span(),
358                    format!("unknown section `{}`; expected meta, icons and animations", key.get_ref()),
359                ));
360            }
361        }
362        let name = self.read_name(&doc, &root).unwrap_or_else(|| id.to_owned());
363        let mut glyphs = BTreeMap::new();
364        match doc::get(&root, "icons") {
365            Some(icons) => match doc.table(icons, "icons") {
366                Ok(table) => read_icon_table(&doc, table, &mut glyphs, &mut self.diagnostics),
367                Err(diagnostic) => self.diagnostics.push(diagnostic),
368            },
369            None => self.diagnostics.push(Diagnostic::error(None, format!("{file}: missing [icons] table"))),
370        }
371        let mut animations = BTreeMap::new();
372        if let Some(table) = doc::get(&root, "animations") {
373            match doc.table(table, "animations") {
374                Ok(table) => animation::read_animation_table(&doc, table, &mut animations, &mut self.diagnostics),
375                Err(diagnostic) => self.diagnostics.push(diagnostic),
376            }
377        }
378        let animations = animations.into_iter().map(|(name, animation)| (name, Arc::new(animation))).collect();
379        self.sets.insert(id.to_owned(), IconSetSource { name, glyphs, animations });
380        self.added.retain(|added| added != id);
381        self.added.push(id.to_owned());
382        true
383    }
384
385    /// The display name from `[meta] name`, reporting a malformed `[meta]` and unknown keys in it.
386    fn read_name(&mut self, doc: &Doc<'_>, root: &DeTable<'_>) -> Option<String> {
387        let meta = match doc.table(doc::get(root, "meta")?, "meta") {
388            Ok(meta) => meta,
389            Err(diagnostic) => {
390                self.diagnostics.push(diagnostic);
391                return None;
392            }
393        };
394        let mut name = None;
395        for (key, value) in meta {
396            if key.get_ref() != "name" {
397                self.diagnostics.push(doc.error(&value.span(), format!("unknown key `meta.{}`", key.get_ref())));
398                continue;
399            }
400            match doc.string(value, "meta.name") {
401                Ok(text) => name = Some(text.to_owned()),
402                Err(diagnostic) => self.diagnostics.push(diagnostic),
403            }
404        }
405        name
406    }
407
408    /// Loads every `*.toml` file in `dir`; the file stem is the set id.
409    ///
410    /// # Errors
411    ///
412    /// Returns the I/O error when the directory cannot be read. A file that cannot be read is
413    /// skipped and reported in the diagnostics.
414    pub fn load_dir(&mut self, dir: &Path) -> io::Result<()> {
415        let found = assets::read_toml_dir(dir)?;
416        self.diagnostics.extend(found.skipped);
417        for (id, file, text) in found.files {
418            self.add_source(&id, &file, &text);
419        }
420        Ok(())
421    }
422
423    /// `(id, display name)` of every set, sorted by id.
424    #[must_use]
425    pub fn list(&self) -> Vec<(String, String)> {
426        self.sets.iter().map(|(id, set)| (id.clone(), set.name.clone())).collect()
427    }
428
429    /// Problems found while loading.
430    #[must_use]
431    pub fn diagnostics(&self) -> &[Diagnostic] {
432        &self.diagnostics
433    }
434
435    /// Builds the icons for set `id` with `overrides` applied on top.
436    ///
437    /// An unknown id falls back to the built-in `default` set.
438    #[must_use]
439    pub fn icons(&self, id: &str, overrides: &BTreeMap<String, IconGlyphs>, mode: GlyphMode) -> Icons {
440        self.icons_with_animations(id, overrides, &BTreeMap::new(), mode)
441    }
442
443    /// Builds the icons and animations for set `id`, with icon `overrides` and `animations` (such
444    /// as a theme's) applied on top.
445    ///
446    /// Animations layer from the built-in `default` set, through set `id`, to the overrides, so a
447    /// set or theme without animations still has every built-in one. Within a layer, a former
448    /// spinner icon key such as `spinner-arc` first replaces the glyphs of its animation, then the
449    /// layer's own animations apply. An unknown id falls back to the built-in `default` set.
450    #[must_use]
451    pub fn icons_with_animations(
452        &self,
453        id: &str,
454        overrides: &BTreeMap<String, IconGlyphs>,
455        animations: &BTreeMap<String, Arc<CellAnimation>>,
456        mode: GlyphMode,
457    ) -> Icons {
458        let fallback = self.sets.get("default");
459        let chosen = self.sets.get(id);
460        let owned = |source: &IconSetSource| source.animations.clone();
461        let mut layered = BTreeMap::new();
462        if let Some(default) = fallback {
463            layer_animations(&mut layered, &default.glyphs, owned(default));
464        }
465        if let Some(set) = chosen.filter(|_| id != "default") {
466            layer_animations(&mut layered, &set.glyphs, owned(set));
467        }
468        layer_animations(&mut layered, overrides, animations.clone());
469        let mut glyphs = self.application_keys();
470        glyphs.extend(chosen.or(fallback).map(|set| set.glyphs.clone()).unwrap_or_default());
471        glyphs.extend(overrides.iter().map(|(k, v)| (k.clone(), v.clone())));
472        glyphs.retain(|key, _| !animation::LEGACY_ICONS.iter().any(|(icon, _)| icon == key));
473        Icons { glyphs, animations: layered, mode }
474    }
475
476    /// The keys the application's sets add to the built-in set, a later set winning a key two of
477    /// them give.
478    fn application_keys(&self) -> BTreeMap<String, IconGlyphs> {
479        let builtin = self.sets.get("default").map(|set| &set.glyphs);
480        let mut keys = BTreeMap::new();
481        for set in self.added.iter().filter_map(|id| self.sets.get(id)) {
482            let new = set.glyphs.iter().filter(|(key, _)| builtin.is_none_or(|builtin| !builtin.contains_key(*key)));
483            keys.extend(new.map(|(key, glyphs)| (key.clone(), glyphs.clone())));
484        }
485        keys
486    }
487}
488
489/// A glyph drawn before a label: an icon of the icon set, which follows the theme and the glyph
490/// mode, or a glyph the application gives as it is, such as a Nerd Font code point it looked up in
491/// its own table.
492///
493/// Text converts into a key, so `cell.icon("folder", None)` reads as before.
494///
495/// ```
496/// use qframe::env::Env;
497/// use qframe::icons::Glyph;
498///
499/// let icons = Env::builtin().icons().clone();
500/// assert_eq!(Glyph::key("check").resolve(&icons), "✓");
501/// assert_eq!(Glyph::literal('\u{e745}').resolve(&icons), "\u{e745}");
502/// ```
503#[derive(Debug, Clone, PartialEq, Eq)]
504pub enum Glyph {
505    /// The icon `key` of the icon set, drawn in the glyph mode in use.
506    Key(String),
507    /// This text, drawn as it is in every glyph mode.
508    Literal(String),
509}
510
511impl Glyph {
512    /// The icon `key` of the icon set, such as `"folder"` or an application's `"category.internet"`.
513    #[must_use]
514    pub fn key(key: impl Into<String>) -> Self {
515        Self::Key(key.into())
516    }
517
518    /// A glyph drawn as it is, such as `'\u{e745}'`. The application answers for the glyph mode: a
519    /// Nerd Font code point only belongs on screen when [`Env::glyph_mode`](crate::env::Env::glyph_mode)
520    /// is [`GlyphMode::Nerd`].
521    #[must_use]
522    pub fn literal(glyph: impl Into<String>) -> Self {
523        Self::Literal(glyph.into())
524    }
525
526    /// The text drawn for this glyph with `icons`.
527    #[must_use]
528    pub fn resolve<'a>(&'a self, icons: &'a Icons) -> Cow<'a, str> {
529        match self {
530            Self::Key(key) => icons.glyph(key),
531            Self::Literal(glyph) => Cow::Borrowed(glyph),
532        }
533    }
534}
535
536impl From<&str> for Glyph {
537    fn from(key: &str) -> Self {
538        Self::key(key)
539    }
540}
541
542impl From<String> for Glyph {
543    fn from(key: String) -> Self {
544        Self::Key(key)
545    }
546}
547
548/// Icons ready to draw in one glyph mode.
549#[derive(Debug, Clone, PartialEq, Eq)]
550pub struct Icons {
551    glyphs: BTreeMap<String, IconGlyphs>,
552    animations: BTreeMap<String, Arc<CellAnimation>>,
553    mode: GlyphMode,
554}
555
556impl Icons {
557    /// The glyph mode in use.
558    #[must_use]
559    pub fn mode(&self) -> GlyphMode {
560        self.mode
561    }
562
563    /// Switches glyph mode.
564    pub fn set_mode(&mut self, mode: GlyphMode) {
565        self.mode = mode;
566    }
567
568    /// The glyphs of `key`, or of the key it was renamed to when `key` is a former name.
569    fn lookup(&self, key: &str) -> Option<&IconGlyphs> {
570        self.glyphs.get(key).or_else(|| {
571            let (_, now) = FORMER_KEYS.iter().find(|(former, _)| *former == key)?;
572            self.glyphs.get(*now)
573        })
574    }
575
576    /// The glyph for `key`. A missing icon is drawn as `⟦key⟧` so it is noticed.
577    #[must_use]
578    pub fn glyph(&self, key: &str) -> Cow<'_, str> {
579        match self.lookup(key) {
580            Some(glyphs) => Cow::Borrowed(glyphs.for_mode(self.mode)),
581            None => Cow::Owned(format!("⟦{key}⟧")),
582        }
583    }
584
585    /// The glyph for `key` split into animation frames, one grapheme each.
586    #[must_use]
587    pub fn frames(&self, key: &str) -> Vec<String> {
588        self.glyph(key).graphemes(true).map(str::to_owned).collect()
589    }
590
591    /// Every glyph of `key`, whatever the mode, or `None` when it is not defined.
592    #[must_use]
593    pub fn glyphs(&self, key: &str) -> Option<&IconGlyphs> {
594        self.lookup(key)
595    }
596
597    /// Whether `key` is defined.
598    #[must_use]
599    pub fn contains(&self, key: &str) -> bool {
600        self.lookup(key).is_some()
601    }
602
603    /// Every icon key, sorted.
604    pub fn keys(&self) -> impl Iterator<Item = &str> {
605        self.glyphs.keys().map(String::as_str)
606    }
607
608    /// The animation named `name`, such as `"spinner-arc"`.
609    #[must_use]
610    pub fn animation(&self, name: &str) -> Option<&Arc<CellAnimation>> {
611        self.animations.get(name)
612    }
613
614    /// Every animation name, sorted.
615    pub fn animation_names(&self) -> impl Iterator<Item = &str> {
616        self.animations.keys().map(String::as_str)
617    }
618}
619
620#[cfg(test)]
621mod tests;