Skip to main content

tui_splitflap/
char_set.rs

1//! Character set definitions and forward-wrapping distance arithmetic.
2
3use std::collections::HashMap;
4use std::sync::Arc;
5
6use thiserror::Error;
7
8#[derive(Debug, Error)]
9pub enum CharSetError {
10    #[error("CharSet requires at least 2 characters, got {0}")]
11    TooFew(usize),
12
13    #[error("duplicate character '{0}' in CharSet")]
14    Duplicate(char),
15}
16
17/// Ordered sequence of characters a split-flap tile can display.
18///
19/// Order determines which intermediate characters appear during a
20/// Sequential or Combined flip animation. Distance always travels
21/// forward (wraps around), never backward.
22#[derive(Debug, Clone)]
23pub struct CharSet {
24    chars: Arc<[char]>,
25    index_of: HashMap<char, usize>,
26}
27
28// Variant glyphs (Ð, Ɨ, Ᵽ, Ɍ, Ŧ, Ʉ, Ɏ, Ƶ) placed after their base letter
29// create a mid-rotation illusion during sequential flip animations.
30const DEFAULT_CHARS: &[char] = &[
31    ' ', 'A', 'B', 'C', 'D', 'Ð', 'E', 'F', 'G', 'H', 'I', 'Ɨ', 'J', 'K', 'L', 'M', 'N', 'O', 'P',
32    'Ᵽ', 'Q', 'R', 'Ɍ', 'S', 'T', 'Ŧ', 'U', 'Ʉ', 'V', 'W', 'X', 'Y', 'Ɏ', 'Z', 'Ƶ', '0', '1', '2',
33    '3', '4', '5', '6', '7', '8', '9', '.', ',', '!', '?', '&', '#', '@', '-', '\'', ':', '/',
34];
35
36impl CharSet {
37    pub fn new(chars: impl Into<Arc<[char]>>) -> Result<Self, CharSetError> {
38        let chars: Arc<[char]> = chars.into();
39
40        if chars.len() < 2 {
41            return Err(CharSetError::TooFew(chars.len()));
42        }
43
44        let mut index_of = HashMap::with_capacity(chars.len());
45
46        for (i, &ch) in chars.iter().enumerate() {
47            if index_of.insert(ch, i).is_some() {
48                return Err(CharSetError::Duplicate(ch));
49            }
50        }
51
52        Ok(Self { chars, index_of })
53    }
54
55    #[must_use]
56    pub fn len(&self) -> usize {
57        self.chars.len()
58    }
59
60    #[must_use]
61    pub fn is_empty(&self) -> bool {
62        self.chars.is_empty()
63    }
64
65    /// O(1) lookup by index.
66    pub fn get(&self, index: usize) -> Option<char> {
67        self.chars.get(index).copied()
68    }
69
70    /// O(1) index lookup for a character. Returns `None` if not in set.
71    pub fn index_of(&self, ch: char) -> Option<usize> {
72        self.index_of.get(&ch).copied()
73    }
74
75    pub fn contains(&self, ch: char) -> bool {
76        self.index_of.contains_key(&ch)
77    }
78
79    /// Forward-wrapping distance. Unknown characters resolve to index 0.
80    pub fn distance(&self, from: char, to: char) -> usize {
81        let a = self.resolve_index(from);
82        let b = self.resolve_index(to);
83
84        if a == b {
85            return 0;
86        }
87
88        let len = self.chars.len();
89        (b + len - a) % len
90    }
91
92    fn resolve_index(&self, ch: char) -> usize {
93        self.index_of.get(&ch).copied().unwrap_or(0)
94    }
95
96    pub(crate) fn step_forward(&self, from: char, steps: usize) -> char {
97        let start = self.resolve_index(from);
98        let target = (start + steps) % self.chars.len();
99        self.chars[target]
100    }
101
102    pub(crate) fn sanitize(&self, ch: char) -> char {
103        self.chars[self.resolve_index(ch)]
104    }
105}
106
107impl Default for CharSet {
108    fn default() -> Self {
109        Self::new(DEFAULT_CHARS).expect("default CharSet is valid")
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[test]
118    fn default_charset_starts_with_space() {
119        let cs = CharSet::default();
120        assert_eq!(cs.get(0), Some(' '));
121    }
122
123    #[test]
124    fn default_charset_contains_all_expected_characters() {
125        let cs = CharSet::default();
126
127        assert!(cs.contains(' '));
128
129        for ch in 'A'..='Z' {
130            assert!(cs.contains(ch), "missing letter {ch}");
131        }
132
133        for ch in '0'..='9' {
134            assert!(cs.contains(ch), "missing digit {ch}");
135        }
136
137        for ch in ['.', ',', '!', '?', '&', '#', '@', '-', '\'', ':', '/'] {
138            assert!(cs.contains(ch), "missing punctuation {ch}");
139        }
140    }
141
142    #[test]
143    fn default_charset_order() {
144        let cs = CharSet::default();
145        assert_eq!(cs.index_of('A'), Some(1));
146        assert_eq!(cs.index_of('Z'), Some(33));
147        assert_eq!(cs.index_of('0'), Some(35));
148        assert_eq!(cs.index_of('9'), Some(44));
149    }
150
151    #[test]
152    fn default_charset_length() {
153        let cs = CharSet::default();
154        assert_eq!(cs.len(), 56);
155    }
156
157    #[test]
158    fn default_charset_contains_variant_glyphs() {
159        let cs = CharSet::default();
160
161        for (base, variant) in [
162            ('D', 'Ð'),
163            ('I', 'Ɨ'),
164            ('P', 'Ᵽ'),
165            ('R', 'Ɍ'),
166            ('T', 'Ŧ'),
167            ('U', 'Ʉ'),
168            ('Y', 'Ɏ'),
169            ('Z', 'Ƶ'),
170        ] {
171            let base_idx = cs
172                .index_of(base)
173                .expect("base char should be in default charset");
174            let variant_idx = cs
175                .index_of(variant)
176                .expect("variant char should be in default charset");
177            assert_eq!(variant_idx, base_idx + 1, "{variant} should follow {base}");
178        }
179    }
180
181    #[test]
182    fn distance_forward_same_region() {
183        let cs = CharSet::default();
184        // A(1) → F(7): passes through B, C, D, Ð, E, F = distance 6
185        assert_eq!(cs.distance('A', 'F'), 6);
186    }
187
188    #[test]
189    fn distance_wraps_forward() {
190        let cs = CharSet::default();
191        let d = cs.distance('Z', 'A');
192
193        // Z(33) → A(1): forward wrap = (1 + 56 - 33) % 56 = 24
194        assert_eq!(d, 24);
195        assert!(d > 0, "must wrap forward, not backward");
196    }
197
198    #[test]
199    fn distance_identical_is_zero() {
200        let cs = CharSet::default();
201        assert_eq!(cs.distance('A', 'A'), 0);
202    }
203
204    #[test]
205    fn distance_unknown_char_treated_as_space() {
206        let cs = CharSet::default();
207
208        // '~' is not in the set → resolves to index 0 (space)
209        // distance from space to 'A' = 1
210        assert_eq!(cs.distance('~', 'A'), 1);
211    }
212
213    #[test]
214    fn step_forward_basic() {
215        let cs = CharSet::default();
216        // A(1) + 6 = 7 → F (passes B, C, D, Ð, E)
217        assert_eq!(cs.step_forward('A', 6), 'F');
218    }
219
220    #[test]
221    fn step_forward_wraps() {
222        let cs = CharSet::default();
223        let result = cs.step_forward('/', 1);
224        assert_eq!(result, ' ', "wrapping past last char returns to space");
225    }
226
227    #[test]
228    fn sanitize_known_char_unchanged() {
229        let cs = CharSet::default();
230        assert_eq!(cs.sanitize('A'), 'A');
231    }
232
233    #[test]
234    fn sanitize_unknown_char_becomes_space() {
235        let cs = CharSet::default();
236        assert_eq!(cs.sanitize('~'), ' ');
237    }
238
239    #[test]
240    fn custom_charset_accepted() {
241        static CUSTOM: &[char] = &['X', 'Y', 'Z'];
242        let cs = CharSet::new(CUSTOM).expect("CUSTOM should be a valid charset");
243        assert_eq!(cs.len(), 3);
244        assert_eq!(cs.distance('X', 'Z'), 2);
245    }
246
247    #[test]
248    fn custom_charset_too_few_rejected() {
249        static ONE: &[char] = &['A'];
250        let err = CharSet::new(ONE).expect_err("single-char charset should be rejected");
251        assert!(matches!(err, CharSetError::TooFew(1)));
252    }
253
254    #[test]
255    fn custom_charset_empty_rejected() {
256        static EMPTY: &[char] = &[];
257        let err = CharSet::new(EMPTY).expect_err("empty charset should be rejected");
258        assert!(matches!(err, CharSetError::TooFew(0)));
259    }
260
261    #[test]
262    fn custom_charset_duplicates_rejected() {
263        static DUPE: &[char] = &['A', 'B', 'A'];
264        let err = CharSet::new(DUPE).expect_err("charset with duplicates should be rejected");
265        assert!(matches!(err, CharSetError::Duplicate('A')));
266    }
267
268    #[test]
269    fn index_lookup_is_consistent() {
270        let cs = CharSet::default();
271
272        for i in 0..cs.len() {
273            let ch = cs.get(i).expect("index should be within charset bounds");
274            assert_eq!(cs.index_of(ch), Some(i));
275        }
276    }
277}