Skip to main content

purple_ssh/
animation.rs

1use std::time::Instant;
2
3use ratatui::buffer::Buffer;
4
5use crate::app::{App, PingStatus, Screen};
6
7/// Braille spinner sequence for ping Checking status.
8pub const SPINNER_FRAMES: &[&str] = &[
9    "\u{280B}", // ⠋
10    "\u{2819}", // ⠙
11    "\u{2839}", // ⠹
12    "\u{2838}", // ⠸
13    "\u{283C}", // ⠼
14    "\u{2834}", // ⠴
15    "\u{2826}", // ⠦
16    "\u{2827}", // ⠧
17    "\u{2807}", // ⠇
18    "\u{280F}", // ⠏
19];
20
21/// Detail panel animation duration in milliseconds.
22const DETAIL_ANIM_DURATION_MS: u128 = 200;
23
24/// Overlay animation duration in milliseconds.
25const OVERLAY_ANIM_DURATION_MS: u128 = 250;
26
27/// Welcome overlay animation duration in milliseconds.
28const WELCOME_ANIM_DURATION_MS: u128 = 350;
29
30/// Active detail panel width animation.
31pub(crate) struct DetailAnim {
32    start: Instant,
33    opening: bool,
34    start_progress: f32,
35}
36
37/// Active overlay open/close animation.
38pub(crate) struct OverlayAnim {
39    pub(crate) start: Instant,
40    pub(crate) opening: bool,
41    pub(crate) duration_ms: u128,
42}
43
44/// Captured overlay state for close animation. Bundles the buffer snapshot with the
45/// dim flag so they are always in sync (the close animation knows whether to dim).
46pub(crate) struct OverlayCloseState {
47    pub(crate) buffer: Buffer,
48    pub(crate) dimmed: bool,
49}
50
51/// Animation state kept separate from App (render-layer concern).
52pub struct AnimationState {
53    pub spinner_tick: u64,
54    pub(crate) prev_was_overlay: bool,
55    pub(crate) detail_anim: Option<DetailAnim>,
56    pub(crate) overlay_anim: Option<OverlayAnim>,
57    /// Saved overlay state for close animation (captured once when overlay is stable).
58    pub(crate) overlay_close: Option<OverlayCloseState>,
59    /// Tunnels detail panel height animation. Triggered when the
60    /// selected tunnel changes its active state (or the user navigates
61    /// to a tunnel with a different state). Mirrors the host_list
62    /// detail-panel anim with the same 200ms cubic ease-out, but
63    /// scales panel HEIGHT instead of width.
64    pub(crate) tunnel_panel_anim: Option<DetailAnim>,
65    /// Last-frame visibility used to detect open/close transitions.
66    /// `None` means we have not observed any frame yet — the first
67    /// call seeds it without triggering an animation so a fresh
68    /// `AnimationState` does not flicker the panel into existence.
69    pub(crate) prev_tunnel_panel_visible: Option<bool>,
70}
71
72impl AnimationState {
73    pub fn new() -> Self {
74        Self {
75            spinner_tick: 0,
76            prev_was_overlay: false,
77            detail_anim: None,
78            overlay_anim: None,
79            overlay_close: None,
80            tunnel_panel_anim: None,
81            prev_tunnel_panel_visible: None,
82        }
83    }
84
85    /// Whether any animation is running.
86    pub fn is_animating(&self, app: &App) -> bool {
87        let welcome_animating = app
88            .ui
89            .welcome_opened()
90            .is_some_and(|t| t.elapsed().as_millis() < 3000);
91        self.detail_anim.is_some()
92            || self.tunnel_panel_anim.is_some()
93            || self.overlay_anim.is_some()
94            || welcome_animating
95    }
96
97    /// Whether any host has PingStatus::Checking (spinner needs ticking).
98    pub fn has_checking_hosts(&self, app: &App) -> bool {
99        app.ping
100            .status_map()
101            .values()
102            .any(|s| matches!(s, PingStatus::Checking))
103    }
104
105    /// Whether any host is currently Reachable. Drives the "breathing"
106    /// pulse on online indicators: when at least one host is alive the
107    /// main loop runs at 80ms tick rate so `online_dot_pulsing` can
108    /// advance smoothly. Slow/Unreachable/Checking deliberately do NOT
109    /// pulse — only confirmed-online gets the subtle live signal.
110    pub fn has_reachable_hosts(&self, app: &App) -> bool {
111        app.ping
112            .status_map()
113            .values()
114            .any(|s| matches!(s, PingStatus::Reachable { .. }))
115    }
116
117    /// Advance spinner tick. Called from main loop at ~80ms intervals.
118    pub fn tick_spinner(&mut self) {
119        self.spinner_tick = self.spinner_tick.wrapping_add(1);
120    }
121
122    /// Current overlay animation progress (0.0 = hidden, 1.0 = fully visible).
123    pub fn overlay_anim_progress(&self) -> Option<f32> {
124        let anim = self.overlay_anim.as_ref()?;
125        let elapsed = anim.start.elapsed().as_millis();
126        if elapsed >= anim.duration_ms {
127            return None;
128        }
129        let t = elapsed as f32 / anim.duration_ms as f32;
130        let eased = 1.0 - (1.0 - t) * (1.0 - t) * (1.0 - t);
131        Some(if anim.opening { eased } else { 1.0 - eased })
132    }
133
134    /// Tick overlay animation: clean up when complete.
135    pub fn tick_overlay_anim(&mut self) {
136        if self.overlay_anim.is_some() && self.overlay_anim_progress().is_none() {
137            let was_closing = self.overlay_anim.as_ref().is_some_and(|a| !a.opening);
138            self.overlay_anim = None;
139            if was_closing {
140                self.overlay_close = None;
141            }
142        }
143    }
144
145    /// Current detail panel animation progress (0.0 = closed, 1.0 = open).
146    pub fn detail_anim_progress(&mut self) -> Option<f32> {
147        let anim = self.detail_anim.as_ref()?;
148        let elapsed = anim.start.elapsed().as_millis();
149        if elapsed >= DETAIL_ANIM_DURATION_MS {
150            self.detail_anim = None;
151            return None;
152        }
153        let t = elapsed as f32 / DETAIL_ANIM_DURATION_MS as f32;
154        let eased = 1.0 - (1.0 - t) * (1.0 - t) * (1.0 - t);
155        let progress = if anim.opening {
156            anim.start_progress + (1.0 - anim.start_progress) * eased
157        } else {
158            anim.start_progress * (1.0 - eased)
159        };
160        Some(progress)
161    }
162
163    /// Notify the animator that the tunnel detail panel target
164    /// visibility has been computed for this frame. Starts a slide
165    /// animation when the target flips, preserving the in-flight
166    /// progress so a flap mid-animation reverses smoothly.
167    pub fn note_tunnel_panel_target(&mut self, visible: bool) {
168        match self.prev_tunnel_panel_visible {
169            None => {
170                // First observation — no anim, just record state.
171                self.prev_tunnel_panel_visible = Some(visible);
172            }
173            Some(prev) if prev == visible => {}
174            Some(_) => {
175                let start_progress =
176                    self.tunnel_panel_anim_progress()
177                        .unwrap_or(if visible { 0.0 } else { 1.0 });
178                self.tunnel_panel_anim = Some(DetailAnim {
179                    start: Instant::now(),
180                    opening: visible,
181                    start_progress,
182                });
183                self.prev_tunnel_panel_visible = Some(visible);
184            }
185        }
186    }
187
188    /// Current tunnel-panel height animation progress
189    /// (0.0 = collapsed, 1.0 = full height). Returns `None` when no
190    /// animation is in flight.
191    pub fn tunnel_panel_anim_progress(&mut self) -> Option<f32> {
192        let anim = self.tunnel_panel_anim.as_ref()?;
193        let elapsed = anim.start.elapsed().as_millis();
194        if elapsed >= DETAIL_ANIM_DURATION_MS {
195            self.tunnel_panel_anim = None;
196            return None;
197        }
198        let t = elapsed as f32 / DETAIL_ANIM_DURATION_MS as f32;
199        let eased = 1.0 - (1.0 - t) * (1.0 - t) * (1.0 - t);
200        let progress = if anim.opening {
201            anim.start_progress + (1.0 - anim.start_progress) * eased
202        } else {
203            anim.start_progress * (1.0 - eased)
204        };
205        Some(progress)
206    }
207
208    /// Detect overlay open/close transitions and start animations.
209    pub fn detect_transitions(&mut self, app: &mut App) {
210        let is_overlay = !matches!(app.screen, Screen::HostList);
211
212        if is_overlay && !self.prev_was_overlay {
213            let is_welcome = matches!(app.screen, Screen::Welcome { .. });
214            if is_welcome {
215                app.ui.set_welcome_opened(Some(Instant::now()));
216            }
217            self.overlay_anim = Some(OverlayAnim {
218                start: Instant::now(),
219                opening: true,
220                duration_ms: if is_welcome {
221                    WELCOME_ANIM_DURATION_MS
222                } else {
223                    OVERLAY_ANIM_DURATION_MS
224                },
225            });
226        } else if !is_overlay && self.prev_was_overlay {
227            if self.overlay_close.is_some() {
228                self.overlay_anim = Some(OverlayAnim {
229                    start: Instant::now(),
230                    opening: false,
231                    duration_ms: OVERLAY_ANIM_DURATION_MS,
232                });
233            }
234            app.ui.set_welcome_opened(None);
235        }
236
237        // Detail panel toggle. Branched on `top_page` so the same
238        // `v` keybinding drives the right view_mode for the active
239        // tab. Only one detail panel is animating at a time, so a
240        // single `detail_anim` slot suffices.
241        if app.ui.detail_toggle_pending() {
242            app.ui.set_detail_toggle_pending(false);
243            let opening = match app.top_page {
244                crate::app::TopPage::Containers => {
245                    app.containers_overview.view_mode() == crate::app::ViewMode::Detailed
246                }
247                crate::app::TopPage::Snippets => {
248                    app.snippets.view_mode() == crate::app::ViewMode::Detailed
249                }
250                _ => app.hosts_state.view_mode() == crate::app::ViewMode::Detailed,
251            };
252            let start_progress =
253                self.detail_anim_progress()
254                    .unwrap_or(if opening { 0.0 } else { 1.0 });
255            self.detail_anim = Some(DetailAnim {
256                start: Instant::now(),
257                opening,
258                start_progress,
259            });
260        }
261
262        self.prev_was_overlay = is_overlay;
263    }
264}
265
266impl Default for AnimationState {
267    fn default() -> Self {
268        Self::new()
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use ratatui::layout::Rect;
275
276    use super::*;
277
278    fn make_app() -> App {
279        use std::path::PathBuf;
280        let config = crate::ssh_config::model::SshConfigFile {
281            elements: crate::ssh_config::model::SshConfigFile::parse_content(""),
282            path: PathBuf::from("/tmp/test_config"),
283            crlf: false,
284            bom: false,
285        };
286        App::new(config)
287    }
288
289    // --- Spinner tests ---
290
291    #[test]
292    fn spinner_frames_are_10() {
293        assert_eq!(SPINNER_FRAMES.len(), 10);
294    }
295
296    #[test]
297    fn spinner_frames_cycle_via_index() {
298        assert_eq!(SPINNER_FRAMES[0], "\u{280B}");
299        assert_eq!(SPINNER_FRAMES[1], "\u{2819}");
300        assert_eq!(SPINNER_FRAMES[10 % SPINNER_FRAMES.len()], "\u{280B}");
301    }
302
303    #[test]
304    fn spinner_frames_at_u64_max() {
305        let idx = (u64::MAX as usize) % SPINNER_FRAMES.len();
306        assert_eq!(SPINNER_FRAMES[idx], "\u{2834}");
307    }
308
309    #[test]
310    fn spinner_tick_wraps() {
311        let mut anim = AnimationState::new();
312        anim.spinner_tick = u64::MAX;
313        anim.tick_spinner();
314        assert_eq!(anim.spinner_tick, 0);
315    }
316
317    #[test]
318    fn spinner_tick_increments_by_one() {
319        let mut anim = AnimationState::new();
320        assert_eq!(anim.spinner_tick, 0);
321        anim.tick_spinner();
322        assert_eq!(anim.spinner_tick, 1);
323    }
324
325    // --- is_animating tests ---
326
327    #[test]
328    fn new_state_not_animating() {
329        let app = make_app();
330        let anim = AnimationState::new();
331        assert!(!anim.is_animating(&app));
332    }
333
334    #[test]
335    fn is_animating_with_overlay_anim() {
336        let mut app = make_app();
337        let mut anim = AnimationState::new();
338        app.screen = Screen::Help {
339            return_screen: Box::new(Screen::HostList),
340        };
341        anim.detect_transitions(&mut app);
342        assert!(anim.is_animating(&app));
343    }
344
345    #[test]
346    fn is_animating_with_detail_anim() {
347        let mut app = make_app();
348        let mut anim = AnimationState::new();
349        app.ui.set_detail_toggle_pending(true);
350        app.hosts_state
351            .set_view_mode(crate::app::ViewMode::Detailed);
352        anim.detect_transitions(&mut app);
353        assert!(anim.is_animating(&app));
354    }
355
356    // --- has_checking_hosts tests ---
357
358    #[test]
359    fn has_checking_hosts_empty() {
360        let app = make_app();
361        let anim = AnimationState::new();
362        assert!(!anim.has_checking_hosts(&app));
363    }
364
365    #[test]
366    fn has_checking_hosts_only_reachable() {
367        let mut app = make_app();
368        app.ping
369            .insert_status("host1".to_string(), PingStatus::Reachable { rtt_ms: 10 });
370        app.ping
371            .insert_status("host2".to_string(), PingStatus::Unreachable);
372        let anim = AnimationState::new();
373        assert!(!anim.has_checking_hosts(&app));
374    }
375
376    #[test]
377    fn has_checking_hosts_with_checking() {
378        let mut app = make_app();
379        app.ping
380            .insert_status("host2".to_string(), PingStatus::Checking);
381        let anim = AnimationState::new();
382        assert!(anim.has_checking_hosts(&app));
383    }
384
385    // --- overlay animation tests ---
386
387    #[test]
388    fn detect_transitions_opens_overlay() {
389        let mut app = make_app();
390        let mut anim = AnimationState::new();
391        app.screen = Screen::Help {
392            return_screen: Box::new(Screen::HostList),
393        };
394        anim.detect_transitions(&mut app);
395        assert!(anim.prev_was_overlay);
396        assert!(anim.overlay_anim.is_some());
397        assert!(anim.overlay_anim.as_ref().unwrap().opening);
398    }
399
400    #[test]
401    fn detect_transitions_closes_overlay() {
402        let mut app = make_app();
403        let mut anim = AnimationState::new();
404        app.screen = Screen::Help {
405            return_screen: Box::new(Screen::HostList),
406        };
407        anim.detect_transitions(&mut app);
408        // Simulate saved buffer
409        anim.overlay_close = Some(OverlayCloseState {
410            buffer: Buffer::empty(Rect::new(0, 0, 80, 24)),
411            dimmed: true,
412        });
413
414        app.screen = Screen::HostList;
415        anim.detect_transitions(&mut app);
416        assert!(!anim.prev_was_overlay);
417        assert!(anim.overlay_anim.is_some());
418        assert!(!anim.overlay_anim.as_ref().unwrap().opening);
419    }
420
421    #[test]
422    fn overlay_close_without_buffer_skips_anim() {
423        let mut app = make_app();
424        let mut anim = AnimationState::new();
425        app.screen = Screen::Help {
426            return_screen: Box::new(Screen::HostList),
427        };
428        anim.detect_transitions(&mut app);
429        // No overlay_buffer saved
430
431        app.screen = Screen::HostList;
432        anim.detect_transitions(&mut app);
433        // No close animation without a saved buffer
434        assert!(anim.overlay_anim.is_none() || anim.overlay_anim.as_ref().unwrap().opening);
435    }
436
437    #[test]
438    fn overlay_anim_progress_returns_value() {
439        let mut app = make_app();
440        let mut anim = AnimationState::new();
441        app.screen = Screen::Help {
442            return_screen: Box::new(Screen::HostList),
443        };
444        anim.detect_transitions(&mut app);
445        let progress = anim.overlay_anim_progress();
446        assert!(progress.is_some());
447        assert!((0.0..=1.0).contains(&progress.unwrap()));
448    }
449
450    #[test]
451    fn tick_overlay_anim_clears_on_completion() {
452        let mut app = make_app();
453        let mut anim = AnimationState::new();
454        app.screen = Screen::Help {
455            return_screen: Box::new(Screen::HostList),
456        };
457        anim.detect_transitions(&mut app);
458        // Fast-forward
459        anim.overlay_anim.as_mut().unwrap().start =
460            Instant::now() - std::time::Duration::from_millis(500);
461        anim.tick_overlay_anim();
462        assert!(anim.overlay_anim.is_none());
463    }
464
465    #[test]
466    fn tick_overlay_close_clears_buffer() {
467        let mut app = make_app();
468        let mut anim = AnimationState::new();
469        app.screen = Screen::Help {
470            return_screen: Box::new(Screen::HostList),
471        };
472        anim.detect_transitions(&mut app);
473        anim.overlay_close = Some(OverlayCloseState {
474            buffer: Buffer::empty(Rect::new(0, 0, 80, 24)),
475            dimmed: true,
476        });
477
478        // Close
479        app.screen = Screen::HostList;
480        anim.detect_transitions(&mut app);
481        // Fast-forward close
482        anim.overlay_anim.as_mut().unwrap().start =
483            Instant::now() - std::time::Duration::from_millis(500);
484        anim.tick_overlay_anim();
485        assert!(anim.overlay_anim.is_none());
486        assert!(anim.overlay_close.is_none());
487    }
488
489    #[test]
490    fn detect_transitions_stable_hostlist_no_anim() {
491        let mut app = make_app();
492        let mut anim = AnimationState::new();
493        anim.detect_transitions(&mut app);
494        anim.detect_transitions(&mut app);
495        assert!(!anim.prev_was_overlay);
496        assert!(anim.overlay_anim.is_none());
497    }
498
499    #[test]
500    fn detect_transitions_welcome_sets_welcome_opened() {
501        let mut app = make_app();
502        let mut anim = AnimationState::new();
503        app.screen = Screen::Welcome {
504            has_backup: false,
505            host_count: 0,
506            known_hosts_count: 0,
507        };
508        anim.detect_transitions(&mut app);
509        assert!(app.ui.welcome_opened().is_some());
510        assert_eq!(
511            anim.overlay_anim.as_ref().unwrap().duration_ms,
512            WELCOME_ANIM_DURATION_MS
513        );
514    }
515
516    #[test]
517    fn detect_transitions_welcome_close_clears_welcome_opened() {
518        let mut app = make_app();
519        let mut anim = AnimationState::new();
520        app.screen = Screen::Welcome {
521            has_backup: false,
522            host_count: 0,
523            known_hosts_count: 0,
524        };
525        anim.detect_transitions(&mut app);
526        app.screen = Screen::HostList;
527        anim.detect_transitions(&mut app);
528        assert!(app.ui.welcome_opened().is_none());
529    }
530
531    #[test]
532    fn close_non_welcome_overlay_clears_welcome_opened() {
533        let mut app = make_app();
534        let mut anim = AnimationState::new();
535        app.ui.set_welcome_opened(Some(Instant::now()));
536        app.screen = Screen::Help {
537            return_screen: Box::new(Screen::HostList),
538        };
539        anim.detect_transitions(&mut app);
540        app.screen = Screen::HostList;
541        anim.detect_transitions(&mut app);
542        assert!(app.ui.welcome_opened().is_none());
543    }
544
545    // --- detail animation tests ---
546
547    #[test]
548    fn detail_toggle_open_starts_anim() {
549        let mut app = make_app();
550        let mut anim = AnimationState::new();
551        app.ui.set_detail_toggle_pending(true);
552        app.hosts_state
553            .set_view_mode(crate::app::ViewMode::Detailed);
554        anim.detect_transitions(&mut app);
555        assert!(!app.ui.detail_toggle_pending());
556        assert!(anim.detail_anim.is_some());
557    }
558
559    #[test]
560    fn detail_toggle_close_starts_anim() {
561        let mut app = make_app();
562        let mut anim = AnimationState::new();
563        app.ui.set_detail_toggle_pending(true);
564        app.hosts_state.set_view_mode(crate::app::ViewMode::Compact);
565        anim.detect_transitions(&mut app);
566        assert!(anim.detail_anim.is_some());
567    }
568
569    #[test]
570    fn detail_anim_progress_returns_value() {
571        let mut app = make_app();
572        let mut anim = AnimationState::new();
573        app.ui.set_detail_toggle_pending(true);
574        app.hosts_state
575            .set_view_mode(crate::app::ViewMode::Detailed);
576        anim.detect_transitions(&mut app);
577        let p = anim.detail_anim_progress();
578        assert!(p.is_some());
579        assert!((0.0..=1.0).contains(&p.unwrap()));
580    }
581
582    #[test]
583    fn detail_anim_progress_none_when_no_anim() {
584        let mut anim = AnimationState::new();
585        assert!(anim.detail_anim_progress().is_none());
586    }
587
588    #[test]
589    fn detail_anim_completes_and_clears() {
590        let mut app = make_app();
591        let mut anim = AnimationState::new();
592        app.ui.set_detail_toggle_pending(true);
593        app.hosts_state
594            .set_view_mode(crate::app::ViewMode::Detailed);
595        anim.detect_transitions(&mut app);
596        anim.detail_anim.as_mut().unwrap().start =
597            Instant::now() - std::time::Duration::from_millis(300);
598        assert!(anim.detail_anim_progress().is_none());
599        assert!(anim.detail_anim.is_none());
600    }
601
602    #[test]
603    fn detail_anim_reversal_mid_flight() {
604        let mut app = make_app();
605        let mut anim = AnimationState::new();
606        app.ui.set_detail_toggle_pending(true);
607        app.hosts_state
608            .set_view_mode(crate::app::ViewMode::Detailed);
609        anim.detect_transitions(&mut app);
610        let _ = anim.detail_anim_progress();
611
612        app.ui.set_detail_toggle_pending(true);
613        app.hosts_state.set_view_mode(crate::app::ViewMode::Compact);
614        anim.detect_transitions(&mut app);
615        assert!(anim.detail_anim.is_some());
616        assert!(!anim.detail_anim.as_ref().unwrap().opening);
617    }
618
619    #[test]
620    fn detail_anim_independent_of_overlay() {
621        let mut app = make_app();
622        let mut anim = AnimationState::new();
623        app.ui.set_detail_toggle_pending(true);
624        app.hosts_state
625            .set_view_mode(crate::app::ViewMode::Detailed);
626        app.screen = Screen::Help {
627            return_screen: Box::new(Screen::HostList),
628        };
629        anim.detect_transitions(&mut app);
630        assert!(anim.detail_anim.is_some());
631        assert!(anim.overlay_anim.is_some());
632    }
633
634    #[test]
635    fn overlay_close_state_initially_none() {
636        let anim = AnimationState::new();
637        assert!(anim.overlay_close.is_none());
638    }
639}