Skip to main content

tui_splitflap/
state.rs

1//! Board-level animation state and message API.
2
3use crate::CharSet;
4use crate::cell::{FlapCell, FlipStyle};
5
6/// A message to display on the board, with optional per-message overrides.
7#[derive(Debug, Clone)]
8pub struct FlapMessage {
9    pub rows: Vec<String>,
10    pub flip_speed_ms: Option<u64>,
11}
12
13impl FlapMessage {
14    pub fn single(text: impl Into<String>) -> Self {
15        Self {
16            rows: vec![text.into()],
17            flip_speed_ms: None,
18        }
19    }
20
21    pub fn multi(rows: impl IntoIterator<Item = impl Into<String>>) -> Self {
22        Self {
23            rows: rows.into_iter().map(Into::into).collect(),
24            flip_speed_ms: None,
25        }
26    }
27
28    pub fn with_flip_speed(mut self, ms: u64) -> Self {
29        self.flip_speed_ms = Some(ms);
30        self
31    }
32}
33
34/// Owns all per-cell animation state for a split-flap board.
35/// Driven externally via `tick()` and `set_message()`.
36#[derive(Debug, Clone)]
37pub struct FlapBoardState {
38    cells: Vec<Vec<FlapCell>>,
39    charset: CharSet,
40    flip_style: FlipStyle,
41    flip_speed_ms: u64,
42    stagger_ms: u64,
43}
44
45impl FlapBoardState {
46    pub fn new(rows: usize, cols: usize) -> Self {
47        let cells = (0..rows)
48            .map(|_| (0..cols).map(|_| FlapCell::new(' ')).collect())
49            .collect();
50
51        Self {
52            cells,
53            charset: CharSet::default(),
54            flip_style: FlipStyle::Sequential,
55            flip_speed_ms: 80,
56            stagger_ms: 0,
57        }
58    }
59
60    pub fn with_charset(mut self, charset: CharSet) -> Self {
61        self.charset = charset;
62        self
63    }
64
65    pub fn with_flip_style(mut self, style: FlipStyle) -> Self {
66        self.flip_style = style;
67        self
68    }
69
70    pub fn with_flip_speed_ms(mut self, ms: u64) -> Self {
71        self.flip_speed_ms = ms;
72        self
73    }
74
75    pub fn with_stagger_ms(mut self, ms: u64) -> Self {
76        self.stagger_ms = ms;
77        self
78    }
79
80    /// Stagger is column-only: each column's pending delay is `col_idx * stagger_ms`.
81    /// In multi-row boards, all rows begin simultaneously.
82    #[must_use]
83    pub fn tick(&mut self, delta_ms: u64) -> bool {
84        let mut any_animating = false;
85
86        for row in &mut self.cells {
87            for cell in row {
88                if cell.tick(delta_ms, &self.charset) {
89                    any_animating = true;
90                }
91            }
92        }
93
94        any_animating
95    }
96
97    pub fn set_message(&mut self, msg: &FlapMessage) {
98        let speed = msg.flip_speed_ms.unwrap_or(self.flip_speed_ms);
99
100        for (row_idx, row) in self.cells.iter_mut().enumerate() {
101            let text = msg.rows.get(row_idx).map(|s| s.as_str()).unwrap_or("");
102            let mut chars = text.chars();
103
104            for (col_idx, cell) in row.iter_mut().enumerate() {
105                let target = chars.next().unwrap_or(' ');
106                let pending = col_idx as u64 * self.stagger_ms;
107
108                cell.set_target(target, self.flip_style, &self.charset, speed, pending);
109            }
110        }
111    }
112
113    pub fn reset(&mut self) {
114        for row in &mut self.cells {
115            for cell in row {
116                cell.reset(' ');
117            }
118        }
119    }
120
121    pub fn cell(&self, row: usize, col: usize) -> Option<&FlapCell> {
122        self.cells.get(row).and_then(|r| r.get(col))
123    }
124
125    pub fn rows(&self) -> usize {
126        self.cells.len()
127    }
128
129    pub fn cols(&self) -> usize {
130        self.cells.first().map_or(0, |r| r.len())
131    }
132
133    pub fn charset(&self) -> &CharSet {
134        &self.charset
135    }
136
137    pub fn flip_style(&self) -> FlipStyle {
138        self.flip_style
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use crate::cell::FlipPhase;
146
147    fn board(rows: usize, cols: usize) -> FlapBoardState {
148        FlapBoardState::new(rows, cols)
149    }
150
151    fn settled_board(text: &str, cols: usize) -> FlapBoardState {
152        let mut b = board(1, cols).with_flip_speed_ms(80);
153        b.set_message(&FlapMessage::single(text));
154
155        // Default charset has 56 chars; max distance is 55 steps × 80ms = 4400ms
156        let _ = b.tick(55 * 80);
157        b
158    }
159
160    fn cell_at(b: &FlapBoardState, row: usize, col: usize) -> &FlapCell {
161        b.cell(row, col).expect("cell should exist at row, col")
162    }
163
164    fn display_string(b: &FlapBoardState, row: usize) -> String {
165        (0..b.cols())
166            .map(|c| cell_at(b, row, c).display())
167            .collect()
168    }
169
170    // --- Tick ---
171
172    #[test]
173    fn tick_advances_animating_cells() {
174        let mut b = board(1, 5).with_flip_speed_ms(80);
175        b.set_message(&FlapMessage::single("ABCDE"));
176
177        let _ = b.tick(40);
178
179        for col in 0..5 {
180            let p = cell_at(&b, 0, col).progress();
181            assert!(p > 0.0, "col {col} should have advanced");
182        }
183    }
184
185    #[test]
186    fn tick_completes_cells() {
187        let mut b = board(1, 3).with_flip_speed_ms(80);
188        b.set_message(&FlapMessage::single("AAA"));
189
190        // Space→A is distance 1, total = 80ms
191        let _ = b.tick(80);
192
193        for col in 0..3 {
194            assert!(!cell_at(&b, 0, col).is_animating());
195            assert_eq!(cell_at(&b, 0, col).display(), 'A');
196        }
197    }
198
199    #[test]
200    fn tick_zero_is_noop() {
201        let mut b = board(1, 3).with_flip_speed_ms(80);
202        b.set_message(&FlapMessage::single("ABC"));
203
204        let before: Vec<f32> = (0..3).map(|c| cell_at(&b, 0, c).progress()).collect();
205        let _ = b.tick(0);
206        let after: Vec<f32> = (0..3).map(|c| cell_at(&b, 0, c).progress()).collect();
207
208        assert_eq!(before, after);
209    }
210
211    #[test]
212    fn tick_returns_animating_status() {
213        let mut b = board(1, 1).with_flip_speed_ms(80);
214        b.set_message(&FlapMessage::single("A"));
215
216        assert!(b.tick(40));
217        assert!(!b.tick(80));
218    }
219
220    // --- set_message ---
221
222    #[test]
223    fn only_changed_cells_animate() {
224        let mut b = settled_board("HELLO", 10);
225
226        b.set_message(&FlapMessage::single("HALLO"));
227
228        // H unchanged, E→A changed, L unchanged twice, O unchanged
229        assert!(!cell_at(&b, 0, 0).is_animating(), "H→H should not animate");
230        assert!(cell_at(&b, 0, 1).is_animating(), "E→A should animate");
231        assert!(!cell_at(&b, 0, 2).is_animating(), "L→L should not animate");
232        assert!(!cell_at(&b, 0, 3).is_animating(), "L→L should not animate");
233        assert!(!cell_at(&b, 0, 4).is_animating(), "O→O should not animate");
234    }
235
236    #[test]
237    fn short_message_pads_with_spaces() {
238        let mut b = settled_board("HELLO", 10);
239
240        b.set_message(&FlapMessage::single("HI"));
241
242        assert_eq!(cell_at(&b, 0, 0).target(), 'H');
243        assert_eq!(cell_at(&b, 0, 1).target(), 'I');
244
245        // Remaining cells should target space
246        for col in 2..10 {
247            let cell = cell_at(&b, 0, col);
248
249            if cell.is_animating() {
250                assert_eq!(cell.target(), ' ', "col {col} should target space");
251            } else {
252                assert_eq!(cell.display(), ' ', "col {col} should display space");
253            }
254        }
255    }
256
257    #[test]
258    fn message_truncated_to_board_width() {
259        let mut b = board(1, 3).with_flip_speed_ms(80);
260        b.set_message(&FlapMessage::single("ABCDEF"));
261
262        // Only first 3 chars should be set
263        let _ = b.tick(10000);
264        assert_eq!(display_string(&b, 0), "ABC");
265    }
266
267    // --- Stagger ---
268
269    #[test]
270    fn stagger_offsets_cell_start_times() {
271        let mut b = board(1, 5).with_flip_speed_ms(80).with_stagger_ms(20);
272        b.set_message(&FlapMessage::single("ABCDE"));
273
274        // At t=0, all cells are pending except col 0
275        assert_eq!(cell_at(&b, 0, 0).phase(), FlipPhase::Sequential);
276        assert_eq!(cell_at(&b, 0, 1).phase(), FlipPhase::Pending);
277        assert_eq!(cell_at(&b, 0, 4).phase(), FlipPhase::Pending);
278
279        // After 20ms, col 0 has been animating for 20ms, col 1 just started
280        let _ = b.tick(20);
281        assert_eq!(cell_at(&b, 0, 1).phase(), FlipPhase::Sequential);
282        assert_eq!(cell_at(&b, 0, 2).phase(), FlipPhase::Pending);
283    }
284
285    #[test]
286    fn stagger_zero_all_simultaneous() {
287        let mut b = board(1, 5).with_flip_speed_ms(80).with_stagger_ms(0);
288        b.set_message(&FlapMessage::single("ABCDE"));
289
290        for col in 0..5 {
291            assert_ne!(
292                cell_at(&b, 0, col).phase(),
293                FlipPhase::Pending,
294                "col {col} should not be pending with stagger=0"
295            );
296        }
297    }
298
299    // --- Multi-row ---
300
301    #[test]
302    fn multi_row_maps_message_rows_to_cell_rows() {
303        let mut b = board(2, 10).with_flip_speed_ms(80);
304        b.set_message(&FlapMessage::multi(["DEPARTING ", "GATE 7B   "]));
305
306        let _ = b.tick(10000);
307        assert_eq!(display_string(&b, 0), "DEPARTING ");
308        assert_eq!(display_string(&b, 1), "GATE 7B   ");
309    }
310
311    #[test]
312    fn rows_animate_independently() {
313        let mut b = board(2, 5).with_flip_speed_ms(80);
314
315        // Row 0: space→A (distance 1, 80ms), Row 1: space→Z (distance 26, 2080ms)
316        b.set_message(&FlapMessage::multi(["AAAAA", "ZZZZZ"]));
317
318        let _ = b.tick(80);
319
320        // Row 0 should be done
321        for col in 0..5 {
322            assert!(
323                !cell_at(&b, 0, col).is_animating(),
324                "row 0 col {col} should be settled"
325            );
326        }
327
328        // Row 1 should still be animating
329        for col in 0..5 {
330            assert!(
331                cell_at(&b, 1, col).is_animating(),
332                "row 1 col {col} should still animate"
333            );
334        }
335    }
336
337    #[test]
338    fn missing_message_rows_pad_with_spaces() {
339        let mut b = board(2, 5).with_flip_speed_ms(80);
340
341        // Only one row of text for a 2-row board
342        b.set_message(&FlapMessage::single("HELLO"));
343
344        let _ = b.tick(10000);
345        assert_eq!(display_string(&b, 0), "HELLO");
346        assert_eq!(display_string(&b, 1), "     ");
347    }
348
349    // --- Reset ---
350
351    #[test]
352    fn reset_clears_all_cells() {
353        let mut b = settled_board("HELLO", 10);
354
355        b.reset();
356
357        for col in 0..10 {
358            let cell = cell_at(&b, 0, col);
359            assert_eq!(cell.display(), ' ');
360            assert_eq!(cell.settled(), ' ');
361            assert!(!cell.is_animating());
362            assert_eq!(cell.progress(), 0.0);
363        }
364    }
365
366    #[test]
367    fn reset_stops_in_flight_animation() {
368        let mut b = board(1, 5).with_flip_speed_ms(80);
369        b.set_message(&FlapMessage::single("HELLO"));
370        let _ = b.tick(20);
371
372        b.reset();
373
374        assert!(!b.tick(0));
375
376        for col in 0..5 {
377            assert!(!cell_at(&b, 0, col).is_animating());
378        }
379    }
380
381    // --- Per-message speed override ---
382
383    #[test]
384    fn message_speed_override() {
385        let mut b = board(1, 1).with_flip_speed_ms(80);
386
387        // Space→A distance 1, with override speed 200ms
388        b.set_message(&FlapMessage::single("A").with_flip_speed(200));
389
390        // At 80ms (default speed), should still be animating
391        assert!(b.tick(80));
392
393        // At 200ms total, should complete
394        assert!(!b.tick(120));
395        assert_eq!(cell_at(&b, 0, 0).display(), 'A');
396    }
397}