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