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