1use ratatui::buffer::{Buffer, Cell};
15use ratatui::layout::Rect;
16use ratatui::style::Color;
17use std::time::{Duration, Instant};
18
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20pub enum EffectStatus {
21 Running,
22 Done,
23}
24
25#[derive(Clone, Copy, Debug, PartialEq, Eq)]
26pub enum Edge {
27 Top,
28 Bottom,
29 Left,
30 Right,
31}
32
33#[derive(Clone, Copy, Debug, PartialEq, Eq)]
34pub enum AnimationKind {
35 SlideIn {
36 from: Edge,
37 duration: Duration,
38 delay: Duration,
39 },
40 CursorJump {
46 from: (u16, u16),
47 to: (u16, u16),
48 duration: Duration,
49 cursor_color: Color,
50 bg_color: Color,
51 },
52 ColorTransition { duration: Duration },
58 Wave { duration: Duration },
68}
69
70#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
71pub struct AnimationId(u64);
72
73impl AnimationId {
74 pub fn raw(self) -> u64 {
75 self.0
76 }
77 pub fn from_raw(v: u64) -> Self {
78 Self(v)
79 }
80}
81
82pub trait FrameEffect {
83 fn capture_before(&mut self, _buf: &Buffer, _area: Rect) {}
89
90 fn apply(&mut self, buf: &mut Buffer, area: Rect, elapsed: Duration) -> EffectStatus;
91}
92
93fn rect_contains(outer: Rect, inner: Rect) -> bool {
95 inner.x >= outer.x
96 && inner.y >= outer.y
97 && inner.x.saturating_add(inner.width) <= outer.x.saturating_add(outer.width)
98 && inner.y.saturating_add(inner.height) <= outer.y.saturating_add(outer.height)
99}
100
101fn ease_out_cubic(t: f32) -> f32 {
103 let t = t.clamp(0.0, 1.0);
104 let inv = 1.0 - t;
105 1.0 - inv * inv * inv
106}
107
108pub struct SlideIn {
115 from: Edge,
116 duration: Duration,
117 after: Option<SlideSnapshot>,
118 before: Option<SlideSnapshot>,
119}
120
121struct SlideSnapshot {
122 area: Rect,
123 cells: Vec<Cell>,
124}
125
126impl SlideIn {
127 pub fn new(from: Edge, duration: Duration) -> Self {
128 Self {
129 from,
130 duration,
131 after: None,
132 before: None,
133 }
134 }
135
136 fn snapshot_area(buf: &Buffer, area: Rect) -> SlideSnapshot {
137 let mut cells = Vec::with_capacity(area.width as usize * area.height as usize);
138 for dy in 0..area.height {
139 for dx in 0..area.width {
140 let x = area.x + dx;
141 let y = area.y + dy;
142 let cell = buf.cell((x, y)).cloned().unwrap_or_default();
143 cells.push(cell);
144 }
145 }
146 SlideSnapshot { area, cells }
147 }
148}
149
150impl FrameEffect for SlideIn {
151 fn capture_before(&mut self, buf: &Buffer, area: Rect) {
152 if self.before.is_none() {
153 self.before = Some(Self::snapshot_area(buf, area));
154 }
155 }
156
157 fn apply(&mut self, buf: &mut Buffer, area: Rect, elapsed: Duration) -> EffectStatus {
158 if self.after.is_none() {
162 self.after = Some(Self::snapshot_area(buf, area));
163 }
164 let after = match &self.after {
165 Some(s) if s.area == area => s,
166 Some(_) => {
167 self.after = Some(Self::snapshot_area(buf, area));
171 self.before = None;
172 self.after.as_ref().unwrap()
173 }
174 None => unreachable!(),
175 };
176 let before = self.before.as_ref().filter(|b| b.area == area);
177
178 let t = if self.duration.is_zero() {
179 1.0
180 } else {
181 (elapsed.as_secs_f32() / self.duration.as_secs_f32()).clamp(0.0, 1.0)
182 };
183 let eased = ease_out_cubic(t);
184
185 let (offset_row, offset_col) = match self.from {
190 Edge::Bottom => (((1.0 - eased) * area.height as f32).round() as i32, 0i32),
191 Edge::Top => (-(((1.0 - eased) * area.height as f32).round() as i32), 0),
192 Edge::Right => (0, ((1.0 - eased) * area.width as f32).round() as i32),
193 Edge::Left => (0, -(((1.0 - eased) * area.width as f32).round() as i32)),
194 };
195
196 let (before_offset_row, before_offset_col) = match self.from {
200 Edge::Bottom => (offset_row - area.height as i32, 0),
201 Edge::Top => (offset_row + area.height as i32, 0),
202 Edge::Right => (0, offset_col - area.width as i32),
203 Edge::Left => (0, offset_col + area.width as i32),
204 };
205
206 let blank = Cell::default();
207 for dy in 0..area.height {
208 for dx in 0..area.width {
209 let x = area.x + dx;
210 let y = area.y + dy;
211
212 let after_src_dy = dy as i32 - offset_row;
216 let after_src_dx = dx as i32 - offset_col;
217 let after_cell = if after_src_dy >= 0
218 && after_src_dy < area.height as i32
219 && after_src_dx >= 0
220 && after_src_dx < area.width as i32
221 {
222 let idx = after_src_dy as usize * area.width as usize + after_src_dx as usize;
223 Some(after.cells[idx].clone())
224 } else {
225 None
226 };
227
228 let before_cell = if let Some(before) = before {
229 let before_src_dy = dy as i32 - before_offset_row;
230 let before_src_dx = dx as i32 - before_offset_col;
231 if before_src_dy >= 0
232 && before_src_dy < area.height as i32
233 && before_src_dx >= 0
234 && before_src_dx < area.width as i32
235 {
236 let idx =
237 before_src_dy as usize * area.width as usize + before_src_dx as usize;
238 Some(before.cells[idx].clone())
239 } else {
240 None
241 }
242 } else {
243 None
244 };
245
246 let new_cell = after_cell.or(before_cell).unwrap_or_else(|| blank.clone());
247 if let Some(dst) = buf.cell_mut((x, y)) {
248 *dst = new_cell;
249 }
250 }
251 }
252
253 if t >= 1.0 {
254 EffectStatus::Done
255 } else {
256 EffectStatus::Running
257 }
258 }
259}
260
261pub struct CursorJump {
271 from: (i32, i32),
272 to: (i32, i32),
273 duration: Duration,
274 cursor_rgb: (u8, u8, u8),
275 bg_rgb: (u8, u8, u8),
276}
277
278impl CursorJump {
279 pub fn new(
280 from: (u16, u16),
281 to: (u16, u16),
282 duration: Duration,
283 cursor_color: Color,
284 bg_color: Color,
285 ) -> Self {
286 let cursor_rgb = color_to_rgb(cursor_color).unwrap_or((255, 255, 255));
290 let bg_rgb = color_to_rgb(bg_color).unwrap_or((0, 0, 0));
291 Self {
292 from: (from.0 as i32, from.1 as i32),
293 to: (to.0 as i32, to.1 as i32),
294 duration,
295 cursor_rgb,
296 bg_rgb,
297 }
298 }
299
300 fn paint_cell(buf: &mut Buffer, col: i32, row: i32, bg: Color) {
301 if col < 0 || row < 0 {
302 return;
303 }
304 let buf_area = buf.area;
305 let c = col as u16;
306 let r = row as u16;
307 if c < buf_area.x
308 || c >= buf_area.x.saturating_add(buf_area.width)
309 || r < buf_area.y
310 || r >= buf_area.y.saturating_add(buf_area.height)
311 {
312 return;
313 }
314 if let Some(cell) = buf.cell_mut((c, r)) {
315 cell.set_bg(bg);
316 }
317 }
318}
319
320impl FrameEffect for CursorJump {
321 fn apply(&mut self, buf: &mut Buffer, _area: Rect, elapsed: Duration) -> EffectStatus {
322 let t = if self.duration.is_zero() {
323 1.0
324 } else {
325 (elapsed.as_secs_f32() / self.duration.as_secs_f32()).clamp(0.0, 1.0)
326 };
327
328 if t >= 1.0 {
336 return EffectStatus::Done;
337 }
338
339 let eased = ease_out_cubic(t);
340
341 let (fx, fy) = (self.from.0 as f32, self.from.1 as f32);
342 let (tx, ty) = (self.to.0 as f32, self.to.1 as f32);
343 let dx = tx - fx;
344 let dy = ty - fy;
345
346 let path_cells = dx.abs().max(dy.abs()).round() as i32;
349 let trail_len = (path_cells.min(8).max(2)) as usize;
350
351 for i in 0..trail_len {
352 let back = (i as f32) / (trail_len as f32);
356 let sample = (eased - back * 0.12).max(0.0);
357 let col = (fx + dx * sample).round() as i32;
358 let row = (fy + dy * sample).round() as i32;
359 let alpha = 1.0 - back;
360 let blended = blend_rgb(self.cursor_rgb, self.bg_rgb, alpha);
361 Self::paint_cell(buf, col, row, blended);
362 }
363
364 EffectStatus::Running
365 }
366}
367
368pub struct ColorTransition {
375 duration: Duration,
376 before: Option<SlideSnapshot>,
377}
378
379impl ColorTransition {
380 pub fn new(duration: Duration) -> Self {
381 Self {
382 duration,
383 before: None,
384 }
385 }
386}
387
388impl FrameEffect for ColorTransition {
389 fn capture_before(&mut self, buf: &Buffer, area: Rect) {
390 if self.before.is_none() {
391 self.before = Some(SlideIn::snapshot_area(buf, area));
392 }
393 }
394
395 fn apply(&mut self, buf: &mut Buffer, area: Rect, elapsed: Duration) -> EffectStatus {
396 let t = if self.duration.is_zero() {
397 1.0
398 } else {
399 (elapsed.as_secs_f32() / self.duration.as_secs_f32()).clamp(0.0, 1.0)
400 };
401 if t >= 1.0 {
405 return EffectStatus::Done;
406 }
407 let Some(before) = self.before.as_ref().filter(|b| b.area == area) else {
410 return EffectStatus::Done;
411 };
412
413 let eased = ease_out_cubic(t);
414 for dy in 0..area.height {
415 for dx in 0..area.width {
416 let idx = dy as usize * area.width as usize + dx as usize;
417 let old = &before.cells[idx];
418 let Some(cell) = buf.cell_mut((area.x + dx, area.y + dy)) else {
419 continue;
420 };
421 if let (Some(new_rgb), Some(old_rgb)) =
422 (color_to_rgb(cell.fg), color_to_rgb(old.fg))
423 {
424 if new_rgb != old_rgb {
425 cell.set_fg(blend_rgb(new_rgb, old_rgb, eased));
426 }
427 }
428 if let (Some(new_rgb), Some(old_rgb)) =
429 (color_to_rgb(cell.bg), color_to_rgb(old.bg))
430 {
431 if new_rgb != old_rgb {
432 cell.set_bg(blend_rgb(new_rgb, old_rgb, eased));
433 }
434 }
435 }
436 }
437
438 EffectStatus::Running
439 }
440}
441
442#[derive(Clone, Copy, PartialEq, Eq)]
444enum PState {
445 Resting,
447 Flying,
450 Sinking,
452}
453
454struct WaveParticle {
460 home_x: f32,
461 home_y: f32,
462 x: f32,
463 y: f32,
464 vx: f32,
465 vy: f32,
466 cell: Cell,
467 word: usize,
468 state: PState,
469}
470
471struct Word {
475 members: Vec<usize>,
476 home_y: f32,
477 center_x: f32,
478 launched: bool,
479}
480
481pub struct WaveEffect {
491 duration: Duration,
492 area: Rect,
493 particles: Vec<WaveParticle>,
494 words: Vec<Word>,
495 fill: Cell,
498 last_elapsed: Option<Duration>,
499 initialized: bool,
500}
501
502impl WaveEffect {
503 const GRAVITY: f32 = 24.0;
505 const LAUNCH_UP_MIN: f32 = 12.0;
506 const LAUNCH_UP_VAR: f32 = 10.0;
507 const LAUNCH_SIDE: f32 = 7.0;
508 const SINK_SPEED: f32 = 2.2;
510 const WORD_CAP: usize = 14;
513 const MAX_LEVEL_FRAC: f32 = 0.5;
515 const RISE_SECS: f32 = 1.6;
517 const AMP_BASE: f32 = 0.4;
521 const AMP_GROWTH: f32 = 0.6; const AMP_MAX: f32 = 12.0;
523
524 const K1: f32 = 0.157; const K2: f32 = 0.370; const K3: f32 = 0.785; const A1: f32 = 1.0;
532 const A2: f32 = 0.55;
533 const A3: f32 = 0.28;
534 const W1: f32 = 1.95; const W2: f32 = 2.95; const W3: f32 = 1.19; const W_SWING: f32 = 1.70; const SWING_A: f32 = 1.6;
540 const LAYERS: usize = 3;
542 const LAYER_SPACING: f32 = 2.3;
543
544 pub fn new(duration: Duration) -> Self {
545 Self {
546 duration,
547 area: Rect::new(0, 0, 0, 0),
548 particles: Vec::new(),
549 words: Vec::new(),
550 fill: Cell::default(),
551 last_elapsed: None,
552 initialized: false,
553 }
554 }
555
556 fn init(&mut self, buf: &Buffer, area: Rect) {
559 self.area = area;
560 let mut bg_counts: std::collections::HashMap<Color, u32> = std::collections::HashMap::new();
564 for dy in 0..area.height {
565 for dx in 0..area.width {
566 if let Some(c) = buf.cell((area.x + dx, area.y + dy)) {
567 *bg_counts.entry(c.bg).or_insert(0) += 1;
568 }
569 }
570 }
571 let fill_bg = bg_counts
572 .into_iter()
573 .max_by_key(|&(_, n)| n)
574 .map(|(c, _)| c)
575 .unwrap_or(Color::Reset);
576 let mut fill = Cell::default();
577 fill.set_symbol(" ");
578 fill.set_bg(fill_bg);
579 fill.set_fg(fill_bg);
580 self.fill = fill;
581
582 self.particles.clear();
588 self.words.clear();
589 for dy in 0..area.height {
590 let mut run: Vec<usize> = Vec::new();
591 let mut close_run = |run: &mut Vec<usize>,
592 particles: &mut Vec<WaveParticle>,
593 words: &mut Vec<Word>| {
594 if run.is_empty() {
595 return;
596 }
597 let wid = words.len();
598 let cx = run.iter().map(|&i| particles[i].home_x).sum::<f32>() / run.len() as f32;
599 for &i in run.iter() {
600 particles[i].word = wid;
601 }
602 words.push(Word {
603 members: std::mem::take(run),
604 home_y: dy as f32,
605 center_x: cx,
606 launched: false,
607 });
608 };
609 for dx in 0..area.width {
610 let Some(cell) = buf.cell((area.x + dx, area.y + dy)) else {
611 continue;
612 };
613 let is_ink = cell.symbol() != " " || cell.bg != fill_bg;
614 if !is_ink {
615 close_run(&mut run, &mut self.particles, &mut self.words);
616 continue;
617 }
618 self.particles.push(WaveParticle {
619 home_x: dx as f32,
620 home_y: dy as f32,
621 x: dx as f32,
622 y: dy as f32,
623 vx: 0.0,
624 vy: 0.0,
625 cell: cell.clone(),
626 word: 0,
627 state: PState::Resting,
628 });
629 run.push(self.particles.len() - 1);
630 if run.len() >= Self::WORD_CAP {
631 close_run(&mut run, &mut self.particles, &mut self.words);
632 }
633 }
634 close_run(&mut run, &mut self.particles, &mut self.words);
635 }
636 self.initialized = true;
637 }
638
639 fn level_amp(&self, elapsed: Duration) -> (f32, f32) {
646 let secs = elapsed.as_secs_f32();
647 let frac = smoothstep((secs / Self::RISE_SECS).min(1.0));
648 let level = frac * Self::MAX_LEVEL_FRAC * self.area.height as f32;
649 let amp = (Self::AMP_BASE + Self::AMP_GROWTH * secs).min(Self::AMP_MAX);
650 (level, amp)
651 }
652
653 fn undulation(x: f32, t: f32, lp: f32, amp: f32) -> f32 {
656 amp * (Self::A1 * (Self::K1 * x + Self::W1 * t + lp).sin()
657 + Self::A2 * (Self::K2 * x - Self::W2 * t + lp * 1.7).sin()
658 + Self::A3 * (Self::K3 * x + Self::W3 * t + lp * 0.5).sin())
659 }
660
661 fn swing(t: f32, amp: f32) -> f32 {
663 amp * Self::SWING_A * (Self::W_SWING * t).sin()
664 }
665
666 fn surface_at(x: f32, t: f32, water_top_mean: f32, amp: f32) -> f32 {
668 water_top_mean - Self::undulation(x, t, 0.0, amp) - Self::swing(t, amp)
669 }
670
671 fn step(&mut self, dt: f32, t: f32, water_top_mean: f32, amp: f32) {
673 let mut to_launch: Vec<(usize, f32, f32)> = Vec::new();
679 for wi in 0..self.words.len() {
680 if self.words[wi].launched {
681 continue;
682 }
683 let w = &self.words[wi];
684 let mut crest = f32::INFINITY;
685 for &pi in &w.members {
686 let s = Self::surface_at(self.particles[pi].home_x, t, water_top_mean, amp);
687 if s < crest {
688 crest = s;
689 }
690 }
691 if crest <= w.home_y + 0.5 {
694 let r = hash01(w.center_x, w.home_y);
695 let r2 = hash01(w.home_y, w.center_x);
696 let vy0 = -(Self::LAUNCH_UP_MIN + r * Self::LAUNCH_UP_VAR);
697 let vx0 = (r2 - 0.5) * 2.0 * Self::LAUNCH_SIDE;
698 to_launch.push((wi, vx0, vy0));
699 }
700 }
701 for (wi, vx0, vy0) in to_launch {
702 self.words[wi].launched = true;
703 for k in 0..self.words[wi].members.len() {
704 let pi = self.words[wi].members[k];
705 let p = &mut self.particles[pi];
706 p.state = PState::Flying;
707 p.vx = vx0;
708 p.vy = vy0;
709 }
710 }
711
712 let bottom = self.area.height as f32 - 1.0;
713 for p in self.particles.iter_mut() {
714 match p.state {
715 PState::Resting => {}
716 PState::Flying => {
717 p.vy += Self::GRAVITY * dt;
718 p.x += p.vx * dt;
719 p.y += p.vy * dt;
720 let surf = Self::surface_at(p.x, t, water_top_mean, amp);
722 if p.vy > 0.0 && p.y >= surf {
723 p.state = PState::Sinking;
724 p.y = surf;
725 p.vy = Self::SINK_SPEED;
726 p.vx *= 0.3;
727 }
728 }
729 PState::Sinking => {
730 p.vx *= (1.0 - 2.0 * dt).max(0.0);
733 p.x += p.vx * dt + 0.6 * (t * 1.3 + p.home_x).sin() * dt;
734 p.y += Self::SINK_SPEED * dt;
735 if p.y >= bottom {
736 p.y = bottom;
737 }
738 }
739 }
740 }
741 }
742
743 fn paint(&self, buf: &mut Buffer, elapsed: Duration, level: f32, amp: f32) {
746 let area = self.area;
747 let t = elapsed.as_secs_f32();
748 let water_top_mean = area.height as f32 - level;
749
750 for dy in 0..area.height {
751 for dx in 0..area.width {
752 if let Some(dst) = buf.cell_mut((area.x + dx, area.y + dy)) {
753 *dst = self.fill.clone();
754 }
755 }
756 }
757 for p in self.particles.iter() {
761 if p.state == PState::Sinking {
762 continue;
763 }
764 self.stamp(buf, p.x, p.y, |dst| *dst = p.cell.clone());
765 }
766 self.paint_water(buf, elapsed, level, amp);
767 for p in self.particles.iter() {
770 if p.state != PState::Sinking {
771 continue;
772 }
773 let depth = (p.y - Self::surface_at(p.x, t, water_top_mean, amp)).max(0.0);
774 let sym = p.cell.symbol().to_string();
775 let base = color_to_rgb(p.cell.fg).unwrap_or((230, 230, 230));
776 let fg = lerp_rgb(base, water_rgb(depth), 0.55);
777 let bg = water_rgb(depth + 0.5);
778 self.stamp(buf, p.x, p.y, |dst| {
779 dst.set_symbol(&sym);
780 dst.set_fg(Color::Rgb(fg.0, fg.1, fg.2));
781 dst.set_bg(Color::Rgb(bg.0, bg.1, bg.2));
782 });
783 }
784 }
785
786 fn stamp(&self, buf: &mut Buffer, x: f32, y: f32, f: impl FnOnce(&mut Cell)) {
788 let area = self.area;
789 let (cx, cy) = (x.round(), y.round());
790 if cx < 0.0 || cy < 0.0 {
791 return;
792 }
793 let (cx, cy) = (cx as u16, cy as u16);
794 if cx >= area.width || cy >= area.height {
795 return;
796 }
797 if let Some(dst) = buf.cell_mut((area.x + cx, area.y + cy)) {
798 f(dst);
799 }
800 }
801
802 fn paint_water(&self, buf: &mut Buffer, elapsed: Duration, level: f32, amp: f32) {
806 const CREST: [&str; 3] = ["~", "≈", "∿"];
807 let area = self.area;
808 let h = area.height as f32;
809 let t = elapsed.as_secs_f32();
810 let water_top_mean = h - level;
811 let foam = Color::Rgb(210, 245, 255);
812
813 for dx in 0..area.width {
814 let x = dx as f32;
815 let surf = Self::surface_at(x, t, water_top_mean, amp);
816 for dy in 0..area.height {
817 let y = dy as f32;
818 if y + 0.5 < surf {
820 continue;
821 }
822 let depth = y - surf;
823 let Some(dst) = buf.cell_mut((area.x + dx, area.y + dy)) else {
824 continue;
825 };
826 let body = water_rgb(depth);
827 if depth < 0.9 {
828 let gi = ((x * 0.5 + t * 1.6).floor() as i64).rem_euclid(3) as usize;
830 let crest_bg = lerp_rgb((70, 170, 228), body, 0.5);
831 dst.set_symbol(CREST[gi]);
832 dst.set_fg(foam);
833 dst.set_bg(Color::Rgb(crest_bg.0, crest_bg.1, crest_bg.2));
834 } else {
835 let bg = Color::Rgb(body.0, body.1, body.2);
838 if hash01(x + (t * 1.5).floor(), y) > 0.95 {
839 dst.set_symbol("∘");
840 dst.set_fg(Color::Rgb(150, 205, 235));
841 } else {
842 dst.set_symbol(" ");
843 dst.set_fg(bg);
844 }
845 dst.set_bg(bg);
846 }
847 }
848 }
849
850 for layer in 1..=Self::LAYERS {
855 let lp = layer as f32 * 2.3;
856 let base = layer as f32 * Self::LAYER_SPACING;
857 let lf = layer as f32 / (Self::LAYERS as f32 + 1.0);
858 let fg_rgb = lerp_rgb((190, 235, 255), (40, 120, 190), lf);
859 let fg = Color::Rgb(fg_rgb.0, fg_rgb.1, fg_rgb.2);
860 for dx in 0..area.width {
861 let x = dx as f32;
862 let surf = Self::surface_at(x, t, water_top_mean, amp);
863 let ly =
864 water_top_mean - Self::undulation(x, t, lp, amp) - Self::swing(t, amp) + base;
865 if ly <= surf + 0.6 || ly < 0.0 || ly >= h {
867 continue;
868 }
869 let row = ly.round();
870 if row < 0.0 || row >= h {
871 continue;
872 }
873 if let Some(dst) = buf.cell_mut((area.x + dx, area.y + row as u16)) {
874 let gi =
875 ((x * 0.4 + t * 1.2 + layer as f32).floor() as i64).rem_euclid(3) as usize;
876 dst.set_symbol(CREST[gi]);
877 dst.set_fg(fg);
878 }
879 }
880 }
881 }
882}
883
884impl FrameEffect for WaveEffect {
885 fn apply(&mut self, buf: &mut Buffer, area: Rect, elapsed: Duration) -> EffectStatus {
886 let t = if self.duration.is_zero() {
887 1.0
888 } else {
889 (elapsed.as_secs_f32() / self.duration.as_secs_f32()).clamp(0.0, 1.0)
890 };
891 if t >= 1.0 {
895 return EffectStatus::Done;
896 }
897
898 if !self.initialized || self.area != area {
899 self.init(buf, area);
903 self.last_elapsed = Some(elapsed);
904 let (level, amp) = self.level_amp(elapsed);
905 self.paint(buf, elapsed, level, amp);
906 return EffectStatus::Running;
907 }
908
909 let prev = self.last_elapsed.unwrap_or(elapsed);
915 let dt = (elapsed.as_secs_f32() - prev.as_secs_f32()).clamp(0.0, 0.25);
916 self.last_elapsed = Some(elapsed);
917 let (level, amp) = self.level_amp(elapsed);
918 let water_top_mean = self.area.height as f32 - level;
919 const SUB: f32 = 1.0 / 120.0;
920 let mut remaining = dt;
921 while remaining > 0.0 {
922 let step = remaining.min(SUB);
923 self.step(step, elapsed.as_secs_f32(), water_top_mean, amp);
924 remaining -= step;
925 }
926
927 self.paint(buf, elapsed, level, amp);
928 EffectStatus::Running
929 }
930}
931
932fn smoothstep(x: f32) -> f32 {
934 let x = x.clamp(0.0, 1.0);
935 x * x * (3.0 - 2.0 * x)
936}
937
938fn lerp_rgb(a: (u8, u8, u8), b: (u8, u8, u8), f: f32) -> (u8, u8, u8) {
940 let f = f.clamp(0.0, 1.0);
941 let mix = |a: u8, b: u8| (a as f32 + (b as f32 - a as f32) * f).round() as u8;
942 (mix(a.0, b.0), mix(a.1, b.1), mix(a.2, b.2))
943}
944
945fn water_rgb(depth: f32) -> (u8, u8, u8) {
948 const SHALLOW: (u8, u8, u8) = (38, 132, 205);
949 const DEEP: (u8, u8, u8) = (6, 26, 68);
950 lerp_rgb(SHALLOW, DEEP, (depth / 14.0).clamp(0.0, 1.0))
951}
952
953fn hash01(x: f32, y: f32) -> f32 {
957 let xi = x as i64;
958 let yi = y as i64;
959 let mut h = (xi.wrapping_mul(73_856_093) ^ yi.wrapping_mul(19_349_663)) as u64;
960 h ^= h >> 13;
961 h = h.wrapping_mul(0x9E37_79B9_7F4A_7C15);
962 h ^= h >> 16;
963 (h & 0xFFFF) as f32 / 65_536.0
964}
965
966fn blend_rgb(fg: (u8, u8, u8), bg: (u8, u8, u8), alpha: f32) -> Color {
967 let a = alpha.clamp(0.0, 1.0);
968 let mix = |f: u8, b: u8| -> u8 {
969 ((f as f32) * a + (b as f32) * (1.0 - a))
970 .round()
971 .clamp(0.0, 255.0) as u8
972 };
973 Color::Rgb(mix(fg.0, bg.0), mix(fg.1, bg.1), mix(fg.2, bg.2))
974}
975
976fn color_to_rgb(color: Color) -> Option<(u8, u8, u8)> {
977 match color {
978 Color::Rgb(r, g, b) => Some((r, g, b)),
979 Color::Black => Some((0, 0, 0)),
980 Color::Red => Some((205, 0, 0)),
981 Color::Green => Some((0, 205, 0)),
982 Color::Yellow => Some((205, 205, 0)),
983 Color::Blue => Some((0, 0, 238)),
984 Color::Magenta => Some((205, 0, 205)),
985 Color::Cyan => Some((0, 205, 205)),
986 Color::Gray => Some((229, 229, 229)),
987 Color::DarkGray => Some((127, 127, 127)),
988 Color::LightRed => Some((255, 0, 0)),
989 Color::LightGreen => Some((0, 255, 0)),
990 Color::LightYellow => Some((255, 255, 0)),
991 Color::LightBlue => Some((92, 92, 255)),
992 Color::LightMagenta => Some((255, 0, 255)),
993 Color::LightCyan => Some((0, 255, 255)),
994 Color::White => Some((255, 255, 255)),
995 Color::Indexed(_) => None,
999 Color::Reset => None,
1000 }
1001}
1002
1003struct ActiveEffect {
1004 id: AnimationId,
1005 area: Rect,
1006 started: Instant,
1007 delay: Duration,
1008 effect: Box<dyn FrameEffect + Send>,
1009 status: EffectStatus,
1010 deadline: Instant,
1011 dismissable: bool,
1015}
1016
1017pub struct AnimationRunner {
1018 next_id: u64,
1019 active: Vec<ActiveEffect>,
1020 total_started: u64,
1027 last_frame: Option<Buffer>,
1033}
1034
1035impl Default for AnimationRunner {
1036 fn default() -> Self {
1037 Self::new()
1038 }
1039}
1040
1041impl AnimationRunner {
1042 pub fn new() -> Self {
1043 Self {
1044 next_id: 1,
1045 active: Vec::new(),
1046 total_started: 0,
1047 last_frame: None,
1048 }
1049 }
1050
1051 pub fn start(&mut self, area: Rect, kind: AnimationKind) -> AnimationId {
1052 let id = AnimationId(self.next_id);
1053 self.next_id += 1;
1054 self.start_with_id(id, area, kind);
1055 id
1056 }
1057
1058 pub fn start_with_id(&mut self, id: AnimationId, area: Rect, kind: AnimationKind) {
1074 self.active.retain(|e| e.area != area);
1075 let now = Instant::now();
1076 let (effect, delay, duration): (Box<dyn FrameEffect + Send>, Duration, Duration) =
1077 match kind {
1078 AnimationKind::SlideIn {
1079 from,
1080 duration,
1081 delay,
1082 } => (Box::new(SlideIn::new(from, duration)), delay, duration),
1083 AnimationKind::CursorJump {
1084 from,
1085 to,
1086 duration,
1087 cursor_color,
1088 bg_color,
1089 } => (
1090 Box::new(CursorJump::new(from, to, duration, cursor_color, bg_color)),
1091 Duration::ZERO,
1092 duration,
1093 ),
1094 AnimationKind::ColorTransition { duration } => (
1095 Box::new(ColorTransition::new(duration)),
1096 Duration::ZERO,
1097 duration,
1098 ),
1099 AnimationKind::Wave { duration } => (
1100 Box::new(WaveEffect::new(duration)),
1101 Duration::ZERO,
1102 duration,
1103 ),
1104 };
1105 let dismissable = matches!(kind, AnimationKind::Wave { .. });
1106 self.total_started += 1;
1107 self.active.push(ActiveEffect {
1108 id,
1109 area,
1110 started: now,
1111 delay,
1112 effect,
1113 status: EffectStatus::Running,
1114 deadline: now + delay + duration,
1115 dismissable,
1116 });
1117 }
1118
1119 pub fn cancel(&mut self, id: AnimationId) {
1120 self.active.retain(|e| e.id != id);
1121 }
1122
1123 pub fn has_dismissable(&self) -> bool {
1126 self.active.iter().any(|e| e.dismissable)
1127 }
1128
1129 pub fn cancel_dismissable(&mut self) {
1131 self.active.retain(|e| !e.dismissable);
1132 }
1133
1134 pub fn capture_before_all(&mut self) {
1146 let now = Instant::now();
1147 let Some(prev) = self.last_frame.as_ref() else {
1148 return;
1149 };
1150 let prev_area = prev.area;
1151 for e in self.active.iter_mut() {
1152 if now < e.started + e.delay {
1153 continue;
1154 }
1155 if !rect_contains(prev_area, e.area) {
1156 continue;
1157 }
1158 e.effect.capture_before(prev, e.area);
1159 }
1160 }
1161
1162 pub fn apply_all(&mut self, buf: &mut Buffer) {
1163 let now = Instant::now();
1164 for e in self.active.iter_mut() {
1165 let effective_start = e.started + e.delay;
1166 if now < effective_start {
1167 continue;
1168 }
1169 let elapsed = now - effective_start;
1170 e.status = e.effect.apply(buf, e.area, elapsed);
1171 }
1172 self.active.retain(|e| e.status == EffectStatus::Running);
1173
1174 self.last_frame = Some(buf.clone());
1178 }
1179
1180 pub fn is_active(&self) -> bool {
1181 self.active
1182 .iter()
1183 .any(|e| e.status == EffectStatus::Running)
1184 }
1185
1186 pub fn total_started(&self) -> u64 {
1192 self.total_started
1193 }
1194
1195 pub fn next_deadline(&self) -> Option<Instant> {
1196 self.active.iter().map(|e| e.deadline).min()
1197 }
1198
1199 pub fn last_frame_area(&self) -> Option<Rect> {
1204 self.last_frame.as_ref().map(|b| b.area)
1205 }
1206
1207 pub fn is_animating_at(&self, col: u16, row: u16) -> bool {
1210 self.active.iter().any(|e| {
1211 e.status == EffectStatus::Running
1212 && col >= e.area.x
1213 && col < e.area.x.saturating_add(e.area.width)
1214 && row >= e.area.y
1215 && row < e.area.y.saturating_add(e.area.height)
1216 })
1217 }
1218}
1219
1220#[cfg(test)]
1221mod tests {
1222 use super::*;
1223 use ratatui::style::Color;
1224
1225 fn make_buf(w: u16, h: u16) -> Buffer {
1226 Buffer::empty(Rect::new(0, 0, w, h))
1227 }
1228
1229 fn paint(buf: &mut Buffer, area: Rect, ch: char, fg: Color) {
1230 for dy in 0..area.height {
1231 for dx in 0..area.width {
1232 if let Some(cell) = buf.cell_mut((area.x + dx, area.y + dy)) {
1233 cell.set_symbol(&ch.to_string());
1234 cell.set_fg(fg);
1235 }
1236 }
1237 }
1238 }
1239
1240 #[test]
1241 fn slide_in_bottom_at_t0_pushes_content_out() {
1242 let area = Rect::new(0, 0, 4, 3);
1243 let mut buf = make_buf(4, 3);
1244 paint(&mut buf, area, 'X', Color::Red);
1245
1246 let mut runner = AnimationRunner::new();
1247 runner.start(
1248 area,
1249 AnimationKind::SlideIn {
1250 from: Edge::Bottom,
1251 duration: Duration::from_millis(500),
1252 delay: Duration::ZERO,
1253 },
1254 );
1255 runner.apply_all(&mut buf);
1258 for dy in 0..area.height {
1259 for dx in 0..area.width {
1260 let cell = buf.cell((area.x + dx, area.y + dy)).unwrap();
1261 assert_eq!(cell.symbol(), " ", "blank at ({}, {}) at t=0", dx, dy);
1262 }
1263 }
1264 }
1265
1266 #[test]
1267 fn slide_in_bottom_at_duration_matches_snapshot() {
1268 let area = Rect::new(0, 0, 4, 3);
1269 let mut buf = make_buf(4, 3);
1270 paint(&mut buf, area, 'X', Color::Red);
1271
1272 let mut effect = SlideIn::new(Edge::Bottom, Duration::from_millis(100));
1274 effect.apply(&mut buf, area, Duration::ZERO);
1276 let status = effect.apply(&mut buf, area, Duration::from_millis(100));
1278 assert_eq!(status, EffectStatus::Done);
1279 for dy in 0..area.height {
1280 for dx in 0..area.width {
1281 let cell = buf.cell((area.x + dx, area.y + dy)).unwrap();
1282 assert_eq!(cell.symbol(), "X");
1283 assert_eq!(cell.fg, Color::Red);
1284 }
1285 }
1286 }
1287
1288 #[test]
1289 fn slide_in_with_before_snapshot_pushes_old_out() {
1290 let area = Rect::new(0, 0, 3, 4);
1292 let mut before_buf = make_buf(3, 4);
1293 paint(&mut before_buf, area, 'O', Color::Green);
1294 let mut after_buf = make_buf(3, 4);
1295 paint(&mut after_buf, area, 'N', Color::Blue);
1296
1297 let mut effect = SlideIn::new(Edge::Bottom, Duration::from_millis(100));
1298 effect.capture_before(&before_buf, area);
1299 let mut work = after_buf.clone();
1304 effect.apply(&mut work, area, Duration::from_millis(50));
1305 for dy in 0..area.height {
1306 for dx in 0..area.width {
1307 let cell = work.cell((area.x + dx, area.y + dy)).unwrap();
1308 let sym = cell.symbol();
1309 assert!(
1310 sym == "N" || sym == "O",
1311 "push should paint only OLD or NEW cells, got {:?} at ({},{})",
1312 sym,
1313 dx,
1314 dy
1315 );
1316 }
1317 }
1318 let status = effect.apply(&mut work, area, Duration::from_millis(100));
1320 assert_eq!(status, EffectStatus::Done);
1321 for dy in 0..area.height {
1322 for dx in 0..area.width {
1323 let cell = work.cell((area.x + dx, area.y + dy)).unwrap();
1324 assert_eq!(cell.symbol(), "N");
1325 }
1326 }
1327 }
1328
1329 #[test]
1330 fn runner_caches_last_frame_for_push_transition() {
1331 let area = Rect::new(0, 0, 3, 3);
1339 let mut runner = AnimationRunner::new();
1340
1341 let mut frame1 = make_buf(3, 3);
1344 paint(&mut frame1, area, 'O', Color::Green);
1345 runner.apply_all(&mut frame1);
1346 assert!(runner.last_frame.is_some());
1347
1348 let id = runner.start(
1352 area,
1353 AnimationKind::SlideIn {
1354 from: Edge::Bottom,
1355 duration: Duration::from_millis(100),
1356 delay: Duration::ZERO,
1357 },
1358 );
1359 runner.capture_before_all();
1360 let mut frame2 = make_buf(3, 3); paint(&mut frame2, area, 'N', Color::Blue);
1362 runner.apply_all(&mut frame2);
1363
1364 let mut seen_old = false;
1367 for dy in 0..area.height {
1368 for dx in 0..area.width {
1369 let cell = frame2.cell((area.x + dx, area.y + dy)).unwrap();
1370 if cell.symbol() == "O" {
1371 seen_old = true;
1372 }
1373 assert!(
1374 cell.symbol() == "O" || cell.symbol() == "N",
1375 "push should paint only OLD or NEW, got {:?}",
1376 cell.symbol()
1377 );
1378 }
1379 }
1380 assert!(
1381 seen_old,
1382 "at least one OLD cell should still be visible mid-transition"
1383 );
1384 let _ = id;
1385 }
1386
1387 #[test]
1388 fn runner_is_active_flips_after_duration() {
1389 let area = Rect::new(0, 0, 2, 2);
1390 let mut buf = make_buf(2, 2);
1391 let mut runner = AnimationRunner::new();
1392 runner.start(
1393 area,
1394 AnimationKind::SlideIn {
1395 from: Edge::Bottom,
1396 duration: Duration::from_millis(10),
1397 delay: Duration::ZERO,
1398 },
1399 );
1400 assert!(runner.is_active());
1401 runner.apply_all(&mut buf);
1402 assert!(runner.is_active(), "still running immediately after start");
1403 std::thread::sleep(Duration::from_millis(25));
1404 runner.apply_all(&mut buf);
1405 assert!(
1406 !runner.is_active(),
1407 "runner should have no active effects after duration elapses"
1408 );
1409 }
1410
1411 #[test]
1412 fn cancel_removes_effect_and_leaves_buffer_unchanged() {
1413 let area = Rect::new(0, 0, 4, 3);
1414 let mut buf = make_buf(4, 3);
1415 paint(&mut buf, area, 'X', Color::Red);
1416
1417 let mut runner = AnimationRunner::new();
1418 let id = runner.start(
1419 area,
1420 AnimationKind::SlideIn {
1421 from: Edge::Bottom,
1422 duration: Duration::from_millis(500),
1423 delay: Duration::ZERO,
1424 },
1425 );
1426 runner.cancel(id);
1427 assert!(!runner.is_active());
1428
1429 let mut buf2 = make_buf(4, 3);
1431 paint(&mut buf2, area, 'X', Color::Red);
1432 runner.apply_all(&mut buf2);
1433 for dy in 0..area.height {
1434 for dx in 0..area.width {
1435 let cell = buf2.cell((area.x + dx, area.y + dy)).unwrap();
1436 assert_eq!(cell.symbol(), "X");
1437 assert_eq!(cell.fg, Color::Red);
1438 }
1439 }
1440 }
1441
1442 #[test]
1443 fn delay_defers_application() {
1444 let area = Rect::new(0, 0, 2, 2);
1445 let mut buf = make_buf(2, 2);
1446 paint(&mut buf, area, 'X', Color::Red);
1447
1448 let mut runner = AnimationRunner::new();
1449 runner.start(
1450 area,
1451 AnimationKind::SlideIn {
1452 from: Edge::Bottom,
1453 duration: Duration::from_millis(10),
1454 delay: Duration::from_secs(3600),
1455 },
1456 );
1457 runner.apply_all(&mut buf);
1458 for dy in 0..area.height {
1460 for dx in 0..area.width {
1461 let cell = buf.cell((area.x + dx, area.y + dy)).unwrap();
1462 assert_eq!(cell.symbol(), "X");
1463 }
1464 }
1465 assert!(runner.is_active());
1466 }
1467
1468 #[test]
1469 fn next_deadline_is_earliest() {
1470 let area_a = Rect::new(0, 0, 2, 2);
1473 let area_b = Rect::new(0, 2, 2, 2);
1474 let mut runner = AnimationRunner::new();
1475 runner.start(
1476 area_a,
1477 AnimationKind::SlideIn {
1478 from: Edge::Bottom,
1479 duration: Duration::from_millis(100),
1480 delay: Duration::ZERO,
1481 },
1482 );
1483 let d1 = runner.next_deadline().unwrap();
1484 runner.start(
1485 area_b,
1486 AnimationKind::SlideIn {
1487 from: Edge::Bottom,
1488 duration: Duration::from_millis(1000),
1489 delay: Duration::ZERO,
1490 },
1491 );
1492 let d2 = runner.next_deadline().unwrap();
1493 assert!(d2 <= d1 + Duration::from_millis(5));
1494 }
1495
1496 #[test]
1497 fn starting_effect_on_same_area_replaces_previous() {
1498 let area = Rect::new(0, 0, 2, 2);
1499 let mut runner = AnimationRunner::new();
1500 let first = runner.start(
1501 area,
1502 AnimationKind::SlideIn {
1503 from: Edge::Bottom,
1504 duration: Duration::from_millis(500),
1505 delay: Duration::ZERO,
1506 },
1507 );
1508 assert_eq!(runner.active.len(), 1);
1509 let second = runner.start(
1510 area,
1511 AnimationKind::SlideIn {
1512 from: Edge::Top,
1513 duration: Duration::from_millis(500),
1514 delay: Duration::ZERO,
1515 },
1516 );
1517 assert_eq!(runner.active.len(), 1);
1519 assert_eq!(runner.active[0].id, second);
1520 assert_ne!(first, second);
1521 }
1522
1523 #[test]
1524 fn cursor_jump_final_frame_is_clean() {
1525 let area = Rect::new(0, 0, 6, 4);
1530 let mut buf = make_buf(6, 4);
1531 paint(&mut buf, area, '.', Color::White);
1532 let bg_before: Vec<_> = (0..area.height)
1533 .flat_map(|dy| (0..area.width).map(move |dx| (dx, dy)))
1534 .map(|(dx, dy)| buf.cell((area.x + dx, area.y + dy)).unwrap().bg)
1535 .collect();
1536
1537 let mut effect = CursorJump::new(
1538 (0, 0),
1539 (4, 2),
1540 Duration::from_millis(100),
1541 Color::Rgb(255, 200, 0),
1542 Color::Rgb(20, 20, 20),
1543 );
1544 let status = effect.apply(&mut buf, area, Duration::from_millis(100));
1545 assert_eq!(status, EffectStatus::Done);
1546
1547 let mut idx = 0;
1548 for dy in 0..area.height {
1549 for dx in 0..area.width {
1550 let cell = buf.cell((area.x + dx, area.y + dy)).unwrap();
1551 assert_eq!(
1552 cell.bg, bg_before[idx],
1553 "no cell bg should change at t>=1.0, but ({}, {}) did",
1554 dx, dy
1555 );
1556 idx += 1;
1557 }
1558 }
1559 }
1560
1561 #[test]
1562 fn cursor_jump_head_uses_cursor_color() {
1563 let area = Rect::new(0, 0, 12, 5);
1566 let mut buf = make_buf(12, 5);
1567 paint(&mut buf, area, '.', Color::White);
1568
1569 let cursor = Color::Rgb(255, 100, 0);
1570 let bg = Color::Rgb(0, 0, 0);
1571 let mut effect = CursorJump::new((0, 0), (10, 4), Duration::from_millis(100), cursor, bg);
1572 let status = effect.apply(&mut buf, area, Duration::from_millis(50));
1573 assert_eq!(status, EffectStatus::Running);
1574
1575 let mut found_full_cursor = false;
1576 for dy in 0..area.height {
1577 for dx in 0..area.width {
1578 let cell = buf.cell((area.x + dx, area.y + dy)).unwrap();
1579 if cell.bg == cursor {
1580 found_full_cursor = true;
1581 }
1582 }
1583 }
1584 assert!(
1585 found_full_cursor,
1586 "head cell should be painted with the full cursor color"
1587 );
1588 }
1589
1590 #[test]
1591 fn cursor_jump_trail_fades_toward_bg() {
1592 let area = Rect::new(0, 0, 20, 5);
1597 let mut buf = make_buf(20, 5);
1598 paint(&mut buf, area, '.', Color::White);
1599
1600 let cursor = Color::Rgb(255, 0, 0);
1601 let bg = Color::Rgb(0, 0, 0);
1602 let mut effect = CursorJump::new((0, 0), (18, 4), Duration::from_millis(100), cursor, bg);
1603 let _ = effect.apply(&mut buf, area, Duration::from_millis(70));
1604
1605 let mut blended_count = 0;
1606 for dy in 0..area.height {
1607 for dx in 0..area.width {
1608 let cell = buf.cell((area.x + dx, area.y + dy)).unwrap();
1609 if let Color::Rgb(r, g, b) = cell.bg {
1610 if r > 0 && r < 255 && g == 0 && b == 0 {
1613 blended_count += 1;
1614 }
1615 }
1616 }
1617 }
1618 assert!(
1619 blended_count > 0,
1620 "at least one trail cell should be a blend between cursor and bg"
1621 );
1622 }
1623
1624 #[test]
1625 fn cursor_jump_through_runner() {
1626 let mut runner = AnimationRunner::new();
1627 let area = Rect::new(0, 0, 10, 5);
1628 let id = runner.start(
1629 area,
1630 AnimationKind::CursorJump {
1631 from: (1, 1),
1632 to: (8, 4),
1633 duration: Duration::from_millis(50),
1634 cursor_color: Color::Rgb(255, 255, 0),
1635 bg_color: Color::Rgb(0, 0, 0),
1636 },
1637 );
1638 assert!(runner.is_active());
1639 let mut buf = make_buf(10, 5);
1640 paint(&mut buf, area, ' ', Color::Reset);
1641 runner.apply_all(&mut buf);
1642 assert!(runner.is_active());
1644 std::thread::sleep(Duration::from_millis(80));
1645 runner.apply_all(&mut buf);
1646 assert!(
1647 !runner.is_active(),
1648 "cursor jump should complete after duration"
1649 );
1650 let _ = id;
1651 }
1652
1653 fn paint_colors(buf: &mut Buffer, area: Rect, fg: Color, bg: Color) {
1654 for dy in 0..area.height {
1655 for dx in 0..area.width {
1656 if let Some(cell) = buf.cell_mut((area.x + dx, area.y + dy)) {
1657 cell.set_symbol("x");
1658 cell.set_fg(fg);
1659 cell.set_bg(bg);
1660 }
1661 }
1662 }
1663 }
1664
1665 #[test]
1666 fn color_transition_starts_at_old_colors() {
1667 let area = Rect::new(0, 0, 3, 2);
1668 let mut old = make_buf(3, 2);
1669 paint_colors(
1670 &mut old,
1671 area,
1672 Color::Rgb(200, 100, 0),
1673 Color::Rgb(10, 20, 30),
1674 );
1675 let mut new = make_buf(3, 2);
1676 paint_colors(
1677 &mut new,
1678 area,
1679 Color::Rgb(0, 100, 200),
1680 Color::Rgb(90, 80, 70),
1681 );
1682
1683 let mut effect = ColorTransition::new(Duration::from_millis(100));
1684 effect.capture_before(&old, area);
1685 let status = effect.apply(&mut new, area, Duration::ZERO);
1686 assert_eq!(status, EffectStatus::Running);
1687 let cell = new.cell((0, 0)).unwrap();
1688 assert_eq!(cell.fg, Color::Rgb(200, 100, 0), "t=0 shows old fg");
1689 assert_eq!(cell.bg, Color::Rgb(10, 20, 30), "t=0 shows old bg");
1690 assert_eq!(cell.symbol(), "x", "glyphs are not touched");
1691 }
1692
1693 #[test]
1694 fn color_transition_blends_mid_flight() {
1695 let area = Rect::new(0, 0, 2, 2);
1696 let mut old = make_buf(2, 2);
1697 paint_colors(&mut old, area, Color::Rgb(255, 0, 0), Color::Rgb(0, 0, 0));
1698 let mut new = make_buf(2, 2);
1699 paint_colors(
1700 &mut new,
1701 area,
1702 Color::Rgb(0, 0, 0),
1703 Color::Rgb(255, 255, 255),
1704 );
1705
1706 let mut effect = ColorTransition::new(Duration::from_millis(100));
1707 effect.capture_before(&old, area);
1708 let status = effect.apply(&mut new, area, Duration::from_millis(50));
1709 assert_eq!(status, EffectStatus::Running);
1710 let cell = new.cell((1, 1)).unwrap();
1711 match cell.fg {
1712 Color::Rgb(r, g, b) => {
1713 assert!(r > 0 && r < 255, "fg red mid-blend, got {}", r);
1714 assert_eq!((g, b), (0, 0));
1715 }
1716 other => panic!("expected RGB fg, got {:?}", other),
1717 }
1718 match cell.bg {
1719 Color::Rgb(r, g, b) => {
1720 assert!(r > 0 && r < 255, "bg mid-blend, got {}", r);
1721 assert_eq!(r, g);
1722 assert_eq!(g, b);
1723 }
1724 other => panic!("expected RGB bg, got {:?}", other),
1725 }
1726 }
1727
1728 #[test]
1729 fn color_transition_final_frame_is_untouched() {
1730 let area = Rect::new(0, 0, 2, 2);
1734 let mut old = make_buf(2, 2);
1735 paint_colors(&mut old, area, Color::Rgb(255, 0, 0), Color::Rgb(0, 0, 0));
1736 let mut new = make_buf(2, 2);
1737 paint_colors(&mut new, area, Color::Rgb(1, 2, 3), Color::Rgb(4, 5, 6));
1738
1739 let mut effect = ColorTransition::new(Duration::from_millis(100));
1740 effect.capture_before(&old, area);
1741 let status = effect.apply(&mut new, area, Duration::from_millis(100));
1742 assert_eq!(status, EffectStatus::Done);
1743 let cell = new.cell((0, 1)).unwrap();
1744 assert_eq!(cell.fg, Color::Rgb(1, 2, 3));
1745 assert_eq!(cell.bg, Color::Rgb(4, 5, 6));
1746 }
1747
1748 #[test]
1749 fn color_transition_without_before_snapshot_is_done() {
1750 let area = Rect::new(0, 0, 2, 2);
1751 let mut new = make_buf(2, 2);
1752 paint_colors(&mut new, area, Color::Rgb(1, 2, 3), Color::Rgb(4, 5, 6));
1753
1754 let mut effect = ColorTransition::new(Duration::from_millis(100));
1755 let status = effect.apply(&mut new, area, Duration::ZERO);
1756 assert_eq!(status, EffectStatus::Done, "no old frame — snap to new");
1757 let cell = new.cell((0, 0)).unwrap();
1758 assert_eq!(cell.fg, Color::Rgb(1, 2, 3));
1759 assert_eq!(cell.bg, Color::Rgb(4, 5, 6));
1760 }
1761
1762 #[test]
1763 fn color_transition_leaves_unresolvable_colors_alone() {
1764 let area = Rect::new(0, 0, 1, 1);
1767 let mut old = make_buf(1, 1);
1768 paint_colors(&mut old, area, Color::Reset, Color::Rgb(0, 0, 0));
1769 let mut new = make_buf(1, 1);
1770 paint_colors(&mut new, area, Color::Rgb(10, 10, 10), Color::Reset);
1771
1772 let mut effect = ColorTransition::new(Duration::from_millis(100));
1773 effect.capture_before(&old, area);
1774 effect.apply(&mut new, area, Duration::from_millis(50));
1775 let cell = new.cell((0, 0)).unwrap();
1776 assert_eq!(
1777 cell.fg,
1778 Color::Rgb(10, 10, 10),
1779 "old fg was Reset — no blend"
1780 );
1781 assert_eq!(cell.bg, Color::Reset, "new bg is Reset — no blend");
1782 }
1783
1784 #[test]
1785 fn color_transition_through_runner_uses_cached_frame() {
1786 let area = Rect::new(0, 0, 3, 2);
1788 let mut runner = AnimationRunner::new();
1789 let mut frame1 = make_buf(3, 2);
1790 paint_colors(
1791 &mut frame1,
1792 area,
1793 Color::Rgb(255, 0, 0),
1794 Color::Rgb(0, 0, 255),
1795 );
1796 runner.apply_all(&mut frame1);
1797 assert_eq!(runner.last_frame_area(), Some(area));
1798
1799 runner.start(
1804 area,
1805 AnimationKind::ColorTransition {
1806 duration: Duration::from_secs(3600),
1807 },
1808 );
1809 runner.capture_before_all();
1810 let mut frame2 = make_buf(3, 2);
1811 paint_colors(
1812 &mut frame2,
1813 area,
1814 Color::Rgb(0, 255, 0),
1815 Color::Rgb(255, 255, 0),
1816 );
1817 runner.apply_all(&mut frame2);
1818 assert!(runner.is_active());
1819
1820 let cell = frame2.cell((1, 1)).unwrap();
1821 let Color::Rgb(r, g, _) = cell.fg else {
1822 panic!("expected RGB fg, got {:?}", cell.fg);
1823 };
1824 assert!(
1825 r > 200 && g < 55,
1826 "right after start the fg should still be mostly the old red, got ({}, {})",
1827 r,
1828 g
1829 );
1830 }
1831
1832 #[test]
1833 fn wave_snapshots_ink_and_disturbs_content() {
1834 let area = Rect::new(0, 0, 8, 6);
1838 let mut buf = make_buf(8, 6);
1839 paint(&mut buf, area, 'A', Color::Rgb(200, 200, 200));
1840
1841 let mut effect = WaveEffect::new(Duration::from_secs(600));
1842 let s0 = effect.apply(&mut buf, area, Duration::ZERO);
1844 assert_eq!(s0, EffectStatus::Running);
1845 assert_eq!(effect.particles.len(), (area.width * area.height) as usize);
1847
1848 for ms in [400u64, 900, 1500, 2200, 3000] {
1852 effect.apply(&mut buf, area, Duration::from_millis(ms));
1853 }
1854 let mut non_a = 0;
1855 for dy in 0..area.height {
1856 for dx in 0..area.width {
1857 if buf.cell((dx, dy)).unwrap().symbol() != "A" {
1858 non_a += 1;
1859 }
1860 }
1861 }
1862 assert!(
1863 non_a > 0,
1864 "wave should have displaced content / drawn crest glyphs"
1865 );
1866 }
1867
1868 #[test]
1869 fn wave_reports_done_at_duration_cap() {
1870 let area = Rect::new(0, 0, 4, 4);
1871 let mut buf = make_buf(4, 4);
1872 paint(&mut buf, area, 'Z', Color::White);
1873 let mut effect = WaveEffect::new(Duration::from_millis(100));
1874 effect.apply(&mut buf, area, Duration::ZERO);
1875 let s = effect.apply(&mut buf, area, Duration::from_millis(100));
1878 assert_eq!(s, EffectStatus::Done);
1879 }
1880
1881 #[test]
1882 fn wave_through_runner_is_active_then_finishes() {
1883 let area = Rect::new(0, 0, 6, 5);
1884 let mut runner = AnimationRunner::new();
1885 runner.start(
1886 area,
1887 AnimationKind::Wave {
1888 duration: Duration::from_millis(60),
1889 },
1890 );
1891 assert!(runner.is_active());
1892 let mut buf = make_buf(6, 5);
1893 paint(&mut buf, area, '#', Color::Rgb(180, 180, 180));
1894 runner.apply_all(&mut buf);
1895 assert!(runner.is_active(), "running right after start");
1896 std::thread::sleep(Duration::from_millis(90));
1897 runner.apply_all(&mut buf);
1898 assert!(!runner.is_active(), "wave finishes past its duration cap");
1899 }
1900
1901 #[test]
1902 fn is_animating_at_covers_area() {
1903 let area = Rect::new(10, 5, 3, 2);
1904 let mut runner = AnimationRunner::new();
1905 runner.start(
1906 area,
1907 AnimationKind::SlideIn {
1908 from: Edge::Bottom,
1909 duration: Duration::from_millis(500),
1910 delay: Duration::ZERO,
1911 },
1912 );
1913 assert!(runner.is_animating_at(10, 5));
1914 assert!(runner.is_animating_at(12, 6));
1915 assert!(!runner.is_animating_at(9, 5));
1916 assert!(!runner.is_animating_at(13, 5));
1917 assert!(!runner.is_animating_at(10, 7));
1918 }
1919}