1use crate::CharSet;
4use crate::cell::{FlapCell, FlipStyle};
5
6#[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#[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 #[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 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 #[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 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 #[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 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 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 let _ = b.tick(10000);
264 assert_eq!(display_string(&b, 0), "ABC");
265 }
266
267 #[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 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 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 #[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 b.set_message(&FlapMessage::multi(["AAAAA", "ZZZZZ"]));
317
318 let _ = b.tick(80);
319
320 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 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 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 #[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 #[test]
384 fn message_speed_override() {
385 let mut b = board(1, 1).with_flip_speed_ms(80);
386
387 b.set_message(&FlapMessage::single("A").with_flip_speed(200));
389
390 assert!(b.tick(80));
392
393 assert!(!b.tick(120));
395 assert_eq!(cell_at(&b, 0, 0).display(), 'A');
396 }
397}