1mod 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#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct IconGlyphs {
39 pub nerd: String,
41 pub unicode: String,
43 pub ascii: String,
45}
46
47impl IconGlyphs {
48 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
61pub enum IconMode {
62 #[default]
64 Auto,
65 Nerd,
67 Unicode,
69 Ascii,
71}
72
73impl IconMode {
74 pub const ALL: [Self; 4] = [Self::Auto, Self::Nerd, Self::Unicode, Self::Ascii];
76
77 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub enum GlyphMode {
99 Nerd,
101 Unicode,
103 Ascii,
105}
106
107const BANNED_ASCII: [char; 6] = ['[', ']', '(', ')', '{', '}'];
109
110pub(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
144pub const PILLAR: &str = "pillar";
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub enum PillarStyle {
150 Thick,
152 Thin,
154}
155
156impl PillarStyle {
157 pub const ALL: [Self; 2] = [Self::Thick, Self::Thin];
159
160 #[must_use]
162 pub fn name(self) -> &'static str {
163 match self {
164 Self::Thick => "thick",
165 Self::Thin => "thin",
166 }
167 }
168
169 #[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 #[must_use]
178 pub fn glyphs(self) -> IconGlyphs {
179 pillar_glyphs(match self {
180 Self::Thick => "▌",
181 Self::Thin => "▎",
182 })
183 }
184}
185
186fn pillar_glyphs(glyph: &str) -> IconGlyphs {
188 IconGlyphs { nerd: glyph.to_owned(), unicode: glyph.to_owned(), ascii: " ".to_owned() }
189}
190
191fn 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
208pub(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
231fn 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
240fn 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#[derive(Debug, Clone)]
257struct IconSetSource {
258 name: String,
259 glyphs: BTreeMap<String, IconGlyphs>,
260 animations: BTreeMap<String, Arc<CellAnimation>>,
261}
262
263#[derive(Debug, Clone)]
265pub struct IconSetRegistry {
266 sets: BTreeMap<String, IconSetSource>,
267 diagnostics: Vec<Diagnostic>,
268}
269
270impl IconSetRegistry {
271 #[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 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 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 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 #[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 #[must_use]
366 pub fn diagnostics(&self) -> &[Diagnostic] {
367 &self.diagnostics
368 }
369
370 #[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 #[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#[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 #[must_use]
422 pub fn mode(&self) -> GlyphMode {
423 self.mode
424 }
425
426 pub fn set_mode(&mut self, mode: GlyphMode) {
428 self.mode = mode;
429 }
430
431 #[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 #[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 #[must_use]
448 pub fn contains(&self, key: &str) -> bool {
449 self.glyphs.contains_key(key)
450 }
451
452 pub fn keys(&self) -> impl Iterator<Item = &str> {
454 self.glyphs.keys().map(String::as_str)
455 }
456
457 #[must_use]
459 pub fn animation(&self, name: &str) -> Option<&Arc<CellAnimation>> {
460 self.animations.get(name)
461 }
462
463 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}