Skip to main content

nms_core/
glyph.rs

1use serde::{Deserialize, Serialize};
2use std::fmt;
3use std::str::FromStr;
4
5pub(crate) struct GlyphInfo {
6    pub index: u8,
7    pub hex_char: char,
8    pub name: &'static str,
9    pub emoji: &'static str,
10}
11
12pub(crate) const GLYPH_TABLE: [GlyphInfo; 16] = [
13    GlyphInfo {
14        index: 0,
15        hex_char: '0',
16        name: "Sunset",
17        emoji: "\u{1F305}",
18    },
19    GlyphInfo {
20        index: 1,
21        hex_char: '1',
22        name: "Bird",
23        emoji: "\u{1F54A}\u{FE0F}",
24    },
25    GlyphInfo {
26        index: 2,
27        hex_char: '2',
28        name: "Face",
29        emoji: "\u{1F611}",
30    },
31    GlyphInfo {
32        index: 3,
33        hex_char: '3',
34        name: "Diplo",
35        emoji: "\u{1F995}",
36    },
37    GlyphInfo {
38        index: 4,
39        hex_char: '4',
40        name: "Eclipse",
41        emoji: "\u{1F31C}",
42    },
43    GlyphInfo {
44        index: 5,
45        hex_char: '5',
46        name: "Balloon",
47        emoji: "\u{1F388}",
48    },
49    GlyphInfo {
50        index: 6,
51        hex_char: '6',
52        name: "Boat",
53        emoji: "\u{26F5}",
54    },
55    GlyphInfo {
56        index: 7,
57        hex_char: '7',
58        name: "Bug",
59        emoji: "\u{1F41C}",
60    },
61    GlyphInfo {
62        index: 8,
63        hex_char: '8',
64        name: "Dragonfly",
65        emoji: "\u{1F98B}",
66    },
67    GlyphInfo {
68        index: 9,
69        hex_char: '9',
70        name: "Galaxy",
71        emoji: "\u{1F300}",
72    },
73    GlyphInfo {
74        index: 10,
75        hex_char: 'A',
76        name: "Voxel",
77        emoji: "\u{1F54B}",
78    },
79    GlyphInfo {
80        index: 11,
81        hex_char: 'B',
82        name: "Whale",
83        emoji: "\u{1F40B}",
84    },
85    GlyphInfo {
86        index: 12,
87        hex_char: 'C',
88        name: "Tent",
89        emoji: "\u{26FA}",
90    },
91    GlyphInfo {
92        index: 13,
93        hex_char: 'D',
94        name: "Rocket",
95        emoji: "\u{1F680}",
96    },
97    GlyphInfo {
98        index: 14,
99        hex_char: 'E',
100        name: "Tree",
101        emoji: "\u{1F333}",
102    },
103    GlyphInfo {
104        index: 15,
105        hex_char: 'F',
106        name: "Atlas",
107        emoji: "\u{1F53A}",
108    },
109];
110
111/// A single portal glyph (value 0-15).
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
113pub struct Glyph(u8);
114
115/// Error returned when parsing a glyph string fails.
116#[derive(Debug, Clone, PartialEq, Eq)]
117#[non_exhaustive]
118pub struct GlyphParseError(pub String);
119
120impl fmt::Display for GlyphParseError {
121    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
122        write!(f, "invalid glyph: {}", self.0)
123    }
124}
125
126impl std::error::Error for GlyphParseError {}
127
128impl Glyph {
129    /// Create from index 0-15. Panics if out of range.
130    pub fn new(index: u8) -> Self {
131        assert!(index < 16, "glyph index must be 0-15, got {index}");
132        Self(index)
133    }
134
135    /// Try to create from index. Returns None if >= 16.
136    pub fn try_new(index: u8) -> Option<Self> {
137        if index < 16 { Some(Self(index)) } else { None }
138    }
139
140    /// The numeric index (0-15).
141    pub fn index(self) -> u8 {
142        self.0
143    }
144
145    /// The hex character ('0'-'9', 'A'-'F').
146    pub fn hex_char(self) -> char {
147        GLYPH_TABLE[self.0 as usize].hex_char
148    }
149
150    /// The glyph name ("Sunset", "Bird", etc.).
151    pub fn name(self) -> &'static str {
152        GLYPH_TABLE[self.0 as usize].name
153    }
154
155    /// The emoji string for this glyph.
156    pub fn emoji(self) -> &'static str {
157        GLYPH_TABLE[self.0 as usize].emoji
158    }
159}
160
161impl From<u8> for Glyph {
162    /// Panics if value >= 16.
163    fn from(v: u8) -> Self {
164        Self::new(v)
165    }
166}
167
168impl From<Glyph> for u8 {
169    fn from(g: Glyph) -> u8 {
170        g.0
171    }
172}
173
174/// Display as the hex character.
175impl fmt::Display for Glyph {
176    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
177        write!(f, "{}", self.hex_char())
178    }
179}
180
181/// Parse from a single hex character, an emoji, or a glyph name (case-insensitive).
182impl FromStr for Glyph {
183    type Err = GlyphParseError;
184
185    fn from_str(s: &str) -> Result<Self, Self::Err> {
186        if s.is_empty() {
187            return Err(GlyphParseError(s.to_string()));
188        }
189
190        // Try single hex character
191        if s.len() == 1 {
192            if let Some(v) = s.chars().next().and_then(|c| c.to_digit(16)) {
193                return Ok(Self(v as u8));
194            }
195        }
196
197        // Try emoji match (strip trailing variation selector U+FE0F for comparison)
198        let stripped = s.trim_end_matches('\u{FE0F}');
199        for info in &GLYPH_TABLE {
200            let table_stripped = info.emoji.trim_end_matches('\u{FE0F}');
201            if stripped == table_stripped || s == info.emoji {
202                return Ok(Self(info.index));
203            }
204        }
205
206        // Try name match (case-insensitive)
207        let lower = s.to_lowercase();
208        for info in &GLYPH_TABLE {
209            if info.name.to_lowercase() == lower {
210                return Ok(Self(info.index));
211            }
212        }
213
214        Err(GlyphParseError(s.to_string()))
215    }
216}
217
218/// Try to parse a single glyph from the start of `input`.
219/// Returns `(Glyph, remaining_str)` on success.
220///
221/// Attempts in order:
222/// 1. Emoji match (longest match first, to handle multi-codepoint emoji)
223/// 2. Glyph name match (case-insensitive, longest name first)
224/// 3. Single hex digit
225pub fn parse_next_glyph(input: &str) -> Result<(Glyph, &str), GlyphParseError> {
226    if input.is_empty() {
227        return Err(GlyphParseError("empty input".to_string()));
228    }
229
230    // 1. Try emoji match — check each glyph's emoji as a prefix
231    //    Sort by emoji byte length descending to match longest first
232    let mut by_len: Vec<(usize, &GlyphInfo)> =
233        GLYPH_TABLE.iter().map(|g| (g.emoji.len(), g)).collect();
234    by_len.sort_by(|a, b| b.0.cmp(&a.0));
235
236    for (_, info) in &by_len {
237        if let Some(rest) = input.strip_prefix(info.emoji) {
238            return Ok((Glyph(info.index), rest));
239        }
240        // Also try without trailing variation selector
241        let emoji_no_vs = info.emoji.trim_end_matches('\u{FE0F}');
242        if emoji_no_vs != info.emoji {
243            if let Some(rest) = input.strip_prefix(emoji_no_vs) {
244                if rest.starts_with('\u{FE0F}') {
245                    // Consume the variation selector too
246                    let vs_len = '\u{FE0F}'.len_utf8();
247                    return Ok((Glyph(info.index), &rest[vs_len..]));
248                }
249                return Ok((Glyph(info.index), rest));
250            }
251        }
252    }
253
254    // 2. Try glyph name match (case-insensitive prefix, longest first)
255    let mut names: Vec<&GlyphInfo> = GLYPH_TABLE.iter().collect();
256    names.sort_by(|a, b| b.name.len().cmp(&a.name.len()));
257
258    let input_lower = input.to_lowercase();
259    for info in &names {
260        let name_lower = info.name.to_lowercase();
261        if input_lower.starts_with(&name_lower) {
262            return Ok((Glyph(info.index), &input[info.name.len()..]));
263        }
264    }
265
266    // 3. Try single hex digit
267    let c = input.chars().next().unwrap();
268    if let Some(v) = c.to_digit(16) {
269        return Ok((Glyph(v as u8), &input[c.len_utf8()..]));
270    }
271
272    Err(GlyphParseError(format!(
273        "unrecognized glyph at: {}",
274        &input[..input.len().min(20)]
275    )))
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    #[test]
283    fn all_16_glyphs_index_roundtrip() {
284        for i in 0..16u8 {
285            let g = Glyph::new(i);
286            assert_eq!(g.index(), i);
287        }
288    }
289
290    #[test]
291    fn all_16_glyphs_hex_roundtrip() {
292        let hex_chars = "0123456789ABCDEF";
293        for (i, c) in hex_chars.chars().enumerate() {
294            let g = Glyph::new(i as u8);
295            assert_eq!(g.hex_char(), c);
296            let parsed: Glyph = c.to_string().parse().unwrap();
297            assert_eq!(parsed.index(), i as u8);
298        }
299    }
300
301    #[test]
302    fn all_16_glyphs_emoji_roundtrip() {
303        for i in 0..16u8 {
304            let g = Glyph::new(i);
305            let emoji = g.emoji();
306            let parsed: Glyph = emoji.parse().unwrap();
307            assert_eq!(parsed.index(), i);
308        }
309    }
310
311    #[test]
312    fn all_16_glyphs_name_roundtrip() {
313        for i in 0..16u8 {
314            let g = Glyph::new(i);
315            let name = g.name();
316            let parsed: Glyph = name.parse().unwrap();
317            assert_eq!(parsed.index(), i);
318        }
319    }
320
321    #[test]
322    fn name_case_insensitive() {
323        assert_eq!("sunset".parse::<Glyph>().unwrap().index(), 0);
324        assert_eq!("SUNSET".parse::<Glyph>().unwrap().index(), 0);
325        assert_eq!("Sunset".parse::<Glyph>().unwrap().index(), 0);
326    }
327
328    #[test]
329    fn bird_with_and_without_variation_selector() {
330        let with_vs: Glyph = "\u{1F54A}\u{FE0F}".parse().unwrap();
331        assert_eq!(with_vs.index(), 1);
332
333        let without_vs: Glyph = "\u{1F54A}".parse().unwrap();
334        assert_eq!(without_vs.index(), 1);
335    }
336
337    #[test]
338    fn hex_lowercase() {
339        assert_eq!("a".parse::<Glyph>().unwrap().index(), 10);
340        assert_eq!("f".parse::<Glyph>().unwrap().index(), 15);
341    }
342
343    #[test]
344    fn invalid_glyph_errors() {
345        assert!("X".parse::<Glyph>().is_err());
346        assert!("hello".parse::<Glyph>().is_err());
347        assert!("".parse::<Glyph>().is_err());
348    }
349
350    #[test]
351    fn parse_next_glyph_emoji_sequence() {
352        let input = "\u{1F305}\u{1F54A}\u{FE0F}\u{1F41C}"; // Sunset Bird Bug
353        let (g1, rest) = parse_next_glyph(input).unwrap();
354        assert_eq!(g1.index(), 0); // Sunset
355        let (g2, rest) = parse_next_glyph(rest).unwrap();
356        assert_eq!(g2.index(), 1); // Bird
357        let (g3, rest) = parse_next_glyph(rest).unwrap();
358        assert_eq!(g3.index(), 7); // Bug
359        assert!(rest.is_empty());
360    }
361
362    #[test]
363    fn parse_next_glyph_hex() {
364        let (g, rest) = parse_next_glyph("A5").unwrap();
365        assert_eq!(g.index(), 10); // A
366        let (g2, rest) = parse_next_glyph(rest).unwrap();
367        assert_eq!(g2.index(), 5); // 5
368        assert!(rest.is_empty());
369    }
370
371    #[test]
372    fn parse_next_glyph_name() {
373        let (g, rest) = parse_next_glyph("DragonflySunset").unwrap();
374        assert_eq!(g.index(), 8); // Dragonfly
375        let (g2, rest) = parse_next_glyph(rest).unwrap();
376        assert_eq!(g2.index(), 0); // Sunset
377        assert!(rest.is_empty());
378    }
379}