Skip to main content

fresh/view/
animation.rs

1//! Frame-buffer animation layer.
2//!
3//! Area-based post-processing effects applied after the main render pass.
4//! The `FrameEffect` trait is the seam: concrete implementations mutate a
5//! `(Buffer, Rect)` region given elapsed time. `AnimationRunner` drives
6//! active effects from the render clock. The layer knows nothing about
7//! virtual buffers; callers resolve areas and pass them in.
8//!
9//! Current effects: `SlideIn`, `CursorJump`, `ColorTransition`, `Wave`.
10//! Easing is an implementation detail. `Wave` is the odd one out — a
11//! stateful particle simulation that takes over the whole frame, rather
12//! than an area transition.
13
14use 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    /// Animate the cursor moving from one screen cell to another. Paints a
41    /// short trail of cells along the line from `from` to `to`: the head
42    /// cell uses `cursor_color` as background; trailing cells fade toward
43    /// `bg_color` (older positions are closer to bg). `from`/`to` are
44    /// absolute screen coordinates (col, row).
45    CursorJump {
46        from: (u16, u16),
47        to: (u16, u16),
48        duration: Duration,
49        cursor_color: Color,
50        bg_color: Color,
51    },
52    /// Crossfade every cell's fg/bg color from what was on screen last
53    /// frame to the freshly painted colors. Glyphs are untouched — only
54    /// colors interpolate — so a theme switch melts into the new palette
55    /// instead of flipping. Cells whose colors can't be resolved to RGB
56    /// (`Reset` / indexed) switch instantly.
57    ColorTransition { duration: Duration },
58    /// Playful full-screen effect: a crest of wave glyphs rises from the
59    /// bottom edge and, as it sweeps past each row, kicks every painted
60    /// cell ("ink" particle) on that row upward and sideways. Each
61    /// particle is then pulled back to its home cell by a damped spring,
62    /// so the whole UI — text, gutter, menu bar, status bar — bounces up,
63    /// down, and sideways before settling exactly back into place once the
64    /// wave exits the top. `duration` is a hard safety cap; the effect
65    /// normally ends earlier, the moment the crest is gone and every
66    /// particle has settled.
67    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    /// Optionally capture the pre-paint ("before") state of `area` from
84    /// the buffer at the start of a render pass. Called by the runner
85    /// once per render before the main paint walk, so effects like
86    /// `SlideIn` can snapshot the outgoing content and push it out
87    /// while new content slides in. Default: no-op.
88    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
93/// True iff `outer` fully contains `inner` (all corners inside).
94fn 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
101/// Ease-out cubic: starts fast, decelerates.
102fn 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
108/// Slide-in effect. Paints the incoming ("after") content sliding in from
109/// `from`. When the runner captures a "before" snapshot at the start of
110/// the render pass, the outgoing content is pushed out the opposite
111/// direction in lock-step — giving a "push" transition that replaces
112/// old content with new. Without a before snapshot (initial bringup,
113/// buffer didn't exist yet), the vacated cells are blank.
114pub 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        // First apply captures the post-paint "after" snapshot. The
159        // "before" snapshot, if any, was captured at the top of this
160        // render pass via the trait hook.
161        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                // Area changed mid-animation (resize) — re-snapshot the
168                // after, and drop the before whose dimensions no longer
169                // match. Falls back to the slide-in-with-blanks path.
170                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        // offset_row/col: how far the AFTER snapshot is shifted toward
186        // `from` at t. At t=0 it sits fully off the `from` edge; at
187        // t=1 it's at its natural position. BEFORE moves the same
188        // distance in the opposite direction (the "push out").
189        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        // Before is pushed opposite to After: if After enters from
197        // below (offset_row > 0), Before exits upward (offset_row -
198        // height in the Bottom case). Same for horizontal edges.
199        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                // Try the incoming snapshot first (post-slide it's what
213                // everyone sees); fall back to the outgoing one, then
214                // to blank if neither slice covers this cell.
215                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
261/// Cursor-jump effect. Paints a moving "head" cell along the straight line
262/// from `from` to `to` with a short fading trail. Both endpoints are in
263/// absolute screen coordinates (col, row). The head cell's background is
264/// set to `cursor_color`; trailing cells blend toward `bg_color` so that
265/// older positions appear progressively dimmer. The effect operates
266/// outside the `area` snapshot model used by `SlideIn`: it directly
267/// mutates cells along the interpolated path and never reads/snapshots an
268/// area, so the `area` passed to the runner is only used for dedupe and
269/// replacement bookkeeping.
270pub 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        // Themes occasionally use Color::Reset / named colors for which we
287        // have no RGB; fall back to white/black so the effect still
288        // visibly fades rather than silently no-oping.
289        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        // Final frame: paint nothing and report Done. We MUST leave the
329        // buffer clean here — the runner removes the effect after this
330        // call and the main loop stops scheduling renders (is_active is
331        // now false), so any trail cells painted now would persist on
332        // screen until the user does something else. The hardware cursor
333        // at the target is drawn by the editor's own pass, so the user
334        // still sees the cursor at its final spot.
335        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        // Trail length scales with the path so short jumps don't get an
347        // oversized tail. Min 2 keeps a hint of motion even on tiny jumps.
348        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            // Trail samples behind the head: i=0 is the head (alpha=1, full
353            // cursor color), larger i is further back along the path with
354            // alpha decreasing toward 0 (full bg color).
355            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
368/// Color-transition effect. Snapshots the previous frame (the colors the
369/// user was looking at before the switch) and, on every render while
370/// running, re-tints the freshly painted buffer: each cell's fg/bg is the
371/// blend of its old color and its new color at the eased progress. The
372/// paint pass keeps drawing pure new-theme colors underneath, so content
373/// changes mid-transition (typing, cursor) stay live; only the tint lags.
374pub 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        // Final frame: the buffer already holds pure new-theme colors, so
402        // leave it untouched and report Done (same contract as CursorJump —
403        // no further redraw is scheduled after the runner drops us).
404        if t >= 1.0 {
405            return EffectStatus::Done;
406        }
407        // Nothing to fade from: no frame was rendered before the switch,
408        // or a resize invalidated the snapshot. Snap to the new colors.
409        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/// Lifecycle of a snapshotted cell.
443#[derive(Clone, Copy, PartialEq, Eq)]
444enum PState {
445    /// Sitting at its home cell above the waterline, untouched so far.
446    Resting,
447    /// Launched: a ballistic projectile under gravity (its whole word flies
448    /// off together with a shared launch velocity).
449    Flying,
450    /// Fell back into the water and is now drifting slowly down through it.
451    Sinking,
452}
453
454/// One painted cell turned into a physics particle. `home` is its original
455/// screen cell (area-local float coords); `pos`/`vel` evolve once its word
456/// is struck by the wave. `cell` carries the full visual (glyph, fg, bg,
457/// modifier) so chrome colors fly along with the text. `word` indexes the
458/// run of characters it belongs to — the unit that launches together.
459struct 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
471/// A contiguous run of "ink" cells on one row — a word (or a chunk of a
472/// chrome band). All its characters launch together with one shared
473/// velocity, so the word flies off as a rigid cluster.
474struct Word {
475    members: Vec<usize>,
476    home_y: f32,
477    center_x: f32,
478    launched: bool,
479}
480
481/// Wave effect — see `AnimationKind::Wave`. A body of water, anchored to
482/// the bottom edge, rises to about half the view's height and then just
483/// undulates there like a real pond. Its surface is the superposition of
484/// several sine waves of different wavelengths, with more undulating "wave
485/// layers" rippling within the body, all heaving up and down slowly (well
486/// under 2 Hz); the swell amplitude builds up slowly. Whenever the
487/// undulating surface reaches a rendered word, that word is flung off as a
488/// ballistic projectile (gravity arc); when a flying character falls back
489/// onto the water it switches to drifting slowly down through it.
490pub struct WaveEffect {
491    duration: Duration,
492    area: Rect,
493    particles: Vec<WaveParticle>,
494    words: Vec<Word>,
495    /// Blank fill cell (background) painted into every cell before the
496    /// particles are stamped, so vacated space reads as empty editor bg.
497    fill: Cell,
498    last_elapsed: Option<Duration>,
499    initialized: bool,
500}
501
502impl WaveEffect {
503    // Ballistic launch + gravity (cells / sec, cells / sec²).
504    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    // Slow downward drift once a character is back in the water.
509    const SINK_SPEED: f32 = 2.2;
510    // Longest contiguous run treated as one word; longer chrome bands split
511    // into chunks so the whole status bar doesn't fly as one slab.
512    const WORD_CAP: usize = 14;
513    // Water tops out at this fraction of the view height, then undulates.
514    const MAX_LEVEL_FRAC: f32 = 0.5;
515    // Seconds for the water to climb to its level; then it just undulates.
516    const RISE_SECS: f32 = 1.6;
517    // Swell amplitude builds forever: base + growth·seconds (capped). As it
518    // grows the crests reach higher and higher, washing over — and flinging
519    // off — words further and further up the view.
520    const AMP_BASE: f32 = 0.4;
521    const AMP_GROWTH: f32 = 0.6; // per second
522    const AMP_MAX: f32 = 12.0;
523
524    // Surface = sum of three sine components with distinct wavelengths
525    // (spatial wavenumber k = 2π / wavelength_in_cols) and distinct slow
526    // temporal frequencies (angular w = 2π·f; every f ≤ 0.5 Hz, far under
527    // the 2 Hz ceiling). A_i are the relative vertical amplitudes (rows).
528    const K1: f32 = 0.157; // ~40-col swell
529    const K2: f32 = 0.370; // ~17-col chop
530    const K3: f32 = 0.785; // ~8-col ripple
531    const A1: f32 = 1.0;
532    const A2: f32 = 0.55;
533    const A3: f32 = 0.28;
534    const W1: f32 = 1.95; // 0.31 Hz
535    const W2: f32 = 2.95; // 0.47 Hz
536    const W3: f32 = 1.19; // 0.19 Hz
537                          // Whole-surface vertical heave.
538    const W_SWING: f32 = 1.70; // 0.27 Hz
539    const SWING_A: f32 = 1.6;
540    // Extra undulating layers drawn inside the body, each on its own phase.
541    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    /// Snapshot the painted buffer into particles grouped into words, and
557    /// record the fill bg.
558    fn init(&mut self, buf: &Buffer, area: Rect) {
559        self.area = area;
560        // Dominant background = the fill color for vacated cells. Counting
561        // exact `Color` values is enough: the editor bg dominates the
562        // frame, so it wins the tally.
563        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        // A cell is "ink" (a flying particle) if it draws anything: a
583        // non-space glyph, or a background that differs from the dominant
584        // fill (so colored chrome bands lift off too). Walk each row left to
585        // right, accumulating contiguous ink cells into a word; a gap (or
586        // the WORD_CAP) closes the current word.
587        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    /// Water height (rows above the bottom edge) and swell-amplitude at
640    /// `elapsed`. The level climbs to ~half the view over `RISE_SECS` and
641    /// then just holds, undulating. The amplitude keeps growing with time
642    /// (capped), so the swells build and reach ever-higher words. Driven by
643    /// absolute wall-clock so it's independent of the (long) safety
644    /// duration; the show really ends when the user dismisses it.
645    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    /// Vertical displacement of the surface at column `x`, time `t`, for a
654    /// layer whose phase is offset by `lp`. Sum of three wavelengths.
655    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    /// Whole-surface heave (slow vertical swing of the mean level).
662    fn swing(t: f32, amp: f32) -> f32 {
663        amp * Self::SWING_A * (Self::W_SWING * t).sin()
664    }
665
666    /// Surface row at column `x` (smaller = higher up the screen).
667    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    /// Advance physics by `dt` seconds at time `t`.
672    fn step(&mut self, dt: f32, t: f32, water_top_mean: f32, amp: f32) {
673        // Launch any word whose row the wave crest has *visibly* reached.
674        // A word only flies when the highest point of the surface over its
675        // own columns rises to its row (the painted waterline covers the
676        // word's cell) — not merely when the mean level is near. The whole
677        // word gets one shared velocity so it flies off as a unit.
678        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            // `crest <= row + 0.5` matches the paint rule that turns a cell
692            // to water (y + 0.5 >= surf), so launch == visibly submerged.
693            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                    // Fell back onto the water? Start sinking.
721                    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                    // Slow descent with a gentle sideways sway, until it
731                    // settles on the seabed.
732                    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    /// Repaint the area: bg fill, then above-water particles, then the
744    /// water body, then submerged (sinking) particles tinted on top.
745    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        // Resting / flying particles sit above (or splash through) the
758        // surface — paint them first so the water can cover any that are
759        // momentarily below the waterline.
760        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        // Sinking particles are drawn over the water, tinted by depth so
768        // they read as submerged and drifting down.
769        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    /// Write to the cell at rounded `(x, y)` if it's on screen.
787    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    /// Paint the water body: every cell at or below the undulating surface
803    /// is tinted (shallow→deep by depth), the crest row gets foam glyphs,
804    /// and `LAYERS` extra undulating foam lines ripple within the body.
805    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                // Cells above the surface stay as air (content / bg).
819                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                    // Foam crest right at the waterline.
829                    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                    // Body: colored water with sparse, slowly twinkling
836                    // bubbles for texture.
837                    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        // Internal undulating layers — each is its own surface curve, on a
851        // distinct phase, sitting progressively deeper. They ride on top of
852        // the water bg painted above, so they read as ripples within the
853        // body rather than replacing its color.
854        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                // Keep the layer strictly inside the body.
866                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        // Hard cap reached: the tide has ebbed. Paint nothing and report
892        // Done so the live UI (re-painted under us every frame) shows
893        // through cleanly.
894        if t >= 1.0 {
895            return EffectStatus::Done;
896        }
897
898        if !self.initialized || self.area != area {
899            // First frame (or a resize changed the area): re-snapshot the
900            // freshly painted buffer. A mid-flight resize is rare;
901            // restarting is simpler and visually fine.
902            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        // Integrate from the last frame's timestamp. We sub-step in fixed
910        // slices so the simulation advances by the true wall-clock delta
911        // regardless of frame rate (a slow debug frame can be 50–100ms),
912        // while each slice stays small enough to keep the arcs stable. A
913        // long stall (debugger pause) is capped so it can't explode.
914        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
932/// Smoothstep on [0, 1]: 0 at 0, 1 at 1, flat slope at both ends.
933fn smoothstep(x: f32) -> f32 {
934    let x = x.clamp(0.0, 1.0);
935    x * x * (3.0 - 2.0 * x)
936}
937
938/// Linear interpolation between two RGB triples (`f`=0 → `a`, `f`=1 → `b`).
939fn 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
945/// Water color at a given depth below the surface: shallow turquoise at
946/// the top fading to deep navy further down.
947fn 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
953/// Cheap deterministic hash of a cell's home position to a float in
954/// [0, 1). Gives each particle a stable per-cell jitter without pulling in
955/// an RNG dependency.
956fn 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        // 256-color palette: skip — themes virtually always supply RGB
996        // for cursor/editor_bg, and this would pull in a lookup table for
997        // a vanishingly rare case.
998        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    /// An interactive, runs-until-dismissed effect (the wave). Such effects
1012    /// are torn down by the next key press or mouse move rather than a
1013    /// timer; `cancel_dismissable` removes them.
1014    dismissable: bool,
1015}
1016
1017pub struct AnimationRunner {
1018    next_id: u64,
1019    active: Vec<ActiveEffect>,
1020    /// Cumulative count of effects accepted by either `start` or
1021    /// `start_with_id`. Monotonic; increments before the effect is
1022    /// pushed so a sample taken any time after the call sees the
1023    /// post-increment value. Tests sample this around the action under
1024    /// test to detect that an effect was kicked off without having to
1025    /// catch the transient `is_active()` window between polling ticks.
1026    total_started: u64,
1027    /// Full snapshot of the buffer at the end of the previous render
1028    /// pass. Ratatui's swap_buffers resets the "current" buffer, so at
1029    /// the start of the next draw `frame.buffer_mut()` is blank — not
1030    /// the previous frame. We keep our own copy so `capture_before`
1031    /// can see what the user actually saw last frame.
1032    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    /// Start an effect using a caller-supplied ID. Intended for the plugin
1059    /// bridge, where the plugin-side counter is the source of truth so the
1060    /// JS call can return the ID synchronously.
1061    ///
1062    /// Replaces any existing active effect covering exactly the same
1063    /// area. Without this, rapid re-triggers over the same Rect (tab
1064    /// cycling, dashboard data refresh) stack effects whose snapshots
1065    /// contaminate each other: the second effect's "after" snapshot is
1066    /// taken from a buffer the first effect has already shifted, so
1067    /// when both finish the final image is frozen mid-transition.
1068    /// Replacement keeps exactly one effect per area — the latest one
1069    /// wins, its before-snapshot (read from the runner's last_frame)
1070    /// captures whatever the user is actually seeing right now,
1071    /// including any in-flight shift, and the new push-over-blanks
1072    /// starts from there.
1073    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    /// True if an interactive, dismiss-on-input effect (the wave) is
1124    /// running.
1125    pub fn has_dismissable(&self) -> bool {
1126        self.active.iter().any(|e| e.dismissable)
1127    }
1128
1129    /// Tear down any interactive, dismiss-on-input effect (the wave).
1130    pub fn cancel_dismissable(&mut self) {
1131        self.active.retain(|e| !e.dismissable);
1132    }
1133
1134    /// Let each active effect snapshot the "before" state of its Rect
1135    /// from the cached last-frame buffer. Called once per render, at
1136    /// the start of the pass. We can't read the live `frame.buffer_mut()`
1137    /// here because ratatui resets the current buffer before each draw
1138    /// (see `swap_buffers`); our own cache is what actually holds what
1139    /// was on screen last frame.
1140    ///
1141    /// Effects still in their `delay` window are skipped, and effects
1142    /// whose Rect falls outside the cached buffer (resize shrank the
1143    /// terminal) are skipped too — they fall back to the slide-over-
1144    /// blanks path.
1145    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        // Cache the final painted buffer so the next frame's
1175        // `capture_before_all` can read it. We clone because ratatui
1176        // resets the current buffer before the next draw.
1177        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    /// Cumulative number of effects accepted by either `start` or
1187    /// `start_with_id`, since this runner was constructed. Monotonic —
1188    /// never decreases. Tests use this to detect that an effect was
1189    /// kicked off without having to catch the transient `is_active()`
1190    /// window between two polling ticks.
1191    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    /// Area of the cached last-frame buffer, i.e. the full screen as of
1200    /// the previous render. `None` until the first frame has been drawn.
1201    /// Full-screen effects (theme color transition) use this as their
1202    /// Rect so callers don't need to thread the terminal size through.
1203    pub fn last_frame_area(&self) -> Option<Rect> {
1204        self.last_frame.as_ref().map(|b| b.area)
1205    }
1206
1207    /// True if `(col, row)` falls inside the area of any running effect.
1208    /// Use this to suppress click routing during an animation.
1209    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        // First apply_all snapshots and paints t≈0. Content is shifted down by
1256        // area.height rows, so every visible row is blank.
1257        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        // Construct SlideIn directly so we can drive its clock.
1273        let mut effect = SlideIn::new(Edge::Bottom, Duration::from_millis(100));
1274        // First apply at t=0 snapshots the buffer.
1275        effect.apply(&mut buf, area, Duration::ZERO);
1276        // Now drive it to t=duration: result should equal the original painted content.
1277        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        // Before: 'O' everywhere. After: 'N' everywhere.
1291        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        // Mid-transition: at t=0.5, half of OLD should still be
1300        // visible (shifted up) and half of NEW should have entered
1301        // (shifted down from the bottom). No blank cells — push
1302        // means the edge vacated by OLD is filled by NEW.
1303        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        // And: at t=duration, the AFTER content is fully in place.
1319        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        // Simulate two frames:
1332        //   frame 1: buf contains OLD content, no effects, runner
1333        //            caches this as last_frame.
1334        //   frame 2: an effect is started, capture_before_all reads
1335        //            OLD from the cache (not the blank live buffer),
1336        //            then buf is repainted with NEW, apply_all runs
1337        //            the push using OLD as the before.
1338        let area = Rect::new(0, 0, 3, 3);
1339        let mut runner = AnimationRunner::new();
1340
1341        // Frame 1: paint OLD into buf, run apply_all (no effects) so
1342        // the runner caches it.
1343        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        // Frame 2: start the effect, capture_before_all (reads cache),
1349        // paint NEW into a fresh blank buf (simulating ratatui reset),
1350        // then apply_all.
1351        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); // blank, like ratatui's reset
1361        paint(&mut frame2, area, 'N', Color::Blue);
1362        runner.apply_all(&mut frame2);
1363
1364        // Mid-transition the painted cells should include OLD pixels
1365        // being pushed out — not blanks where OLD used to be.
1366        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        // A fresh buffer with the same content — apply_all must leave it alone.
1430        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        // Under the delay, apply is a no-op — buffer retains painted content.
1459        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        // Use two DIFFERENT areas so neither effect replaces the other
1471        // (`start_with_id` drops any existing effect on the same Rect).
1472        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        // Exactly one effect still active, and it's the newer one.
1518        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        // Cursor jumps from (0,0) to (4,2). At t>=1.0 the effect must
1526        // paint nothing and just report Done so the last frame on screen
1527        // has no leftover trail (no further redraw is scheduled once the
1528        // runner drops the effect).
1529        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        // Mid-flight, the head cell (sample at the leading edge of the
1564        // trail) should be painted with the full cursor color (alpha=1).
1565        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        // Tail cells (older positions) must blend toward bg; checking that
1593        // among cells the effect touches there is at least one whose bg is
1594        // strictly between the cursor color and the bg color (i.e., a true
1595        // blend, not just one or the other).
1596        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                    // Strictly between cursor (255,0,0) and bg (0,0,0):
1611                    // red channel partially attenuated, others still 0.
1612                    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        // Should still be running right after start.
1643        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        // At t>=duration the buffer must keep its pure new-theme colors —
1731        // the runner drops the effect after this call and no further
1732        // redraw is scheduled, so any tint painted now would stick.
1733        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        // Reset has no RGB equivalent — those cells must flip instantly
1765        // rather than blend through a bogus fallback color.
1766        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        // Frame 1: old-theme colors, no effects — runner caches the frame.
1787        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        // Frame 2: theme switched — start the transition, capture the old
1800        // frame from the cache, paint new-theme colors, apply. Right after
1801        // start (t≈0) the visible colors must still be (close to) the old
1802        // ones, not the new ones.
1803        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        // A buffer of 'A's gets a wave; after a step the crest has begun
1835        // climbing and the painted content should differ from the static
1836        // input (cells displaced / crest glyphs laid down).
1837        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        // First apply initializes (snapshot) and paints t≈0.
1843        let s0 = effect.apply(&mut buf, area, Duration::ZERO);
1844        assert_eq!(s0, EffectStatus::Running);
1845        // Every cell was ink ('A'), so we get one particle per cell.
1846        assert_eq!(effect.particles.len(), (area.width * area.height) as usize);
1847
1848        // Drive past the rise, where the water has climbed to ~half the
1849        // view and the swell has flung off the lower rows. The buffer
1850        // should no longer be all 'A'.
1851        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        // At/after the hard cap the effect must report Done so the live UI
1876        // shows through cleanly (same contract as the other effects).
1877        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}