1use crate::CharSet;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum FlipStyle {
8 Sequential,
11
12 Mechanical { frames: u8 },
15
16 Combined { frames: u8 },
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum FlipPhase {
25 Settled,
26 Pending,
27 Mechanical { frame: u8, total_frames: u8 },
28 Sequential,
29}
30
31#[derive(Debug, Clone)]
33pub struct FlapCell {
34 settled: char,
35 display: char,
36 target: char,
37 elapsed_ms: u64,
38 total_ms: u64,
39 pending_ms: u64,
40 distance: usize,
41 flip_speed_ms: u64,
42 mechanical_frames: Option<u8>,
44}
45
46impl FlapCell {
47 pub fn new(ch: char) -> Self {
48 Self {
49 settled: ch,
50 display: ch,
51 target: ch,
52 elapsed_ms: 0,
53 total_ms: 0,
54 pending_ms: 0,
55 distance: 0,
56 flip_speed_ms: 0,
57 mechanical_frames: None,
58 }
59 }
60
61 pub(crate) fn set_target(
62 &mut self,
63 target: char,
64 style: FlipStyle,
65 charset: &CharSet,
66 flip_speed_ms: u64,
67 pending_ms: u64,
68 ) {
69 if self.is_animating() {
70 let resolved = self.resolve_interrupt();
71 self.settled = resolved;
72 self.display = resolved;
73 }
74
75 let target = charset.sanitize(target);
76 let distance = charset.distance(self.display, target);
77
78 self.target = target;
79
80 if distance == 0 {
81 self.distance = 0;
82 return;
83 }
84
85 self.configure_for_style(style, distance, flip_speed_ms, pending_ms);
86 }
87
88 fn configure_for_style(
89 &mut self,
90 style: FlipStyle,
91 distance: usize,
92 flip_speed_ms: u64,
93 pending_ms: u64,
94 ) {
95 self.distance = distance;
96 self.flip_speed_ms = flip_speed_ms;
97 self.elapsed_ms = 0;
98 self.pending_ms = pending_ms;
99
100 self.mechanical_frames = match style {
101 FlipStyle::Sequential => None,
102 FlipStyle::Mechanical { frames } | FlipStyle::Combined { frames } => Some(frames),
103 };
104
105 self.total_ms = match style {
106 FlipStyle::Sequential | FlipStyle::Combined { .. } => distance as u64 * flip_speed_ms,
107 FlipStyle::Mechanical { .. } => flip_speed_ms,
108 };
109 }
110
111 pub(crate) fn tick(&mut self, mut delta_ms: u64, charset: &CharSet) -> bool {
112 if !self.is_animating() || delta_ms == 0 {
113 return self.is_animating();
114 }
115
116 if self.pending_ms > 0 {
117 if delta_ms <= self.pending_ms {
118 self.pending_ms -= delta_ms;
119 return true;
120 }
121
122 delta_ms -= self.pending_ms;
123 self.pending_ms = 0;
124 }
125
126 self.elapsed_ms += delta_ms;
127
128 if self.elapsed_ms >= self.total_ms {
129 self.complete();
130 return false;
131 }
132
133 self.update_display(charset);
134 true
135 }
136
137 pub fn is_animating(&self) -> bool {
138 self.distance > 0 && (self.pending_ms > 0 || self.elapsed_ms < self.total_ms)
139 }
140
141 pub fn phase(&self) -> FlipPhase {
142 if !self.is_animating() {
143 return FlipPhase::Settled;
144 }
145
146 if self.pending_ms > 0 {
147 return FlipPhase::Pending;
148 }
149
150 match self.mechanical_frames {
151 None => FlipPhase::Sequential,
152 Some(frames) if self.in_mechanical_phase() => {
153 let frame = mechanical_frame(self.elapsed_ms, self.flip_speed_ms, frames);
154 FlipPhase::Mechanical {
155 frame,
156 total_frames: frames,
157 }
158 }
159 Some(_) => FlipPhase::Sequential,
160 }
161 }
162
163 pub fn display(&self) -> char {
164 self.display
165 }
166
167 pub fn settled(&self) -> char {
168 self.settled
169 }
170
171 pub fn target(&self) -> char {
172 self.target
173 }
174
175 pub fn progress(&self) -> f32 {
177 if self.total_ms == 0 {
178 return 0.0;
179 }
180
181 (self.elapsed_ms as f32 / self.total_ms as f32).min(1.0)
182 }
183
184 pub(crate) fn reset(&mut self, ch: char) {
185 self.settled = ch;
186 self.display = ch;
187 self.target = ch;
188 self.elapsed_ms = 0;
189 self.total_ms = 0;
190 self.pending_ms = 0;
191 self.distance = 0;
192 }
193
194 fn resolve_interrupt(&self) -> char {
195 match self.mechanical_frames {
196 None => self.display,
197
198 Some(_) if self.in_mechanical_phase() => {
199 let mech_progress = if self.flip_speed_ms == 0 {
200 1.0
201 } else {
202 self.elapsed_ms as f32 / self.flip_speed_ms as f32
203 };
204
205 if mech_progress < 0.5 {
206 self.settled
207 } else {
208 self.target
209 }
210 }
211
212 Some(_) => self.display,
214 }
215 }
216
217 fn in_mechanical_phase(&self) -> bool {
218 self.mechanical_frames.is_some() && self.elapsed_ms < self.flip_speed_ms
219 }
220
221 fn update_display(&mut self, charset: &CharSet) {
222 match self.mechanical_frames {
223 None => {
224 let step = clamped_step(self.elapsed_ms, self.flip_speed_ms, self.distance);
226 self.display = charset.step_forward(self.settled, step);
227 }
228
229 Some(_) if self.in_mechanical_phase() => {
230 }
232
233 Some(_) => {
234 let seq_elapsed = self.elapsed_ms - self.flip_speed_ms;
236 let step = clamped_step(seq_elapsed, self.flip_speed_ms, self.distance) + 1;
237 self.display = charset.step_forward(self.settled, step.min(self.distance));
238 }
239 }
240 }
241
242 fn complete(&mut self) {
243 self.elapsed_ms = self.total_ms;
244 self.display = self.target;
245 self.settled = self.target;
246 self.distance = 0;
247 }
248}
249
250fn clamped_step(elapsed_ms: u64, flip_speed_ms: u64, max: usize) -> usize {
252 if flip_speed_ms == 0 {
253 return max;
254 }
255
256 ((elapsed_ms / flip_speed_ms) as usize).min(max)
257}
258
259fn mechanical_frame(elapsed_ms: u64, flip_speed_ms: u64, frames: u8) -> u8 {
261 if flip_speed_ms == 0 {
262 return frames.saturating_sub(1);
263 }
264
265 let frame = (elapsed_ms * frames as u64 / flip_speed_ms) as u8;
266 frame.min(frames.saturating_sub(1))
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272
273 fn default_charset() -> CharSet {
274 CharSet::default()
275 }
276
277 #[test]
280 fn sequential_cycles_through_intermediates() {
281 let cs = default_charset();
282 let mut cell = FlapCell::new('A');
283 cell.set_target('F', FlipStyle::Sequential, &cs, 80, 0);
284
285 let expected = ['B', 'C', 'D', 'Ð', 'E', 'F'];
287
288 for &ch in &expected[..5] {
289 cell.tick(80, &cs);
290 assert_eq!(cell.display(), ch, "expected intermediate {ch}");
291 }
292
293 cell.tick(80, &cs);
294 assert_eq!(cell.display(), 'F');
295 assert!(!cell.is_animating());
296 }
297
298 #[test]
299 fn sequential_duration_scales_with_distance() {
300 let cs = default_charset();
301
302 let mut short = FlapCell::new('A');
303 short.set_target('B', FlipStyle::Sequential, &cs, 80, 0);
304
305 let mut long = FlapCell::new('A');
306 long.set_target('Z', FlipStyle::Sequential, &cs, 80, 0);
307
308 short.tick(80, &cs);
310 long.tick(80, &cs);
311
312 assert!(!short.is_animating());
313 assert!(long.is_animating());
314 }
315
316 #[test]
317 fn sequential_identical_chars_no_animation() {
318 let cs = default_charset();
319 let mut cell = FlapCell::new('A');
320 cell.set_target('A', FlipStyle::Sequential, &cs, 80, 0);
321
322 assert!(!cell.is_animating());
323 assert_eq!(cell.display(), 'A');
324 }
325
326 #[test]
327 fn sequential_wraps_forward() {
328 let cs = default_charset();
329 let mut cell = FlapCell::new('Z');
330 cell.set_target('A', FlipStyle::Sequential, &cs, 80, 0);
331
332 assert!(cell.is_animating());
334
335 cell.tick(80, &cs);
337 assert_eq!(cell.display(), 'Ƶ');
338 }
339
340 #[test]
343 fn mechanical_constant_duration() {
344 let cs = default_charset();
345 let style = FlipStyle::Mechanical { frames: 4 };
346
347 let mut short = FlapCell::new('A');
348 short.set_target('B', style, &cs, 80, 0);
349
350 let mut long = FlapCell::new('A');
351 long.set_target('Z', style, &cs, 80, 0);
352
353 short.tick(80, &cs);
355 long.tick(80, &cs);
356
357 assert!(!short.is_animating());
358 assert!(!long.is_animating());
359 }
360
361 #[test]
362 fn mechanical_frame_sequence() {
363 let cs = default_charset();
364 let mut cell = FlapCell::new('A');
365 cell.set_target('Z', FlipStyle::Mechanical { frames: 4 }, &cs, 80, 0);
366
367 for expected_frame in 0..4u8 {
369 assert_eq!(
370 cell.phase(),
371 FlipPhase::Mechanical {
372 frame: expected_frame,
373 total_frames: 4
374 }
375 );
376 cell.tick(20, &cs);
377 }
378
379 assert!(!cell.is_animating());
380 assert_eq!(cell.display(), 'Z');
381 }
382
383 #[test]
384 fn mechanical_display_unchanged_during_flip() {
385 let cs = default_charset();
386 let mut cell = FlapCell::new('A');
387 cell.set_target('Z', FlipStyle::Mechanical { frames: 4 }, &cs, 80, 0);
388
389 cell.tick(40, &cs);
391 assert_eq!(cell.display(), 'A');
392 assert!(cell.is_animating());
393 }
394
395 #[test]
398 fn combined_mechanical_then_sequential() {
399 let cs = default_charset();
400 let mut cell = FlapCell::new('A');
401 cell.set_target('F', FlipStyle::Combined { frames: 3 }, &cs, 80, 0);
402
403 assert!(matches!(cell.phase(), FlipPhase::Mechanical { .. }));
406
407 cell.tick(80, &cs);
408 assert_eq!(cell.phase(), FlipPhase::Sequential);
409 assert_eq!(cell.display(), 'B');
410
411 cell.tick(80, &cs);
412 assert_eq!(cell.display(), 'C');
413
414 cell.tick(80, &cs);
415 assert_eq!(cell.display(), 'D');
416
417 cell.tick(80, &cs);
418 assert_eq!(cell.display(), 'Ð');
419
420 cell.tick(80, &cs);
421 assert_eq!(cell.display(), 'E');
422 assert!(cell.is_animating());
423
424 cell.tick(80, &cs);
425 assert!(!cell.is_animating());
426 assert_eq!(cell.display(), 'F');
427 }
428
429 #[test]
430 fn combined_same_total_duration_as_sequential() {
431 let cs = default_charset();
432
433 let mut seq = FlapCell::new('A');
434 seq.set_target('F', FlipStyle::Sequential, &cs, 80, 0);
435
436 let mut comb = FlapCell::new('A');
437 comb.set_target('F', FlipStyle::Combined { frames: 3 }, &cs, 80, 0);
438
439 seq.tick(480, &cs);
441 comb.tick(480, &cs);
442
443 assert!(!seq.is_animating());
444 assert!(!comb.is_animating());
445 }
446
447 #[test]
448 fn combined_distance_one_no_sequential_phase() {
449 let cs = default_charset();
450 let mut cell = FlapCell::new('A');
451 cell.set_target('B', FlipStyle::Combined { frames: 3 }, &cs, 80, 0);
452
453 assert!(matches!(cell.phase(), FlipPhase::Mechanical { .. }));
455
456 cell.tick(80, &cs);
457 assert!(!cell.is_animating());
458 assert_eq!(cell.display(), 'B');
459 }
460
461 #[test]
464 fn progress_range() {
465 let cs = default_charset();
466 let mut cell = FlapCell::new('A');
467 cell.set_target('F', FlipStyle::Sequential, &cs, 80, 0);
468
469 assert_eq!(cell.progress(), 0.0);
470
471 cell.tick(200, &cs);
472 let p = cell.progress();
473 assert!(
474 p > 0.0 && p < 1.0,
475 "mid-flip progress should be in (0, 1), got {p}"
476 );
477
478 cell.tick(300, &cs);
479 assert!(!cell.is_animating());
480 }
481
482 #[test]
483 fn tick_past_completion_clamps() {
484 let cs = default_charset();
485 let mut cell = FlapCell::new('A');
486 cell.set_target('B', FlipStyle::Sequential, &cs, 80, 0);
487
488 cell.tick(10_000, &cs);
490 assert!(!cell.is_animating());
491 assert_eq!(cell.display(), 'B');
492 }
493
494 #[test]
495 fn tick_zero_is_noop() {
496 let cs = default_charset();
497 let mut cell = FlapCell::new('A');
498 cell.set_target('F', FlipStyle::Sequential, &cs, 80, 0);
499
500 let progress_before = cell.progress();
501 cell.tick(0, &cs);
502 assert_eq!(cell.progress(), progress_before);
503 }
504
505 #[test]
506 fn settled_cell_renders_character() {
507 let cell = FlapCell::new('X');
508 assert_eq!(cell.phase(), FlipPhase::Settled);
509 assert_eq!(cell.display(), 'X');
510 }
511
512 #[test]
515 fn interrupt_sequential_starts_from_display() {
516 let cs = default_charset();
517 let mut cell = FlapCell::new('A');
518 cell.set_target('Z', FlipStyle::Sequential, &cs, 80, 0);
519
520 cell.tick(80 * 14, &cs);
522 assert_eq!(cell.display(), 'M');
523
524 cell.set_target('B', FlipStyle::Sequential, &cs, 80, 0);
526
527 assert_eq!(cell.settled(), 'M');
529 assert_eq!(cell.target(), 'B');
530
531 cell.tick(80, &cs);
533 assert_eq!(cell.display(), 'N');
534 }
535
536 #[test]
537 fn interrupt_mechanical_early_resolves_to_settled() {
538 let cs = default_charset();
539 let mut cell = FlapCell::new('A');
540 cell.set_target('Z', FlipStyle::Mechanical { frames: 4 }, &cs, 80, 0);
541
542 cell.tick(20, &cs);
544
545 cell.set_target('B', FlipStyle::Mechanical { frames: 4 }, &cs, 80, 0);
546 assert_eq!(cell.settled(), 'A');
547 }
548
549 #[test]
550 fn interrupt_mechanical_late_resolves_to_target() {
551 let cs = default_charset();
552 let mut cell = FlapCell::new('A');
553 cell.set_target('Z', FlipStyle::Mechanical { frames: 4 }, &cs, 80, 0);
554
555 cell.tick(60, &cs);
557
558 cell.set_target('B', FlipStyle::Mechanical { frames: 4 }, &cs, 80, 0);
559 assert_eq!(cell.settled(), 'Z');
560 }
561
562 #[test]
563 fn interrupt_combined_mechanical_phase_resolves_like_mechanical() {
564 let cs = default_charset();
565 let mut cell = FlapCell::new('A');
566 cell.set_target('Z', FlipStyle::Combined { frames: 3 }, &cs, 80, 0);
567
568 cell.tick(60, &cs);
571 assert!(cell.in_mechanical_phase());
572
573 cell.set_target('B', FlipStyle::Combined { frames: 3 }, &cs, 80, 0);
574 assert_eq!(cell.settled(), 'Z');
575 }
576
577 #[test]
578 fn interrupt_combined_sequential_phase_starts_from_display() {
579 let cs = default_charset();
580 let mut cell = FlapCell::new('A');
581 cell.set_target('Z', FlipStyle::Combined { frames: 3 }, &cs, 80, 0);
582
583 cell.tick(160, &cs);
586 assert_eq!(cell.phase(), FlipPhase::Sequential);
587 assert_eq!(cell.display(), 'C');
588
589 cell.set_target('B', FlipStyle::Combined { frames: 3 }, &cs, 80, 0);
590 assert_eq!(cell.settled(), 'C');
591 }
592
593 #[test]
596 fn pending_delay_consumed_before_animation() {
597 let cs = default_charset();
598 let mut cell = FlapCell::new('A');
599 cell.set_target('F', FlipStyle::Sequential, &cs, 80, 100);
600
601 assert_eq!(cell.phase(), FlipPhase::Pending);
602
603 cell.tick(60, &cs);
605 assert_eq!(cell.phase(), FlipPhase::Pending);
606
607 cell.tick(40, &cs);
609 assert_eq!(cell.phase(), FlipPhase::Sequential);
610 assert_eq!(cell.display(), 'A');
611
612 cell.tick(80, &cs);
614 assert_eq!(cell.display(), 'B');
615 }
616
617 #[test]
618 fn pending_overflow_applies_to_animation() {
619 let cs = default_charset();
620 let mut cell = FlapCell::new('A');
621 cell.set_target('F', FlipStyle::Sequential, &cs, 80, 50);
622
623 cell.tick(130, &cs);
625 assert_eq!(cell.display(), 'B');
626 }
627
628 #[test]
631 fn reset_clears_to_char() {
632 let cs = default_charset();
633 let mut cell = FlapCell::new('A');
634 cell.set_target('Z', FlipStyle::Sequential, &cs, 80, 0);
635 cell.tick(80, &cs);
636
637 cell.reset(' ');
638 assert_eq!(cell.display(), ' ');
639 assert_eq!(cell.settled(), ' ');
640 assert!(!cell.is_animating());
641 assert_eq!(cell.progress(), 0.0);
642 }
643}