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