1use ratatui::buffer::{Buffer, Cell};
12use ratatui::layout::Rect;
13use ratatui::style::Color;
14use std::time::{Duration, Instant};
15
16#[derive(Clone, Copy, Debug, PartialEq, Eq)]
17pub enum EffectStatus {
18 Running,
19 Done,
20}
21
22#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23pub enum Edge {
24 Top,
25 Bottom,
26 Left,
27 Right,
28}
29
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31pub enum AnimationKind {
32 SlideIn {
33 from: Edge,
34 duration: Duration,
35 delay: Duration,
36 },
37 CursorJump {
43 from: (u16, u16),
44 to: (u16, u16),
45 duration: Duration,
46 cursor_color: Color,
47 bg_color: Color,
48 },
49}
50
51#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
52pub struct AnimationId(u64);
53
54impl AnimationId {
55 pub fn raw(self) -> u64 {
56 self.0
57 }
58 pub fn from_raw(v: u64) -> Self {
59 Self(v)
60 }
61}
62
63pub trait FrameEffect {
64 fn capture_before(&mut self, _buf: &Buffer, _area: Rect) {}
70
71 fn apply(&mut self, buf: &mut Buffer, area: Rect, elapsed: Duration) -> EffectStatus;
72}
73
74fn rect_contains(outer: Rect, inner: Rect) -> bool {
76 inner.x >= outer.x
77 && inner.y >= outer.y
78 && inner.x.saturating_add(inner.width) <= outer.x.saturating_add(outer.width)
79 && inner.y.saturating_add(inner.height) <= outer.y.saturating_add(outer.height)
80}
81
82fn ease_out_cubic(t: f32) -> f32 {
84 let t = t.clamp(0.0, 1.0);
85 let inv = 1.0 - t;
86 1.0 - inv * inv * inv
87}
88
89pub struct SlideIn {
96 from: Edge,
97 duration: Duration,
98 after: Option<SlideSnapshot>,
99 before: Option<SlideSnapshot>,
100}
101
102struct SlideSnapshot {
103 area: Rect,
104 cells: Vec<Cell>,
105}
106
107impl SlideIn {
108 pub fn new(from: Edge, duration: Duration) -> Self {
109 Self {
110 from,
111 duration,
112 after: None,
113 before: None,
114 }
115 }
116
117 fn snapshot_area(buf: &Buffer, area: Rect) -> SlideSnapshot {
118 let mut cells = Vec::with_capacity(area.width as usize * area.height as usize);
119 for dy in 0..area.height {
120 for dx in 0..area.width {
121 let x = area.x + dx;
122 let y = area.y + dy;
123 let cell = buf.cell((x, y)).cloned().unwrap_or_default();
124 cells.push(cell);
125 }
126 }
127 SlideSnapshot { area, cells }
128 }
129}
130
131impl FrameEffect for SlideIn {
132 fn capture_before(&mut self, buf: &Buffer, area: Rect) {
133 if self.before.is_none() {
134 self.before = Some(Self::snapshot_area(buf, area));
135 }
136 }
137
138 fn apply(&mut self, buf: &mut Buffer, area: Rect, elapsed: Duration) -> EffectStatus {
139 if self.after.is_none() {
143 self.after = Some(Self::snapshot_area(buf, area));
144 }
145 let after = match &self.after {
146 Some(s) if s.area == area => s,
147 Some(_) => {
148 self.after = Some(Self::snapshot_area(buf, area));
152 self.before = None;
153 self.after.as_ref().unwrap()
154 }
155 None => unreachable!(),
156 };
157 let before = self.before.as_ref().filter(|b| b.area == area);
158
159 let t = if self.duration.is_zero() {
160 1.0
161 } else {
162 (elapsed.as_secs_f32() / self.duration.as_secs_f32()).clamp(0.0, 1.0)
163 };
164 let eased = ease_out_cubic(t);
165
166 let (offset_row, offset_col) = match self.from {
171 Edge::Bottom => (((1.0 - eased) * area.height as f32).round() as i32, 0i32),
172 Edge::Top => (-(((1.0 - eased) * area.height as f32).round() as i32), 0),
173 Edge::Right => (0, ((1.0 - eased) * area.width as f32).round() as i32),
174 Edge::Left => (0, -(((1.0 - eased) * area.width as f32).round() as i32)),
175 };
176
177 let (before_offset_row, before_offset_col) = match self.from {
181 Edge::Bottom => (offset_row - area.height as i32, 0),
182 Edge::Top => (offset_row + area.height as i32, 0),
183 Edge::Right => (0, offset_col - area.width as i32),
184 Edge::Left => (0, offset_col + area.width as i32),
185 };
186
187 let blank = Cell::default();
188 for dy in 0..area.height {
189 for dx in 0..area.width {
190 let x = area.x + dx;
191 let y = area.y + dy;
192
193 let after_src_dy = dy as i32 - offset_row;
197 let after_src_dx = dx as i32 - offset_col;
198 let after_cell = if after_src_dy >= 0
199 && after_src_dy < area.height as i32
200 && after_src_dx >= 0
201 && after_src_dx < area.width as i32
202 {
203 let idx = after_src_dy as usize * area.width as usize + after_src_dx as usize;
204 Some(after.cells[idx].clone())
205 } else {
206 None
207 };
208
209 let before_cell = if let Some(before) = before {
210 let before_src_dy = dy as i32 - before_offset_row;
211 let before_src_dx = dx as i32 - before_offset_col;
212 if before_src_dy >= 0
213 && before_src_dy < area.height as i32
214 && before_src_dx >= 0
215 && before_src_dx < area.width as i32
216 {
217 let idx =
218 before_src_dy as usize * area.width as usize + before_src_dx as usize;
219 Some(before.cells[idx].clone())
220 } else {
221 None
222 }
223 } else {
224 None
225 };
226
227 let new_cell = after_cell.or(before_cell).unwrap_or_else(|| blank.clone());
228 if let Some(dst) = buf.cell_mut((x, y)) {
229 *dst = new_cell;
230 }
231 }
232 }
233
234 if t >= 1.0 {
235 EffectStatus::Done
236 } else {
237 EffectStatus::Running
238 }
239 }
240}
241
242pub struct CursorJump {
252 from: (i32, i32),
253 to: (i32, i32),
254 duration: Duration,
255 cursor_rgb: (u8, u8, u8),
256 bg_rgb: (u8, u8, u8),
257}
258
259impl CursorJump {
260 pub fn new(
261 from: (u16, u16),
262 to: (u16, u16),
263 duration: Duration,
264 cursor_color: Color,
265 bg_color: Color,
266 ) -> Self {
267 let cursor_rgb = color_to_rgb(cursor_color).unwrap_or((255, 255, 255));
271 let bg_rgb = color_to_rgb(bg_color).unwrap_or((0, 0, 0));
272 Self {
273 from: (from.0 as i32, from.1 as i32),
274 to: (to.0 as i32, to.1 as i32),
275 duration,
276 cursor_rgb,
277 bg_rgb,
278 }
279 }
280
281 fn paint_cell(buf: &mut Buffer, col: i32, row: i32, bg: Color) {
282 if col < 0 || row < 0 {
283 return;
284 }
285 let buf_area = buf.area;
286 let c = col as u16;
287 let r = row as u16;
288 if c < buf_area.x
289 || c >= buf_area.x.saturating_add(buf_area.width)
290 || r < buf_area.y
291 || r >= buf_area.y.saturating_add(buf_area.height)
292 {
293 return;
294 }
295 if let Some(cell) = buf.cell_mut((c, r)) {
296 cell.set_bg(bg);
297 }
298 }
299}
300
301impl FrameEffect for CursorJump {
302 fn apply(&mut self, buf: &mut Buffer, _area: Rect, elapsed: Duration) -> EffectStatus {
303 let t = if self.duration.is_zero() {
304 1.0
305 } else {
306 (elapsed.as_secs_f32() / self.duration.as_secs_f32()).clamp(0.0, 1.0)
307 };
308
309 if t >= 1.0 {
317 return EffectStatus::Done;
318 }
319
320 let eased = ease_out_cubic(t);
321
322 let (fx, fy) = (self.from.0 as f32, self.from.1 as f32);
323 let (tx, ty) = (self.to.0 as f32, self.to.1 as f32);
324 let dx = tx - fx;
325 let dy = ty - fy;
326
327 let path_cells = dx.abs().max(dy.abs()).round() as i32;
330 let trail_len = (path_cells.min(8).max(2)) as usize;
331
332 for i in 0..trail_len {
333 let back = (i as f32) / (trail_len as f32);
337 let sample = (eased - back * 0.12).max(0.0);
338 let col = (fx + dx * sample).round() as i32;
339 let row = (fy + dy * sample).round() as i32;
340 let alpha = 1.0 - back;
341 let blended = blend_rgb(self.cursor_rgb, self.bg_rgb, alpha);
342 Self::paint_cell(buf, col, row, blended);
343 }
344
345 EffectStatus::Running
346 }
347}
348
349fn blend_rgb(fg: (u8, u8, u8), bg: (u8, u8, u8), alpha: f32) -> Color {
350 let a = alpha.clamp(0.0, 1.0);
351 let mix = |f: u8, b: u8| -> u8 {
352 ((f as f32) * a + (b as f32) * (1.0 - a))
353 .round()
354 .clamp(0.0, 255.0) as u8
355 };
356 Color::Rgb(mix(fg.0, bg.0), mix(fg.1, bg.1), mix(fg.2, bg.2))
357}
358
359fn color_to_rgb(color: Color) -> Option<(u8, u8, u8)> {
360 match color {
361 Color::Rgb(r, g, b) => Some((r, g, b)),
362 Color::Black => Some((0, 0, 0)),
363 Color::Red => Some((205, 0, 0)),
364 Color::Green => Some((0, 205, 0)),
365 Color::Yellow => Some((205, 205, 0)),
366 Color::Blue => Some((0, 0, 238)),
367 Color::Magenta => Some((205, 0, 205)),
368 Color::Cyan => Some((0, 205, 205)),
369 Color::Gray => Some((229, 229, 229)),
370 Color::DarkGray => Some((127, 127, 127)),
371 Color::LightRed => Some((255, 0, 0)),
372 Color::LightGreen => Some((0, 255, 0)),
373 Color::LightYellow => Some((255, 255, 0)),
374 Color::LightBlue => Some((92, 92, 255)),
375 Color::LightMagenta => Some((255, 0, 255)),
376 Color::LightCyan => Some((0, 255, 255)),
377 Color::White => Some((255, 255, 255)),
378 Color::Indexed(_) => None,
382 Color::Reset => None,
383 }
384}
385
386struct ActiveEffect {
387 id: AnimationId,
388 area: Rect,
389 started: Instant,
390 delay: Duration,
391 effect: Box<dyn FrameEffect + Send>,
392 status: EffectStatus,
393 deadline: Instant,
394}
395
396pub struct AnimationRunner {
397 next_id: u64,
398 active: Vec<ActiveEffect>,
399 total_started: u64,
406 last_frame: Option<Buffer>,
412}
413
414impl Default for AnimationRunner {
415 fn default() -> Self {
416 Self::new()
417 }
418}
419
420impl AnimationRunner {
421 pub fn new() -> Self {
422 Self {
423 next_id: 1,
424 active: Vec::new(),
425 total_started: 0,
426 last_frame: None,
427 }
428 }
429
430 pub fn start(&mut self, area: Rect, kind: AnimationKind) -> AnimationId {
431 let id = AnimationId(self.next_id);
432 self.next_id += 1;
433 self.start_with_id(id, area, kind);
434 id
435 }
436
437 pub fn start_with_id(&mut self, id: AnimationId, area: Rect, kind: AnimationKind) {
453 self.active.retain(|e| e.area != area);
454 let now = Instant::now();
455 let (effect, delay, duration): (Box<dyn FrameEffect + Send>, Duration, Duration) =
456 match kind {
457 AnimationKind::SlideIn {
458 from,
459 duration,
460 delay,
461 } => (Box::new(SlideIn::new(from, duration)), delay, duration),
462 AnimationKind::CursorJump {
463 from,
464 to,
465 duration,
466 cursor_color,
467 bg_color,
468 } => (
469 Box::new(CursorJump::new(from, to, duration, cursor_color, bg_color)),
470 Duration::ZERO,
471 duration,
472 ),
473 };
474 self.total_started += 1;
475 self.active.push(ActiveEffect {
476 id,
477 area,
478 started: now,
479 delay,
480 effect,
481 status: EffectStatus::Running,
482 deadline: now + delay + duration,
483 });
484 }
485
486 pub fn cancel(&mut self, id: AnimationId) {
487 self.active.retain(|e| e.id != id);
488 }
489
490 pub fn capture_before_all(&mut self) {
502 let now = Instant::now();
503 let Some(prev) = self.last_frame.as_ref() else {
504 return;
505 };
506 let prev_area = prev.area;
507 for e in self.active.iter_mut() {
508 if now < e.started + e.delay {
509 continue;
510 }
511 if !rect_contains(prev_area, e.area) {
512 continue;
513 }
514 e.effect.capture_before(prev, e.area);
515 }
516 }
517
518 pub fn apply_all(&mut self, buf: &mut Buffer) {
519 let now = Instant::now();
520 for e in self.active.iter_mut() {
521 let effective_start = e.started + e.delay;
522 if now < effective_start {
523 continue;
524 }
525 let elapsed = now - effective_start;
526 e.status = e.effect.apply(buf, e.area, elapsed);
527 }
528 self.active.retain(|e| e.status == EffectStatus::Running);
529
530 self.last_frame = Some(buf.clone());
534 }
535
536 pub fn is_active(&self) -> bool {
537 self.active
538 .iter()
539 .any(|e| e.status == EffectStatus::Running)
540 }
541
542 pub fn total_started(&self) -> u64 {
548 self.total_started
549 }
550
551 pub fn next_deadline(&self) -> Option<Instant> {
552 self.active.iter().map(|e| e.deadline).min()
553 }
554
555 pub fn is_animating_at(&self, col: u16, row: u16) -> bool {
558 self.active.iter().any(|e| {
559 e.status == EffectStatus::Running
560 && col >= e.area.x
561 && col < e.area.x.saturating_add(e.area.width)
562 && row >= e.area.y
563 && row < e.area.y.saturating_add(e.area.height)
564 })
565 }
566}
567
568#[cfg(test)]
569mod tests {
570 use super::*;
571 use ratatui::style::Color;
572
573 fn make_buf(w: u16, h: u16) -> Buffer {
574 Buffer::empty(Rect::new(0, 0, w, h))
575 }
576
577 fn paint(buf: &mut Buffer, area: Rect, ch: char, fg: Color) {
578 for dy in 0..area.height {
579 for dx in 0..area.width {
580 if let Some(cell) = buf.cell_mut((area.x + dx, area.y + dy)) {
581 cell.set_symbol(&ch.to_string());
582 cell.set_fg(fg);
583 }
584 }
585 }
586 }
587
588 #[test]
589 fn slide_in_bottom_at_t0_pushes_content_out() {
590 let area = Rect::new(0, 0, 4, 3);
591 let mut buf = make_buf(4, 3);
592 paint(&mut buf, area, 'X', Color::Red);
593
594 let mut runner = AnimationRunner::new();
595 runner.start(
596 area,
597 AnimationKind::SlideIn {
598 from: Edge::Bottom,
599 duration: Duration::from_millis(500),
600 delay: Duration::ZERO,
601 },
602 );
603 runner.apply_all(&mut buf);
606 for dy in 0..area.height {
607 for dx in 0..area.width {
608 let cell = buf.cell((area.x + dx, area.y + dy)).unwrap();
609 assert_eq!(cell.symbol(), " ", "blank at ({}, {}) at t=0", dx, dy);
610 }
611 }
612 }
613
614 #[test]
615 fn slide_in_bottom_at_duration_matches_snapshot() {
616 let area = Rect::new(0, 0, 4, 3);
617 let mut buf = make_buf(4, 3);
618 paint(&mut buf, area, 'X', Color::Red);
619
620 let mut effect = SlideIn::new(Edge::Bottom, Duration::from_millis(100));
622 effect.apply(&mut buf, area, Duration::ZERO);
624 let status = effect.apply(&mut buf, area, Duration::from_millis(100));
626 assert_eq!(status, EffectStatus::Done);
627 for dy in 0..area.height {
628 for dx in 0..area.width {
629 let cell = buf.cell((area.x + dx, area.y + dy)).unwrap();
630 assert_eq!(cell.symbol(), "X");
631 assert_eq!(cell.fg, Color::Red);
632 }
633 }
634 }
635
636 #[test]
637 fn slide_in_with_before_snapshot_pushes_old_out() {
638 let area = Rect::new(0, 0, 3, 4);
640 let mut before_buf = make_buf(3, 4);
641 paint(&mut before_buf, area, 'O', Color::Green);
642 let mut after_buf = make_buf(3, 4);
643 paint(&mut after_buf, area, 'N', Color::Blue);
644
645 let mut effect = SlideIn::new(Edge::Bottom, Duration::from_millis(100));
646 effect.capture_before(&before_buf, area);
647 let mut work = after_buf.clone();
652 effect.apply(&mut work, area, Duration::from_millis(50));
653 for dy in 0..area.height {
654 for dx in 0..area.width {
655 let cell = work.cell((area.x + dx, area.y + dy)).unwrap();
656 let sym = cell.symbol();
657 assert!(
658 sym == "N" || sym == "O",
659 "push should paint only OLD or NEW cells, got {:?} at ({},{})",
660 sym,
661 dx,
662 dy
663 );
664 }
665 }
666 let status = effect.apply(&mut work, area, Duration::from_millis(100));
668 assert_eq!(status, EffectStatus::Done);
669 for dy in 0..area.height {
670 for dx in 0..area.width {
671 let cell = work.cell((area.x + dx, area.y + dy)).unwrap();
672 assert_eq!(cell.symbol(), "N");
673 }
674 }
675 }
676
677 #[test]
678 fn runner_caches_last_frame_for_push_transition() {
679 let area = Rect::new(0, 0, 3, 3);
687 let mut runner = AnimationRunner::new();
688
689 let mut frame1 = make_buf(3, 3);
692 paint(&mut frame1, area, 'O', Color::Green);
693 runner.apply_all(&mut frame1);
694 assert!(runner.last_frame.is_some());
695
696 let id = runner.start(
700 area,
701 AnimationKind::SlideIn {
702 from: Edge::Bottom,
703 duration: Duration::from_millis(100),
704 delay: Duration::ZERO,
705 },
706 );
707 runner.capture_before_all();
708 let mut frame2 = make_buf(3, 3); paint(&mut frame2, area, 'N', Color::Blue);
710 runner.apply_all(&mut frame2);
711
712 let mut seen_old = false;
715 for dy in 0..area.height {
716 for dx in 0..area.width {
717 let cell = frame2.cell((area.x + dx, area.y + dy)).unwrap();
718 if cell.symbol() == "O" {
719 seen_old = true;
720 }
721 assert!(
722 cell.symbol() == "O" || cell.symbol() == "N",
723 "push should paint only OLD or NEW, got {:?}",
724 cell.symbol()
725 );
726 }
727 }
728 assert!(
729 seen_old,
730 "at least one OLD cell should still be visible mid-transition"
731 );
732 let _ = id;
733 }
734
735 #[test]
736 fn runner_is_active_flips_after_duration() {
737 let area = Rect::new(0, 0, 2, 2);
738 let mut buf = make_buf(2, 2);
739 let mut runner = AnimationRunner::new();
740 runner.start(
741 area,
742 AnimationKind::SlideIn {
743 from: Edge::Bottom,
744 duration: Duration::from_millis(10),
745 delay: Duration::ZERO,
746 },
747 );
748 assert!(runner.is_active());
749 runner.apply_all(&mut buf);
750 assert!(runner.is_active(), "still running immediately after start");
751 std::thread::sleep(Duration::from_millis(25));
752 runner.apply_all(&mut buf);
753 assert!(
754 !runner.is_active(),
755 "runner should have no active effects after duration elapses"
756 );
757 }
758
759 #[test]
760 fn cancel_removes_effect_and_leaves_buffer_unchanged() {
761 let area = Rect::new(0, 0, 4, 3);
762 let mut buf = make_buf(4, 3);
763 paint(&mut buf, area, 'X', Color::Red);
764
765 let mut runner = AnimationRunner::new();
766 let id = runner.start(
767 area,
768 AnimationKind::SlideIn {
769 from: Edge::Bottom,
770 duration: Duration::from_millis(500),
771 delay: Duration::ZERO,
772 },
773 );
774 runner.cancel(id);
775 assert!(!runner.is_active());
776
777 let mut buf2 = make_buf(4, 3);
779 paint(&mut buf2, area, 'X', Color::Red);
780 runner.apply_all(&mut buf2);
781 for dy in 0..area.height {
782 for dx in 0..area.width {
783 let cell = buf2.cell((area.x + dx, area.y + dy)).unwrap();
784 assert_eq!(cell.symbol(), "X");
785 assert_eq!(cell.fg, Color::Red);
786 }
787 }
788 }
789
790 #[test]
791 fn delay_defers_application() {
792 let area = Rect::new(0, 0, 2, 2);
793 let mut buf = make_buf(2, 2);
794 paint(&mut buf, area, 'X', Color::Red);
795
796 let mut runner = AnimationRunner::new();
797 runner.start(
798 area,
799 AnimationKind::SlideIn {
800 from: Edge::Bottom,
801 duration: Duration::from_millis(10),
802 delay: Duration::from_secs(3600),
803 },
804 );
805 runner.apply_all(&mut buf);
806 for dy in 0..area.height {
808 for dx in 0..area.width {
809 let cell = buf.cell((area.x + dx, area.y + dy)).unwrap();
810 assert_eq!(cell.symbol(), "X");
811 }
812 }
813 assert!(runner.is_active());
814 }
815
816 #[test]
817 fn next_deadline_is_earliest() {
818 let area_a = Rect::new(0, 0, 2, 2);
821 let area_b = Rect::new(0, 2, 2, 2);
822 let mut runner = AnimationRunner::new();
823 runner.start(
824 area_a,
825 AnimationKind::SlideIn {
826 from: Edge::Bottom,
827 duration: Duration::from_millis(100),
828 delay: Duration::ZERO,
829 },
830 );
831 let d1 = runner.next_deadline().unwrap();
832 runner.start(
833 area_b,
834 AnimationKind::SlideIn {
835 from: Edge::Bottom,
836 duration: Duration::from_millis(1000),
837 delay: Duration::ZERO,
838 },
839 );
840 let d2 = runner.next_deadline().unwrap();
841 assert!(d2 <= d1 + Duration::from_millis(5));
842 }
843
844 #[test]
845 fn starting_effect_on_same_area_replaces_previous() {
846 let area = Rect::new(0, 0, 2, 2);
847 let mut runner = AnimationRunner::new();
848 let first = runner.start(
849 area,
850 AnimationKind::SlideIn {
851 from: Edge::Bottom,
852 duration: Duration::from_millis(500),
853 delay: Duration::ZERO,
854 },
855 );
856 assert_eq!(runner.active.len(), 1);
857 let second = runner.start(
858 area,
859 AnimationKind::SlideIn {
860 from: Edge::Top,
861 duration: Duration::from_millis(500),
862 delay: Duration::ZERO,
863 },
864 );
865 assert_eq!(runner.active.len(), 1);
867 assert_eq!(runner.active[0].id, second);
868 assert_ne!(first, second);
869 }
870
871 #[test]
872 fn cursor_jump_final_frame_is_clean() {
873 let area = Rect::new(0, 0, 6, 4);
878 let mut buf = make_buf(6, 4);
879 paint(&mut buf, area, '.', Color::White);
880 let bg_before: Vec<_> = (0..area.height)
881 .flat_map(|dy| (0..area.width).map(move |dx| (dx, dy)))
882 .map(|(dx, dy)| buf.cell((area.x + dx, area.y + dy)).unwrap().bg)
883 .collect();
884
885 let mut effect = CursorJump::new(
886 (0, 0),
887 (4, 2),
888 Duration::from_millis(100),
889 Color::Rgb(255, 200, 0),
890 Color::Rgb(20, 20, 20),
891 );
892 let status = effect.apply(&mut buf, area, Duration::from_millis(100));
893 assert_eq!(status, EffectStatus::Done);
894
895 let mut idx = 0;
896 for dy in 0..area.height {
897 for dx in 0..area.width {
898 let cell = buf.cell((area.x + dx, area.y + dy)).unwrap();
899 assert_eq!(
900 cell.bg, bg_before[idx],
901 "no cell bg should change at t>=1.0, but ({}, {}) did",
902 dx, dy
903 );
904 idx += 1;
905 }
906 }
907 }
908
909 #[test]
910 fn cursor_jump_head_uses_cursor_color() {
911 let area = Rect::new(0, 0, 12, 5);
914 let mut buf = make_buf(12, 5);
915 paint(&mut buf, area, '.', Color::White);
916
917 let cursor = Color::Rgb(255, 100, 0);
918 let bg = Color::Rgb(0, 0, 0);
919 let mut effect = CursorJump::new((0, 0), (10, 4), Duration::from_millis(100), cursor, bg);
920 let status = effect.apply(&mut buf, area, Duration::from_millis(50));
921 assert_eq!(status, EffectStatus::Running);
922
923 let mut found_full_cursor = false;
924 for dy in 0..area.height {
925 for dx in 0..area.width {
926 let cell = buf.cell((area.x + dx, area.y + dy)).unwrap();
927 if cell.bg == cursor {
928 found_full_cursor = true;
929 }
930 }
931 }
932 assert!(
933 found_full_cursor,
934 "head cell should be painted with the full cursor color"
935 );
936 }
937
938 #[test]
939 fn cursor_jump_trail_fades_toward_bg() {
940 let area = Rect::new(0, 0, 20, 5);
945 let mut buf = make_buf(20, 5);
946 paint(&mut buf, area, '.', Color::White);
947
948 let cursor = Color::Rgb(255, 0, 0);
949 let bg = Color::Rgb(0, 0, 0);
950 let mut effect = CursorJump::new((0, 0), (18, 4), Duration::from_millis(100), cursor, bg);
951 let _ = effect.apply(&mut buf, area, Duration::from_millis(70));
952
953 let mut blended_count = 0;
954 for dy in 0..area.height {
955 for dx in 0..area.width {
956 let cell = buf.cell((area.x + dx, area.y + dy)).unwrap();
957 if let Color::Rgb(r, g, b) = cell.bg {
958 if r > 0 && r < 255 && g == 0 && b == 0 {
961 blended_count += 1;
962 }
963 }
964 }
965 }
966 assert!(
967 blended_count > 0,
968 "at least one trail cell should be a blend between cursor and bg"
969 );
970 }
971
972 #[test]
973 fn cursor_jump_through_runner() {
974 let mut runner = AnimationRunner::new();
975 let area = Rect::new(0, 0, 10, 5);
976 let id = runner.start(
977 area,
978 AnimationKind::CursorJump {
979 from: (1, 1),
980 to: (8, 4),
981 duration: Duration::from_millis(50),
982 cursor_color: Color::Rgb(255, 255, 0),
983 bg_color: Color::Rgb(0, 0, 0),
984 },
985 );
986 assert!(runner.is_active());
987 let mut buf = make_buf(10, 5);
988 paint(&mut buf, area, ' ', Color::Reset);
989 runner.apply_all(&mut buf);
990 assert!(runner.is_active());
992 std::thread::sleep(Duration::from_millis(80));
993 runner.apply_all(&mut buf);
994 assert!(
995 !runner.is_active(),
996 "cursor jump should complete after duration"
997 );
998 let _ = id;
999 }
1000
1001 #[test]
1002 fn is_animating_at_covers_area() {
1003 let area = Rect::new(10, 5, 3, 2);
1004 let mut runner = AnimationRunner::new();
1005 runner.start(
1006 area,
1007 AnimationKind::SlideIn {
1008 from: Edge::Bottom,
1009 duration: Duration::from_millis(500),
1010 delay: Duration::ZERO,
1011 },
1012 );
1013 assert!(runner.is_animating_at(10, 5));
1014 assert!(runner.is_animating_at(12, 6));
1015 assert!(!runner.is_animating_at(9, 5));
1016 assert!(!runner.is_animating_at(13, 5));
1017 assert!(!runner.is_animating_at(10, 7));
1018 }
1019}