1use std::time::Instant;
2
3use ratatui::buffer::Buffer;
4
5use crate::app::{App, PingStatus, Screen};
6
7pub const SPINNER_FRAMES: &[&str] = &[
9 "\u{280B}", "\u{2819}", "\u{2839}", "\u{2838}", "\u{283C}", "\u{2834}", "\u{2826}", "\u{2827}", "\u{2807}", "\u{280F}", ];
20
21const DETAIL_ANIM_DURATION_MS: u128 = 200;
23
24const OVERLAY_ANIM_DURATION_MS: u128 = 250;
26
27const WELCOME_ANIM_DURATION_MS: u128 = 350;
29
30pub(crate) struct DetailAnim {
32 start: Instant,
33 opening: bool,
34 start_progress: f32,
35}
36
37pub(crate) struct OverlayAnim {
39 pub(crate) start: Instant,
40 pub(crate) opening: bool,
41 pub(crate) duration_ms: u128,
42}
43
44pub(crate) struct OverlayCloseState {
47 pub(crate) buffer: Buffer,
48 pub(crate) dimmed: bool,
49}
50
51pub 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 pub(crate) overlay_close: Option<OverlayCloseState>,
59 pub(crate) tunnel_panel_anim: Option<DetailAnim>,
65 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 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 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 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 pub fn tick_spinner(&mut self) {
119 self.spinner_tick = self.spinner_tick.wrapping_add(1);
120 }
121
122 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 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 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 pub fn note_tunnel_panel_target(&mut self, visible: bool) {
168 match self.prev_tunnel_panel_visible {
169 None => {
170 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 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 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 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 #[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 #[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 #[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 #[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 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 app.screen = Screen::HostList;
432 anim.detect_transitions(&mut app);
433 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 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 app.screen = Screen::HostList;
480 anim.detect_transitions(&mut app);
481 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 #[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}