1mod 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#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct IconGlyphs {
49 pub nerd: String,
51 pub unicode: String,
53 pub ascii: String,
55}
56
57impl IconGlyphs {
58 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
71pub enum IconMode {
72 #[default]
74 Auto,
75 Nerd,
77 Unicode,
79 Ascii,
81}
82
83impl IconMode {
84 pub const ALL: [Self; 4] = [Self::Auto, Self::Nerd, Self::Unicode, Self::Ascii];
86
87 #[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 #[must_use]
101 pub fn label(self, i18n: &crate::i18n::I18n) -> String {
102 i18n.translate(&self.label_key(), &[])
103 }
104
105 pub(crate) fn label_key(self) -> String {
107 format!("quvyta.appearance.icons-{}", self.name())
108 }
109
110 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120pub enum GlyphMode {
121 Nerd,
123 Unicode,
125 Ascii,
127}
128
129const BANNED_ASCII: [char; 6] = ['[', ']', '(', ')', '{', '}'];
131
132pub(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
199pub const PILLAR: &str = "pillar";
201
202#[derive(Debug, Clone, Copy, PartialEq, Eq)]
204pub enum PillarStyle {
205 Thick,
207 Thin,
209}
210
211impl PillarStyle {
212 pub const ALL: [Self; 2] = [Self::Thick, Self::Thin];
214
215 #[must_use]
217 pub fn name(self) -> &'static str {
218 match self {
219 Self::Thick => "thick",
220 Self::Thin => "thin",
221 }
222 }
223
224 #[must_use]
227 pub fn label(self, i18n: &crate::i18n::I18n) -> String {
228 i18n.translate(&self.label_key(), &[])
229 }
230
231 pub(crate) fn label_key(self) -> String {
233 format!("quvyta.appearance.pillar-{}", self.name())
234 }
235
236 #[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 #[must_use]
245 pub fn glyphs(self) -> IconGlyphs {
246 pillar_glyphs(match self {
247 Self::Thick => "▌",
248 Self::Thin => "▎",
249 })
250 }
251}
252
253fn pillar_glyphs(glyph: &str) -> IconGlyphs {
255 IconGlyphs { nerd: glyph.to_owned(), unicode: glyph.to_owned(), ascii: " ".to_owned() }
256}
257
258fn 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
275pub(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
299const FORMER_KEYS: &[(&str, &str)] = &[("family", "ecosystem")];
303
304fn 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
313fn 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#[derive(Debug, Clone)]
330struct IconSetSource {
331 name: String,
332 glyphs: BTreeMap<String, IconGlyphs>,
333 animations: BTreeMap<String, Arc<CellAnimation>>,
334}
335
336#[derive(Debug, Clone)]
346pub struct IconSetRegistry {
347 sets: BTreeMap<String, IconSetSource>,
348 added: Vec<String>,
350 diagnostics: Vec<Diagnostic>,
351}
352
353impl IconSetRegistry {
354 #[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 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 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 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 #[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 #[must_use]
455 pub fn diagnostics(&self) -> &[Diagnostic] {
456 &self.diagnostics
457 }
458
459 #[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 #[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 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#[derive(Debug, Clone, PartialEq, Eq)]
528pub enum Glyph {
529 Key(String),
531 Literal(String),
533}
534
535impl Glyph {
536 #[must_use]
538 pub fn key(key: impl Into<String>) -> Self {
539 Self::Key(key.into())
540 }
541
542 #[must_use]
546 pub fn literal(glyph: impl Into<String>) -> Self {
547 Self::Literal(glyph.into())
548 }
549
550 #[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#[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 #[must_use]
583 pub fn mode(&self) -> GlyphMode {
584 self.mode
585 }
586
587 pub fn set_mode(&mut self, mode: GlyphMode) {
589 self.mode = mode;
590 }
591
592 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 #[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 #[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 #[must_use]
617 pub fn glyphs(&self, key: &str) -> Option<&IconGlyphs> {
618 self.lookup(key)
619 }
620
621 #[must_use]
623 pub fn contains(&self, key: &str) -> bool {
624 self.lookup(key).is_some()
625 }
626
627 pub fn keys(&self) -> impl Iterator<Item = &str> {
629 self.glyphs.keys().map(String::as_str)
630 }
631
632 #[must_use]
634 pub fn animation(&self, name: &str) -> Option<&Arc<CellAnimation>> {
635 self.animations.get(name)
636 }
637
638 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;