Skip to main content

tui_splitflap/
cell.rs

1//! Per-cell animation state and flip phase logic.
2
3use crate::CharSet;
4
5/// Animation behavior for a cell flip.
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum FlipStyle {
8    /// Cycles through each intermediate character in CharSet order.
9    /// Total duration = distance * flip_speed_ms.
10    Sequential,
11
12    /// Fixed split-block animation regardless of character distance.
13    /// Total duration = flip_speed_ms.
14    Mechanical { frames: u8 },
15
16    /// Mechanical split-block phase, then sequential character roll.
17    /// Total duration = distance * flip_speed_ms (same as Sequential,
18    /// but the first step is replaced by a mechanical animation).
19    Combined { frames: u8 },
20}
21
22/// What a cell is currently doing — determines how the widget renders it.
23#[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/// Per-cell animation state for a single split-flap tile.
32#[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    /// Some for Mechanical/Combined styles, None for Sequential.
43    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    /// Normalized animation progress in [0.0, 1.0].
176    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            // Combined, past mechanical phase — sequential rule
213            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                // Sequential: each flap takes flip_speed_ms to land
225                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                // Mechanical phase: widget handles visuals via phase()
231            }
232
233            Some(_) => {
234                // Combined: mechanical phase consumed step 0, so +1 offset
235                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
250/// How many flaps have landed: `floor(elapsed / speed)`, capped at `max`.
251fn 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
259/// Which mechanical frame we're on (0-based).
260fn 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    // --- Sequential ---
278
279    #[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        // A→F distance is 6: B, C, D, Ð, E, F
286        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        // Tick both to 80ms — short should be done, long should not
309        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        // Z→A wraps forward through Ƶ, 0-9, punctuation, space, A
333        assert!(cell.is_animating());
334
335        // First step: Ƶ (variant glyph immediately after Z)
336        cell.tick(80, &cs);
337        assert_eq!(cell.display(), 'Ƶ');
338    }
339
340    // --- Mechanical ---
341
342    #[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        // Both should complete at exactly 80ms
354        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        // 4 frames over 80ms = 20ms per frame
368        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        // Display stays as settled during mechanical phase
390        cell.tick(40, &cs);
391        assert_eq!(cell.display(), 'A');
392        assert!(cell.is_animating());
393    }
394
395    // --- Combined ---
396
397    #[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        // Distance = 6 (B,C,D,Ð,E,F), total = 480ms
404        // Mechanical phase: 0-79ms
405        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        // Both should complete at 480ms (distance 6 × 80ms)
440        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        // Distance 1, total = 80ms, all mechanical
454        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    // --- General behavior ---
462
463    #[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        // Tick way past completion
489        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    // --- Interrupt resolution ---
513
514    #[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        // step_forward(A, 14) = M (passes B,C,D,Ð,E,F,G,H,I,Ɨ,J,K,L,M)
521        cell.tick(80 * 14, &cs);
522        assert_eq!(cell.display(), 'M');
523
524        // Interrupt with new target 'B'
525        cell.set_target('B', FlipStyle::Sequential, &cs, 80, 0);
526
527        // Should start from M, not snap back to A
528        assert_eq!(cell.settled(), 'M');
529        assert_eq!(cell.target(), 'B');
530
531        // First intermediate after M should be N
532        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        // Frame 1 of 4 = progress 0.25 (< 0.5) → resolve to settled
543        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        // Frame 3 of 4 = progress 0.75 (>= 0.5) → resolve to target
556        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        // Still in mechanical phase (elapsed < flip_speed_ms)
569        // Late in mechanical phase: 60ms of 80ms → resolve to target
570        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        // Past mechanical phase, into sequential
584        // At elapsed 160: seq_elapsed=80, step=2, display=step_forward(A,2)=C
585        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    // --- Stagger ---
594
595    #[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        // 60ms of pending consumed
604        cell.tick(60, &cs);
605        assert_eq!(cell.phase(), FlipPhase::Pending);
606
607        // 40ms more → pending consumed, 0ms of animation elapsed
608        cell.tick(40, &cs);
609        assert_eq!(cell.phase(), FlipPhase::Sequential);
610        assert_eq!(cell.display(), 'A');
611
612        // After one full step, first flap lands
613        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        // Tick 130ms: 50ms pending + 80ms animation
624        cell.tick(130, &cs);
625        assert_eq!(cell.display(), 'B');
626    }
627
628    // --- Reset ---
629
630    #[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}