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
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
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.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.welcome_opened = None;
235 }
236
237 if app.ui.detail_toggle_pending {
242 app.ui.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 _ => app.hosts_state.view_mode == crate::app::ViewMode::Detailed,
248 };
249 let start_progress =
250 self.detail_anim_progress()
251 .unwrap_or(if opening { 0.0 } else { 1.0 });
252 self.detail_anim = Some(DetailAnim {
253 start: Instant::now(),
254 opening,
255 start_progress,
256 });
257 }
258
259 self.prev_was_overlay = is_overlay;
260 }
261}
262
263impl Default for AnimationState {
264 fn default() -> Self {
265 Self::new()
266 }
267}
268
269#[cfg(test)]
270mod tests {
271 use ratatui::layout::Rect;
272
273 use super::*;
274
275 fn make_app() -> App {
276 use std::path::PathBuf;
277 let config = crate::ssh_config::model::SshConfigFile {
278 elements: crate::ssh_config::model::SshConfigFile::parse_content(""),
279 path: PathBuf::from("/tmp/test_config"),
280 crlf: false,
281 bom: false,
282 };
283 App::new(config)
284 }
285
286 #[test]
289 fn spinner_frames_are_10() {
290 assert_eq!(SPINNER_FRAMES.len(), 10);
291 }
292
293 #[test]
294 fn spinner_frames_cycle_via_index() {
295 assert_eq!(SPINNER_FRAMES[0], "\u{280B}");
296 assert_eq!(SPINNER_FRAMES[1], "\u{2819}");
297 assert_eq!(SPINNER_FRAMES[10 % SPINNER_FRAMES.len()], "\u{280B}");
298 }
299
300 #[test]
301 fn spinner_frames_at_u64_max() {
302 let idx = (u64::MAX as usize) % SPINNER_FRAMES.len();
303 assert_eq!(SPINNER_FRAMES[idx], "\u{2834}");
304 }
305
306 #[test]
307 fn spinner_tick_wraps() {
308 let mut anim = AnimationState::new();
309 anim.spinner_tick = u64::MAX;
310 anim.tick_spinner();
311 assert_eq!(anim.spinner_tick, 0);
312 }
313
314 #[test]
315 fn spinner_tick_increments_by_one() {
316 let mut anim = AnimationState::new();
317 assert_eq!(anim.spinner_tick, 0);
318 anim.tick_spinner();
319 assert_eq!(anim.spinner_tick, 1);
320 }
321
322 #[test]
325 fn new_state_not_animating() {
326 let app = make_app();
327 let anim = AnimationState::new();
328 assert!(!anim.is_animating(&app));
329 }
330
331 #[test]
332 fn is_animating_with_overlay_anim() {
333 let mut app = make_app();
334 let mut anim = AnimationState::new();
335 app.screen = Screen::Help {
336 return_screen: Box::new(Screen::HostList),
337 };
338 anim.detect_transitions(&mut app);
339 assert!(anim.is_animating(&app));
340 }
341
342 #[test]
343 fn is_animating_with_detail_anim() {
344 let mut app = make_app();
345 let mut anim = AnimationState::new();
346 app.ui.detail_toggle_pending = true;
347 app.hosts_state.view_mode = crate::app::ViewMode::Detailed;
348 anim.detect_transitions(&mut app);
349 assert!(anim.is_animating(&app));
350 }
351
352 #[test]
355 fn has_checking_hosts_empty() {
356 let app = make_app();
357 let anim = AnimationState::new();
358 assert!(!anim.has_checking_hosts(&app));
359 }
360
361 #[test]
362 fn has_checking_hosts_only_reachable() {
363 let mut app = make_app();
364 app.ping
365 .status
366 .insert("host1".to_string(), PingStatus::Reachable { rtt_ms: 10 });
367 app.ping
368 .status
369 .insert("host2".to_string(), PingStatus::Unreachable);
370 let anim = AnimationState::new();
371 assert!(!anim.has_checking_hosts(&app));
372 }
373
374 #[test]
375 fn has_checking_hosts_with_checking() {
376 let mut app = make_app();
377 app.ping
378 .status
379 .insert("host2".to_string(), PingStatus::Checking);
380 let anim = AnimationState::new();
381 assert!(anim.has_checking_hosts(&app));
382 }
383
384 #[test]
387 fn detect_transitions_opens_overlay() {
388 let mut app = make_app();
389 let mut anim = AnimationState::new();
390 app.screen = Screen::Help {
391 return_screen: Box::new(Screen::HostList),
392 };
393 anim.detect_transitions(&mut app);
394 assert!(anim.prev_was_overlay);
395 assert!(anim.overlay_anim.is_some());
396 assert!(anim.overlay_anim.as_ref().unwrap().opening);
397 }
398
399 #[test]
400 fn detect_transitions_closes_overlay() {
401 let mut app = make_app();
402 let mut anim = AnimationState::new();
403 app.screen = Screen::Help {
404 return_screen: Box::new(Screen::HostList),
405 };
406 anim.detect_transitions(&mut app);
407 anim.overlay_close = Some(OverlayCloseState {
409 buffer: Buffer::empty(Rect::new(0, 0, 80, 24)),
410 dimmed: true,
411 });
412
413 app.screen = Screen::HostList;
414 anim.detect_transitions(&mut app);
415 assert!(!anim.prev_was_overlay);
416 assert!(anim.overlay_anim.is_some());
417 assert!(!anim.overlay_anim.as_ref().unwrap().opening);
418 }
419
420 #[test]
421 fn overlay_close_without_buffer_skips_anim() {
422 let mut app = make_app();
423 let mut anim = AnimationState::new();
424 app.screen = Screen::Help {
425 return_screen: Box::new(Screen::HostList),
426 };
427 anim.detect_transitions(&mut app);
428 app.screen = Screen::HostList;
431 anim.detect_transitions(&mut app);
432 assert!(anim.overlay_anim.is_none() || anim.overlay_anim.as_ref().unwrap().opening);
434 }
435
436 #[test]
437 fn overlay_anim_progress_returns_value() {
438 let mut app = make_app();
439 let mut anim = AnimationState::new();
440 app.screen = Screen::Help {
441 return_screen: Box::new(Screen::HostList),
442 };
443 anim.detect_transitions(&mut app);
444 let progress = anim.overlay_anim_progress();
445 assert!(progress.is_some());
446 assert!((0.0..=1.0).contains(&progress.unwrap()));
447 }
448
449 #[test]
450 fn tick_overlay_anim_clears_on_completion() {
451 let mut app = make_app();
452 let mut anim = AnimationState::new();
453 app.screen = Screen::Help {
454 return_screen: Box::new(Screen::HostList),
455 };
456 anim.detect_transitions(&mut app);
457 anim.overlay_anim.as_mut().unwrap().start =
459 Instant::now() - std::time::Duration::from_millis(500);
460 anim.tick_overlay_anim();
461 assert!(anim.overlay_anim.is_none());
462 }
463
464 #[test]
465 fn tick_overlay_close_clears_buffer() {
466 let mut app = make_app();
467 let mut anim = AnimationState::new();
468 app.screen = Screen::Help {
469 return_screen: Box::new(Screen::HostList),
470 };
471 anim.detect_transitions(&mut app);
472 anim.overlay_close = Some(OverlayCloseState {
473 buffer: Buffer::empty(Rect::new(0, 0, 80, 24)),
474 dimmed: true,
475 });
476
477 app.screen = Screen::HostList;
479 anim.detect_transitions(&mut app);
480 anim.overlay_anim.as_mut().unwrap().start =
482 Instant::now() - std::time::Duration::from_millis(500);
483 anim.tick_overlay_anim();
484 assert!(anim.overlay_anim.is_none());
485 assert!(anim.overlay_close.is_none());
486 }
487
488 #[test]
489 fn detect_transitions_stable_hostlist_no_anim() {
490 let mut app = make_app();
491 let mut anim = AnimationState::new();
492 anim.detect_transitions(&mut app);
493 anim.detect_transitions(&mut app);
494 assert!(!anim.prev_was_overlay);
495 assert!(anim.overlay_anim.is_none());
496 }
497
498 #[test]
499 fn detect_transitions_welcome_sets_welcome_opened() {
500 let mut app = make_app();
501 let mut anim = AnimationState::new();
502 app.screen = Screen::Welcome {
503 has_backup: false,
504 host_count: 0,
505 known_hosts_count: 0,
506 };
507 anim.detect_transitions(&mut app);
508 assert!(app.ui.welcome_opened.is_some());
509 assert_eq!(
510 anim.overlay_anim.as_ref().unwrap().duration_ms,
511 WELCOME_ANIM_DURATION_MS
512 );
513 }
514
515 #[test]
516 fn detect_transitions_welcome_close_clears_welcome_opened() {
517 let mut app = make_app();
518 let mut anim = AnimationState::new();
519 app.screen = Screen::Welcome {
520 has_backup: false,
521 host_count: 0,
522 known_hosts_count: 0,
523 };
524 anim.detect_transitions(&mut app);
525 app.screen = Screen::HostList;
526 anim.detect_transitions(&mut app);
527 assert!(app.ui.welcome_opened.is_none());
528 }
529
530 #[test]
531 fn close_non_welcome_overlay_clears_welcome_opened() {
532 let mut app = make_app();
533 let mut anim = AnimationState::new();
534 app.ui.welcome_opened = Some(Instant::now());
535 app.screen = Screen::Help {
536 return_screen: Box::new(Screen::HostList),
537 };
538 anim.detect_transitions(&mut app);
539 app.screen = Screen::HostList;
540 anim.detect_transitions(&mut app);
541 assert!(app.ui.welcome_opened.is_none());
542 }
543
544 #[test]
547 fn detail_toggle_open_starts_anim() {
548 let mut app = make_app();
549 let mut anim = AnimationState::new();
550 app.ui.detail_toggle_pending = true;
551 app.hosts_state.view_mode = crate::app::ViewMode::Detailed;
552 anim.detect_transitions(&mut app);
553 assert!(!app.ui.detail_toggle_pending);
554 assert!(anim.detail_anim.is_some());
555 }
556
557 #[test]
558 fn detail_toggle_close_starts_anim() {
559 let mut app = make_app();
560 let mut anim = AnimationState::new();
561 app.ui.detail_toggle_pending = true;
562 app.hosts_state.view_mode = crate::app::ViewMode::Compact;
563 anim.detect_transitions(&mut app);
564 assert!(anim.detail_anim.is_some());
565 }
566
567 #[test]
568 fn detail_anim_progress_returns_value() {
569 let mut app = make_app();
570 let mut anim = AnimationState::new();
571 app.ui.detail_toggle_pending = true;
572 app.hosts_state.view_mode = crate::app::ViewMode::Detailed;
573 anim.detect_transitions(&mut app);
574 let p = anim.detail_anim_progress();
575 assert!(p.is_some());
576 assert!((0.0..=1.0).contains(&p.unwrap()));
577 }
578
579 #[test]
580 fn detail_anim_progress_none_when_no_anim() {
581 let mut anim = AnimationState::new();
582 assert!(anim.detail_anim_progress().is_none());
583 }
584
585 #[test]
586 fn detail_anim_completes_and_clears() {
587 let mut app = make_app();
588 let mut anim = AnimationState::new();
589 app.ui.detail_toggle_pending = true;
590 app.hosts_state.view_mode = crate::app::ViewMode::Detailed;
591 anim.detect_transitions(&mut app);
592 anim.detail_anim.as_mut().unwrap().start =
593 Instant::now() - std::time::Duration::from_millis(300);
594 assert!(anim.detail_anim_progress().is_none());
595 assert!(anim.detail_anim.is_none());
596 }
597
598 #[test]
599 fn detail_anim_reversal_mid_flight() {
600 let mut app = make_app();
601 let mut anim = AnimationState::new();
602 app.ui.detail_toggle_pending = true;
603 app.hosts_state.view_mode = crate::app::ViewMode::Detailed;
604 anim.detect_transitions(&mut app);
605 let _ = anim.detail_anim_progress();
606
607 app.ui.detail_toggle_pending = true;
608 app.hosts_state.view_mode = crate::app::ViewMode::Compact;
609 anim.detect_transitions(&mut app);
610 assert!(anim.detail_anim.is_some());
611 assert!(!anim.detail_anim.as_ref().unwrap().opening);
612 }
613
614 #[test]
615 fn detail_anim_independent_of_overlay() {
616 let mut app = make_app();
617 let mut anim = AnimationState::new();
618 app.ui.detail_toggle_pending = true;
619 app.hosts_state.view_mode = crate::app::ViewMode::Detailed;
620 app.screen = Screen::Help {
621 return_screen: Box::new(Screen::HostList),
622 };
623 anim.detect_transitions(&mut app);
624 assert!(anim.detail_anim.is_some());
625 assert!(anim.overlay_anim.is_some());
626 }
627
628 #[test]
629 fn overlay_close_state_initially_none() {
630 let anim = AnimationState::new();
631 assert!(anim.overlay_close.is_none());
632 }
633}