Skip to main content

monospace_core/
glyph.rs

1//! Glyph rules and the catalog they are looked up in. See _Strokes, glyph sets and the catalog_
2//! in [`docs/model.md`](../../../docs/model.md).
3//!
4//! A key carries a stroke per side rather than one stroke and four flags, because all four sides
5//! are read on every lookup and the tables the rules come from already have four independent
6//! columns: the narrower key would have had to change, with the catalog and every rule reading it,
7//! the day strokes stopped being uniform. Glyph sets collapse into one catalog rather than being
8//! consulted in turn, because precedence is settled while a catalog is being built rather than
9//! while it is read, and nothing can tell the two apart afterwards.
10
11use std::collections::HashMap;
12
13use unicode_segmentation::UnicodeSegmentation;
14
15use crate::Stroke;
16
17/// What a cell renders to: one extended grapheme cluster, never a control character.
18///
19/// A grapheme cluster is the unit [UAX #29](https://www.unicode.org/reports/tr29/) draws a
20/// boundary around — what a reader sees as one character, which may be built from several code
21/// points: `é` written as `e` followed by a combining acute, a flag, or an emoji with a modifier.
22/// A control character is one in Unicode's `Cc` category — what [`char::is_control`] answers —
23/// and the refusal holds even inside an otherwise-accepted cluster: `"\r\n"` is one cluster by
24/// UAX #29 and is refused anyway.
25///
26/// Width is not part of this invariant: a grapheme wider than one column is accepted and shifts
27/// the rest of its row by a column, and a cluster made only of format characters is accepted and
28/// occupies no column. Equality is by text, not by appearance: two glyphs that render alike but
29/// are encoded differently compare unequal, because nothing here is normalized.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct Glyph(String);
32
33impl Glyph {
34    /// `Some` when `text` is exactly one extended grapheme cluster containing no control
35    /// character, `None` otherwise.
36    #[must_use]
37    pub fn new(text: &str) -> Option<Self> {
38        let mut clusters = text.graphemes(true);
39        clusters.next()?;
40        if clusters.next().is_some() || text.chars().any(char::is_control) {
41            return None;
42        }
43
44        Some(Self(text.to_owned()))
45    }
46
47    /// The glyph's text.
48    #[must_use]
49    pub fn as_str(&self) -> &str {
50        &self.0
51    }
52}
53
54/// A symbol a cell can name: a direction an arrow points in, or a shape a terminal stands on.
55///
56/// Closed rather than a name, so that a symbol the core does not know cannot arrive and be
57/// rendered as a space: a misspelled style and a missing pair answer alike, and this is the half
58/// that must not. [`Self::ALL`] is the order a sheet prints the vocabulary in.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
60pub enum Symbol {
61    /// An arrow pointing up.
62    DirectionUp,
63    /// An arrow pointing right.
64    DirectionRight,
65    /// An arrow pointing down.
66    DirectionDown,
67    /// An arrow pointing left.
68    DirectionLeft,
69    /// A diamond, which points nowhere.
70    Diamond,
71    /// A hexagon, which points nowhere.
72    Hexagon,
73    /// A circle, which points nowhere.
74    Circle,
75}
76
77impl Symbol {
78    /// Every symbol, in the order a sheet prints them: the four directions, then the shapes.
79    pub const ALL: [Symbol; 7] = [
80        Symbol::DirectionUp,
81        Symbol::DirectionRight,
82        Symbol::DirectionDown,
83        Symbol::DirectionLeft,
84        Symbol::Diamond,
85        Symbol::Hexagon,
86        Symbol::Circle,
87    ];
88}
89
90/// The name of the style a symbol is drawn in — `solid`, `outline`, `arrow`.
91///
92/// Open, as [`Stroke`] is: the catalog decides which styles exist, so a style someone loads is a
93/// style like any other. A style is not a table: a table may hold rules of more than one, and one
94/// style may be spread over several.
95#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
96pub struct SymbolStyleName(pub String);
97
98impl From<&str> for SymbolStyleName {
99    fn from(name: &str) -> Self {
100        Self(name.to_owned())
101    }
102}
103
104/// A symbol and the style it is drawn in: the value a cell holds and the key its rule is filed
105/// under.
106///
107/// **The two are one value on purpose**, so writing a cell and looking one up are the same
108/// spelling: a second key carrying the same two fields would be a second way to say one thing,
109/// with a conversion at every write and every lookup.
110#[derive(Debug, Clone, PartialEq, Eq, Hash)]
111pub struct SymbolCell {
112    /// Which symbol is drawn.
113    pub symbol: Symbol,
114    /// Which style it is drawn in.
115    pub style: SymbolStyleName,
116}
117
118/// The four sides of a glyph rule: a stroke name on each side, or nothing.
119#[derive(Debug, Clone, PartialEq, Eq, Hash)]
120pub struct GlyphKey {
121    /// The stroke on the top side, or `None` if none runs there.
122    pub top: Option<Stroke>,
123    /// The stroke on the right side, or `None` if none runs there.
124    pub right: Option<Stroke>,
125    /// The stroke on the bottom side, or `None` if none runs there.
126    pub bottom: Option<Stroke>,
127    /// The stroke on the left side, or `None` if none runs there.
128    pub left: Option<Stroke>,
129}
130
131/// Every glyph rule in play. Answering a key is one lookup; nothing about a catalog says which
132/// set a rule came from.
133///
134/// **Two maps, because there are two kinds of key.** An arm rule is filed under four strokes, a
135/// symbol rule under a symbol and a style, and the two answer different questions about a miss:
136/// an arm cell degrades to its base stroke, while a symbol cell answers nothing. Folding them into
137/// one map over one key would make the answers collide.
138pub struct GlyphCatalog {
139    /// The rules filed under a combination of arms.
140    arms: HashMap<GlyphKey, Glyph>,
141    /// The rules filed under a symbol and the style it is drawn in.
142    symbols: HashMap<SymbolCell, Glyph>,
143}
144
145/// A row of a glyph table: a stroke name or nothing on each side, and the glyph it draws.
146///
147/// The glyph is `&'static str` rather than `Glyph` so that no row moves when the invariant widens
148/// past one character.
149type Row = (
150    Option<&'static str>,
151    Option<&'static str>,
152    Option<&'static str>,
153    Option<&'static str>,
154    &'static str,
155);
156
157/// The 15 rules of the Light table in [`docs/glyph-sets.md`](../../../docs/glyph-sets.md), held as
158/// data rather than parsed at run time.
159const LIGHT: &[Row] = &[
160    (None, None, Some("light"), None, "│"),
161    (None, None, Some("light"), Some("light"), "┐"),
162    (None, Some("light"), Some("light"), Some("light"), "┬"),
163    (
164        Some("light"),
165        Some("light"),
166        Some("light"),
167        Some("light"),
168        "┼",
169    ),
170    (Some("light"), None, Some("light"), Some("light"), "┤"),
171    (None, Some("light"), Some("light"), None, "┌"),
172    (Some("light"), Some("light"), Some("light"), None, "├"),
173    (Some("light"), None, Some("light"), None, "│"),
174    (None, None, None, Some("light"), "─"),
175    (None, Some("light"), None, Some("light"), "─"),
176    (Some("light"), Some("light"), None, Some("light"), "┴"),
177    (Some("light"), None, None, Some("light"), "┘"),
178    (None, Some("light"), None, None, "─"),
179    (Some("light"), Some("light"), None, None, "└"),
180    (Some("light"), None, None, None, "│"),
181];
182
183/// The seven rules of the Solid style, held as data rather than parsed at run time. The style is
184/// the core's the way Light is: `solid` is what a cell draws when nothing has said otherwise.
185const SOLID: &[(Symbol, &str)] = &[
186    (Symbol::DirectionUp, "▲"),
187    (Symbol::DirectionRight, "▶"),
188    (Symbol::DirectionDown, "▼"),
189    (Symbol::DirectionLeft, "◀"),
190    (Symbol::Diamond, "◆"),
191    (Symbol::Hexagon, "⬢"),
192    (Symbol::Circle, "●"),
193];
194
195impl GlyphCatalog {
196    /// Builds a catalog from an ordered group of rules. Where two rules claim the same key, the
197    /// first one in `rules` wins.
198    #[must_use]
199    pub fn from_rules(rules: impl IntoIterator<Item = (GlyphKey, Glyph)>) -> Self {
200        let mut map = HashMap::new();
201        for (key, glyph) in rules {
202            map.entry(key).or_insert(glyph);
203        }
204
205        Self {
206            arms: map,
207            symbols: HashMap::new(),
208        }
209    }
210
211    /// Builds a catalog from an ordered group of symbol rules. Where two rules claim the same
212    /// pair, the first one in `rules` wins.
213    #[must_use]
214    pub fn from_symbol_rules(rules: impl IntoIterator<Item = (SymbolCell, Glyph)>) -> Self {
215        let mut map = HashMap::new();
216        for (key, glyph) in rules {
217            map.entry(key).or_insert(glyph);
218        }
219
220        Self {
221            arms: HashMap::new(),
222            symbols: map,
223        }
224    }
225
226    /// Builds a catalog by merging other catalogs, in the order given. Where two claim the same
227    /// key, the earlier one in `catalogs` wins, and each of the two maps keeps its own claims.
228    #[must_use]
229    pub fn union(catalogs: impl IntoIterator<Item = Self>) -> Self {
230        let mut arms = HashMap::new();
231        let mut symbols = HashMap::new();
232        for catalog in catalogs {
233            for (key, glyph) in catalog.arms {
234                arms.entry(key).or_insert(glyph);
235            }
236            for (key, glyph) in catalog.symbols {
237                symbols.entry(key).or_insert(glyph);
238            }
239        }
240
241        Self { arms, symbols }
242    }
243
244    /// Builds a catalog from the Light table alone.
245    ///
246    /// Named after what the catalog holds, not where it comes from: it stays accurate once a
247    /// second built-in table exists, which is why it is not called `built_in`.
248    ///
249    /// # Panics
250    ///
251    /// Panics if a row of the Light table is not a valid glyph. That is a bug in data this
252    /// library ships, never a condition a caller can trigger.
253    #[must_use]
254    pub fn light() -> Self {
255        Self::from_rules(LIGHT.iter().map(|&(top, right, bottom, left, glyph)| {
256            let key = GlyphKey {
257                top: top.map(Stroke::from),
258                right: right.map(Stroke::from),
259                bottom: bottom.map(Stroke::from),
260                left: left.map(Stroke::from),
261            };
262            let glyph = Glyph::new(glyph)
263                .unwrap_or_else(|| panic!("Light table row {key:?} is not a valid glyph"));
264            (key, glyph)
265        }))
266    }
267
268    /// Builds a catalog from the Solid style alone: one glyph per [`Symbol`].
269    ///
270    /// Named after what it holds, as [`light`](Self::light) is, and it stays accurate once another
271    /// style ships beside it.
272    ///
273    /// # Panics
274    ///
275    /// Panics if a row of the Solid table is not a valid glyph. That is a bug in data this
276    /// library ships, never a condition a caller can trigger.
277    #[must_use]
278    pub fn solid() -> Self {
279        Self::from_symbol_rules(SOLID.iter().map(|&(symbol, glyph)| {
280            let key = SymbolCell {
281                symbol,
282                style: SymbolStyleName::from("solid"),
283            };
284            let glyph = Glyph::new(glyph)
285                .unwrap_or_else(|| panic!("Solid table row {key:?} is not a valid glyph"));
286            (key, glyph)
287        }))
288    }
289
290    /// Returns the glyph `key` answers to, or `None` if the catalog has no rule for it.
291    #[must_use]
292    pub fn glyph(&self, key: &GlyphKey) -> Option<&Glyph> {
293        self.arms.get(key)
294    }
295
296    /// Returns the glyph the pair `key` answers to, or `None` if the catalog has no rule for it.
297    ///
298    /// **A miss is final.** An arm cell falls back to its base stroke; a symbol cell does not fall
299    /// back to another style, because a fallback would replace the look the cell named with one it
300    /// did not.
301    #[must_use]
302    pub fn symbol(&self, key: &SymbolCell) -> Option<&Glyph> {
303        self.symbols.get(key)
304    }
305
306    /// Every style the catalog holds a symbol rule for, each once, in name order.
307    ///
308    /// **Read off the rules rather than off a list**, so a style added to a table is reported
309    /// without a second place to change and a style with no rules is not reported at all.
310    #[must_use]
311    pub fn symbol_styles(&self) -> Vec<&SymbolStyleName> {
312        let mut styles: Vec<&SymbolStyleName> = self.symbols.keys().map(|key| &key.style).collect();
313        styles.sort();
314        styles.dedup();
315
316        styles
317    }
318}
319
320#[cfg(test)]
321mod tests {
322    use super::{Glyph, GlyphCatalog, GlyphKey, Symbol, SymbolCell, SymbolStyleName};
323    use crate::Stroke;
324
325    /// `"│"` is one cluster and one character, so it is accepted and reads back
326    /// unchanged.
327    #[test]
328    fn a_single_character_is_accepted_and_reads_back_unchanged() {
329        assert_eq!(Glyph::new("│").as_ref().map(Glyph::as_str), Some("│"));
330    }
331
332    /// Empty text is not a glyph.
333    #[test]
334    fn empty_text_is_refused() {
335        assert_eq!(Glyph::new(""), None);
336    }
337
338    /// Two characters are two clusters too, so `"ab"` stays
339    /// refused once the invariant widens.
340    #[test]
341    fn more_than_one_character_is_refused() {
342        assert_eq!(Glyph::new("ab"), None);
343    }
344
345    /// A control character on its own is refused.
346    #[test]
347    fn a_control_character_is_refused() {
348        assert_eq!(Glyph::new("\n"), None);
349    }
350
351    /// A zero-width joiner is a format character, not a control character, so it is
352    /// accepted on its own even though it occupies no column.
353    #[test]
354    fn a_lone_format_character_is_accepted() {
355        assert!(Glyph::new("\u{200D}").is_some());
356    }
357
358    /// `é` decomposed as `e` followed by a combining acute is two code
359    /// points and one cluster, so it is accepted as one glyph.
360    #[test]
361    fn a_cluster_built_from_several_code_points_is_accepted() {
362        assert!(Glyph::new("e\u{301}").is_some());
363    }
364
365    /// A regional-indicator pair is one cluster, so it is accepted as one
366    /// glyph.
367    #[test]
368    fn a_regional_indicator_pair_is_accepted() {
369        assert!(Glyph::new("🇦🇷").is_some());
370    }
371
372    /// `"\r\n"` is one cluster by UAX #29 and is refused anyway, which is
373    /// why the invariant has two halves rather than one.
374    #[test]
375    fn a_control_character_is_refused_even_inside_one_cluster() {
376        assert_eq!(Glyph::new("\r\n"), None);
377    }
378
379    /// `é` as U+00E9 and as `e` followed by U+0301 both construct and compare
380    /// unequal, because construction does not normalize.
381    #[test]
382    fn two_normal_forms_of_the_same_letter_compare_unequal() {
383        let composed = Glyph::new("é").expect("é is one glyph");
384        let decomposed = Glyph::new("e\u{301}").expect("e followed by U+0301 is one glyph");
385
386        assert_ne!(composed, decomposed);
387    }
388
389    /// The example named "From a cell to a character": the cell `(light; S, C, S, C)` builds a
390    /// key with `light` on top and bottom and nothing on the sides, which answers `│`.
391    #[test]
392    fn an_exact_key_answers_the_light_table() {
393        let catalog = GlyphCatalog::light();
394        let key = GlyphKey {
395            top: Some(Stroke::from("light")),
396            right: None,
397            bottom: Some(Stroke::from("light")),
398            left: None,
399        };
400
401        assert_eq!(catalog.glyph(&key).map(Glyph::as_str), Some("│"));
402    }
403
404    /// The property `docs/model.md` names under _Properties worth testing_: a catalog built from
405    /// one stroke's complete set answers every key a cell of that stroke can produce.
406    #[test]
407    fn light_answers_every_non_empty_combination_of_its_own_stroke() {
408        let catalog = GlyphCatalog::light();
409
410        for mask in 1u8..16 {
411            let side = |bit: u8| {
412                if mask & bit == 0 {
413                    None
414                } else {
415                    Some(Stroke::from("light"))
416                }
417            };
418            let key = GlyphKey {
419                top: side(0b0001),
420                right: side(0b0010),
421                bottom: side(0b0100),
422                left: side(0b1000),
423            };
424
425            assert!(catalog.glyph(&key).is_some(), "no glyph for {key:?}");
426        }
427    }
428
429    #[test]
430    fn the_empty_key_has_no_answer_in_the_light_table() {
431        let catalog = GlyphCatalog::light();
432        let key = GlyphKey {
433            top: None,
434            right: None,
435            bottom: None,
436            left: None,
437        };
438
439        assert_eq!(catalog.glyph(&key), None);
440    }
441
442    /// A key with the given stroke on its top side and nothing else, so two distinct keys are
443    /// cheap to build for the `from_rules`/`union` tests below.
444    fn key_with_top(stroke: &str) -> GlyphKey {
445        GlyphKey {
446            top: Some(Stroke::from(stroke)),
447            right: None,
448            bottom: None,
449            left: None,
450        }
451    }
452
453    /// A key only one side holds answers from that side regardless of
454    /// union order.
455    #[test]
456    fn a_key_only_one_catalog_holds_answers_the_same_regardless_of_union_order() {
457        let mine_key = key_with_top("mine");
458        let mine = Glyph::new("m").expect("\"m\" is one glyph");
459        let other_key = key_with_top("other");
460        let other = Glyph::new("o").expect("\"o\" is one glyph");
461        let mine_catalog = GlyphCatalog::from_rules([(mine_key.clone(), mine.clone())]);
462        let other_catalog = GlyphCatalog::from_rules([(other_key.clone(), other.clone())]);
463
464        let mine_first = GlyphCatalog::union([
465            GlyphCatalog::from_rules([(mine_key.clone(), mine.clone())]),
466            GlyphCatalog::from_rules([(other_key.clone(), other.clone())]),
467        ]);
468        let other_first = GlyphCatalog::union([other_catalog, mine_catalog]);
469
470        assert_eq!(mine_first.glyph(&mine_key), Some(&mine));
471        assert_eq!(mine_first.glyph(&other_key), Some(&other));
472        assert_eq!(other_first.glyph(&mine_key), Some(&mine));
473        assert_eq!(other_first.glyph(&other_key), Some(&other));
474    }
475
476    /// A key two catalogs both claim answers from whichever was
477    /// given first to `union`, and reversing the order reverses the answer.
478    #[test]
479    fn a_key_two_catalogs_both_claim_answers_from_whichever_came_first() {
480        let key = key_with_top("shared");
481        let first = Glyph::new("1").expect("\"1\" is one glyph");
482        let second = Glyph::new("2").expect("\"2\" is one glyph");
483
484        let first_wins = GlyphCatalog::union([
485            GlyphCatalog::from_rules([(key.clone(), first.clone())]),
486            GlyphCatalog::from_rules([(key.clone(), second.clone())]),
487        ]);
488        let second_wins = GlyphCatalog::union([
489            GlyphCatalog::from_rules([(key.clone(), second.clone())]),
490            GlyphCatalog::from_rules([(key.clone(), first.clone())]),
491        ]);
492
493        assert_eq!(first_wins.glyph(&key), Some(&first));
494        assert_eq!(second_wins.glyph(&key), Some(&second));
495    }
496
497    /// an empty `rules` iterator produces a valid catalog that answers nothing.
498    #[test]
499    fn an_empty_rules_iterator_produces_a_catalog_with_no_answers() {
500        let catalog = GlyphCatalog::from_rules(std::iter::empty());
501
502        assert_eq!(catalog.glyph(&key_with_top("anything")), None);
503    }
504
505    /// a zero-element `catalogs` list produces a valid catalog that answers nothing.
506    #[test]
507    fn a_union_of_no_catalogs_produces_a_catalog_with_no_answers() {
508        let catalog = GlyphCatalog::union(std::iter::empty());
509
510        assert_eq!(catalog.glyph(&key_with_top("anything")), None);
511    }
512
513    /// a one-element `catalogs` list answers exactly as that catalog does on its own.
514    #[test]
515    fn a_union_of_a_single_catalog_answers_the_same_as_that_catalog_alone() {
516        let key = key_with_top("solo");
517        let glyph = Glyph::new("s").expect("\"s\" is one glyph");
518        let alone = GlyphCatalog::from_rules([(key.clone(), glyph.clone())]);
519        let merged =
520            GlyphCatalog::union([GlyphCatalog::from_rules([(key.clone(), glyph.clone())])]);
521
522        assert_eq!(alone.glyph(&key), merged.glyph(&key));
523        assert_eq!(merged.glyph(&key), Some(&glyph));
524    }
525
526    /// A pair with `symbol` drawn in `style`, so a test can name one without the whole struct
527    /// spelled at every call.
528    fn a_symbol(symbol: Symbol, style: &str) -> SymbolCell {
529        SymbolCell {
530            symbol,
531            style: SymbolStyleName::from(style),
532        }
533    }
534
535    /// Rule 5: `union` merges both maps by the same first-claim rule within each, and neither
536    /// answers for the other. Two styles of one symbol are two pairs, so both survive.
537    #[test]
538    fn union_merges_both_maps_and_holds_two_styles_of_one_symbol() {
539        let arm_key = key_with_top("shared-arm");
540        let arm = Glyph::new("│").expect("\"│\" is one glyph");
541        let solid = a_symbol(Symbol::DirectionUp, "solid");
542        let outline = a_symbol(Symbol::DirectionUp, "outline");
543        let solid_glyph = Glyph::new("▲").expect("\"▲\" is one glyph");
544        let outline_glyph = Glyph::new("△").expect("\"△\" is one glyph");
545
546        let arms_only = GlyphCatalog::from_rules([(arm_key.clone(), arm.clone())]);
547        let symbols_only = GlyphCatalog::from_symbol_rules([
548            (solid.clone(), solid_glyph.clone()),
549            (outline.clone(), outline_glyph.clone()),
550        ]);
551        assert_eq!(arms_only.symbol(&solid), None, "an arm rule is not a pair");
552        assert_eq!(
553            symbols_only.glyph(&arm_key),
554            None,
555            "a symbol rule is not a key of arms"
556        );
557
558        let merged = GlyphCatalog::union([arms_only, symbols_only]);
559
560        assert_eq!(merged.glyph(&arm_key), Some(&arm));
561        assert_eq!(merged.symbol(&solid), Some(&solid_glyph));
562        assert_eq!(merged.symbol(&outline), Some(&outline_glyph));
563    }
564
565    /// The first-claim rule is per map: a pair two catalogs both claim answers from whichever came
566    /// first, and reversing the order reverses the answer.
567    #[test]
568    fn a_pair_two_catalogs_both_claim_answers_from_whichever_came_first() {
569        let key = a_symbol(Symbol::Diamond, "shared");
570        let first = Glyph::new("1").expect("\"1\" is one glyph");
571        let second = Glyph::new("2").expect("\"2\" is one glyph");
572
573        let first_wins = GlyphCatalog::union([
574            GlyphCatalog::from_symbol_rules([(key.clone(), first.clone())]),
575            GlyphCatalog::from_symbol_rules([(key.clone(), second.clone())]),
576        ]);
577        let second_wins = GlyphCatalog::union([
578            GlyphCatalog::from_symbol_rules([(key.clone(), second.clone())]),
579            GlyphCatalog::from_symbol_rules([(key.clone(), first.clone())]),
580        ]);
581
582        assert_eq!(first_wins.symbol(&key), Some(&first));
583        assert_eq!(second_wins.symbol(&key), Some(&second));
584    }
585
586    /// Rule 6: the core's Solid style answers every symbol, and draws the characters it names.
587    #[test]
588    fn solid_answers_all_seven_symbols() {
589        let catalog = GlyphCatalog::solid();
590
591        let drawn: String = Symbol::ALL
592            .iter()
593            .filter_map(|symbol| catalog.symbol(&a_symbol(*symbol, "solid")))
594            .map(Glyph::as_str)
595            .collect();
596
597        assert_eq!(drawn, "▲▶▼◀◆⬢●");
598    }
599
600    /// Rule 7: a style is reported once however many rules carry it, and the names come back in
601    /// name order rather than in the order the rules were built.
602    #[test]
603    fn symbol_styles_reports_each_style_once_in_name_order() {
604        let catalog = GlyphCatalog::from_symbol_rules([
605            (
606                a_symbol(Symbol::Diamond, "zulu"),
607                Glyph::new("z").expect("\"z\" is one glyph"),
608            ),
609            (
610                a_symbol(Symbol::Diamond, "alpha"),
611                Glyph::new("a").expect("\"a\" is one glyph"),
612            ),
613            (
614                a_symbol(Symbol::Circle, "alpha"),
615                Glyph::new("A").expect("\"A\" is one glyph"),
616            ),
617            (
618                a_symbol(Symbol::Circle, "mike"),
619                Glyph::new("m").expect("\"m\" is one glyph"),
620            ),
621        ]);
622
623        let styles: Vec<&str> = catalog
624            .symbol_styles()
625            .iter()
626            .map(|style| style.0.as_str())
627            .collect();
628
629        assert_eq!(styles, ["alpha", "mike", "zulu"]);
630    }
631}