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` only. Easing is an implementation detail.
10
11use ratatui::buffer::{Buffer, Cell};
12use ratatui::layout::Rect;
13use ratatui::style::Color;
14use std::time::{Duration, Instant};
15
16#[derive(Clone, Copy, Debug, PartialEq, Eq)]
17pub enum EffectStatus {
18    Running,
19    Done,
20}
21
22#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23pub enum Edge {
24    Top,
25    Bottom,
26    Left,
27    Right,
28}
29
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31pub enum AnimationKind {
32    SlideIn {
33        from: Edge,
34        duration: Duration,
35        delay: Duration,
36    },
37    /// Animate the cursor moving from one screen cell to another. Paints a
38    /// short trail of cells along the line from `from` to `to`: the head
39    /// cell uses `cursor_color` as background; trailing cells fade toward
40    /// `bg_color` (older positions are closer to bg). `from`/`to` are
41    /// absolute screen coordinates (col, row).
42    CursorJump {
43        from: (u16, u16),
44        to: (u16, u16),
45        duration: Duration,
46        cursor_color: Color,
47        bg_color: Color,
48    },
49}
50
51#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
52pub struct AnimationId(u64);
53
54impl AnimationId {
55    pub fn raw(self) -> u64 {
56        self.0
57    }
58    pub fn from_raw(v: u64) -> Self {
59        Self(v)
60    }
61}
62
63pub trait FrameEffect {
64    /// Optionally capture the pre-paint ("before") state of `area` from
65    /// the buffer at the start of a render pass. Called by the runner
66    /// once per render before the main paint walk, so effects like
67    /// `SlideIn` can snapshot the outgoing content and push it out
68    /// while new content slides in. Default: no-op.
69    fn capture_before(&mut self, _buf: &Buffer, _area: Rect) {}
70
71    fn apply(&mut self, buf: &mut Buffer, area: Rect, elapsed: Duration) -> EffectStatus;
72}
73
74/// True iff `outer` fully contains `inner` (all corners inside).
75fn rect_contains(outer: Rect, inner: Rect) -> bool {
76    inner.x >= outer.x
77        && inner.y >= outer.y
78        && inner.x.saturating_add(inner.width) <= outer.x.saturating_add(outer.width)
79        && inner.y.saturating_add(inner.height) <= outer.y.saturating_add(outer.height)
80}
81
82/// Ease-out cubic: starts fast, decelerates.
83fn ease_out_cubic(t: f32) -> f32 {
84    let t = t.clamp(0.0, 1.0);
85    let inv = 1.0 - t;
86    1.0 - inv * inv * inv
87}
88
89/// Slide-in effect. Paints the incoming ("after") content sliding in from
90/// `from`. When the runner captures a "before" snapshot at the start of
91/// the render pass, the outgoing content is pushed out the opposite
92/// direction in lock-step — giving a "push" transition that replaces
93/// old content with new. Without a before snapshot (initial bringup,
94/// buffer didn't exist yet), the vacated cells are blank.
95pub struct SlideIn {
96    from: Edge,
97    duration: Duration,
98    after: Option<SlideSnapshot>,
99    before: Option<SlideSnapshot>,
100}
101
102struct SlideSnapshot {
103    area: Rect,
104    cells: Vec<Cell>,
105}
106
107impl SlideIn {
108    pub fn new(from: Edge, duration: Duration) -> Self {
109        Self {
110            from,
111            duration,
112            after: None,
113            before: None,
114        }
115    }
116
117    fn snapshot_area(buf: &Buffer, area: Rect) -> SlideSnapshot {
118        let mut cells = Vec::with_capacity(area.width as usize * area.height as usize);
119        for dy in 0..area.height {
120            for dx in 0..area.width {
121                let x = area.x + dx;
122                let y = area.y + dy;
123                let cell = buf.cell((x, y)).cloned().unwrap_or_default();
124                cells.push(cell);
125            }
126        }
127        SlideSnapshot { area, cells }
128    }
129}
130
131impl FrameEffect for SlideIn {
132    fn capture_before(&mut self, buf: &Buffer, area: Rect) {
133        if self.before.is_none() {
134            self.before = Some(Self::snapshot_area(buf, area));
135        }
136    }
137
138    fn apply(&mut self, buf: &mut Buffer, area: Rect, elapsed: Duration) -> EffectStatus {
139        // First apply captures the post-paint "after" snapshot. The
140        // "before" snapshot, if any, was captured at the top of this
141        // render pass via the trait hook.
142        if self.after.is_none() {
143            self.after = Some(Self::snapshot_area(buf, area));
144        }
145        let after = match &self.after {
146            Some(s) if s.area == area => s,
147            Some(_) => {
148                // Area changed mid-animation (resize) — re-snapshot the
149                // after, and drop the before whose dimensions no longer
150                // match. Falls back to the slide-in-with-blanks path.
151                self.after = Some(Self::snapshot_area(buf, area));
152                self.before = None;
153                self.after.as_ref().unwrap()
154            }
155            None => unreachable!(),
156        };
157        let before = self.before.as_ref().filter(|b| b.area == area);
158
159        let t = if self.duration.is_zero() {
160            1.0
161        } else {
162            (elapsed.as_secs_f32() / self.duration.as_secs_f32()).clamp(0.0, 1.0)
163        };
164        let eased = ease_out_cubic(t);
165
166        // offset_row/col: how far the AFTER snapshot is shifted toward
167        // `from` at t. At t=0 it sits fully off the `from` edge; at
168        // t=1 it's at its natural position. BEFORE moves the same
169        // distance in the opposite direction (the "push out").
170        let (offset_row, offset_col) = match self.from {
171            Edge::Bottom => (((1.0 - eased) * area.height as f32).round() as i32, 0i32),
172            Edge::Top => (-(((1.0 - eased) * area.height as f32).round() as i32), 0),
173            Edge::Right => (0, ((1.0 - eased) * area.width as f32).round() as i32),
174            Edge::Left => (0, -(((1.0 - eased) * area.width as f32).round() as i32)),
175        };
176
177        // Before is pushed opposite to After: if After enters from
178        // below (offset_row > 0), Before exits upward (offset_row -
179        // height in the Bottom case). Same for horizontal edges.
180        let (before_offset_row, before_offset_col) = match self.from {
181            Edge::Bottom => (offset_row - area.height as i32, 0),
182            Edge::Top => (offset_row + area.height as i32, 0),
183            Edge::Right => (0, offset_col - area.width as i32),
184            Edge::Left => (0, offset_col + area.width as i32),
185        };
186
187        let blank = Cell::default();
188        for dy in 0..area.height {
189            for dx in 0..area.width {
190                let x = area.x + dx;
191                let y = area.y + dy;
192
193                // Try the incoming snapshot first (post-slide it's what
194                // everyone sees); fall back to the outgoing one, then
195                // to blank if neither slice covers this cell.
196                let after_src_dy = dy as i32 - offset_row;
197                let after_src_dx = dx as i32 - offset_col;
198                let after_cell = if after_src_dy >= 0
199                    && after_src_dy < area.height as i32
200                    && after_src_dx >= 0
201                    && after_src_dx < area.width as i32
202                {
203                    let idx = after_src_dy as usize * area.width as usize + after_src_dx as usize;
204                    Some(after.cells[idx].clone())
205                } else {
206                    None
207                };
208
209                let before_cell = if let Some(before) = before {
210                    let before_src_dy = dy as i32 - before_offset_row;
211                    let before_src_dx = dx as i32 - before_offset_col;
212                    if before_src_dy >= 0
213                        && before_src_dy < area.height as i32
214                        && before_src_dx >= 0
215                        && before_src_dx < area.width as i32
216                    {
217                        let idx =
218                            before_src_dy as usize * area.width as usize + before_src_dx as usize;
219                        Some(before.cells[idx].clone())
220                    } else {
221                        None
222                    }
223                } else {
224                    None
225                };
226
227                let new_cell = after_cell.or(before_cell).unwrap_or_else(|| blank.clone());
228                if let Some(dst) = buf.cell_mut((x, y)) {
229                    *dst = new_cell;
230                }
231            }
232        }
233
234        if t >= 1.0 {
235            EffectStatus::Done
236        } else {
237            EffectStatus::Running
238        }
239    }
240}
241
242/// Cursor-jump effect. Paints a moving "head" cell along the straight line
243/// from `from` to `to` with a short fading trail. Both endpoints are in
244/// absolute screen coordinates (col, row). The head cell's background is
245/// set to `cursor_color`; trailing cells blend toward `bg_color` so that
246/// older positions appear progressively dimmer. The effect operates
247/// outside the `area` snapshot model used by `SlideIn`: it directly
248/// mutates cells along the interpolated path and never reads/snapshots an
249/// area, so the `area` passed to the runner is only used for dedupe and
250/// replacement bookkeeping.
251pub struct CursorJump {
252    from: (i32, i32),
253    to: (i32, i32),
254    duration: Duration,
255    cursor_rgb: (u8, u8, u8),
256    bg_rgb: (u8, u8, u8),
257}
258
259impl CursorJump {
260    pub fn new(
261        from: (u16, u16),
262        to: (u16, u16),
263        duration: Duration,
264        cursor_color: Color,
265        bg_color: Color,
266    ) -> Self {
267        // Themes occasionally use Color::Reset / named colors for which we
268        // have no RGB; fall back to white/black so the effect still
269        // visibly fades rather than silently no-oping.
270        let cursor_rgb = color_to_rgb(cursor_color).unwrap_or((255, 255, 255));
271        let bg_rgb = color_to_rgb(bg_color).unwrap_or((0, 0, 0));
272        Self {
273            from: (from.0 as i32, from.1 as i32),
274            to: (to.0 as i32, to.1 as i32),
275            duration,
276            cursor_rgb,
277            bg_rgb,
278        }
279    }
280
281    fn paint_cell(buf: &mut Buffer, col: i32, row: i32, bg: Color) {
282        if col < 0 || row < 0 {
283            return;
284        }
285        let buf_area = buf.area;
286        let c = col as u16;
287        let r = row as u16;
288        if c < buf_area.x
289            || c >= buf_area.x.saturating_add(buf_area.width)
290            || r < buf_area.y
291            || r >= buf_area.y.saturating_add(buf_area.height)
292        {
293            return;
294        }
295        if let Some(cell) = buf.cell_mut((c, r)) {
296            cell.set_bg(bg);
297        }
298    }
299}
300
301impl FrameEffect for CursorJump {
302    fn apply(&mut self, buf: &mut Buffer, _area: Rect, elapsed: Duration) -> EffectStatus {
303        let t = if self.duration.is_zero() {
304            1.0
305        } else {
306            (elapsed.as_secs_f32() / self.duration.as_secs_f32()).clamp(0.0, 1.0)
307        };
308
309        // Final frame: paint nothing and report Done. We MUST leave the
310        // buffer clean here — the runner removes the effect after this
311        // call and the main loop stops scheduling renders (is_active is
312        // now false), so any trail cells painted now would persist on
313        // screen until the user does something else. The hardware cursor
314        // at the target is drawn by the editor's own pass, so the user
315        // still sees the cursor at its final spot.
316        if t >= 1.0 {
317            return EffectStatus::Done;
318        }
319
320        let eased = ease_out_cubic(t);
321
322        let (fx, fy) = (self.from.0 as f32, self.from.1 as f32);
323        let (tx, ty) = (self.to.0 as f32, self.to.1 as f32);
324        let dx = tx - fx;
325        let dy = ty - fy;
326
327        // Trail length scales with the path so short jumps don't get an
328        // oversized tail. Min 2 keeps a hint of motion even on tiny jumps.
329        let path_cells = dx.abs().max(dy.abs()).round() as i32;
330        let trail_len = (path_cells.min(8).max(2)) as usize;
331
332        for i in 0..trail_len {
333            // Trail samples behind the head: i=0 is the head (alpha=1, full
334            // cursor color), larger i is further back along the path with
335            // alpha decreasing toward 0 (full bg color).
336            let back = (i as f32) / (trail_len as f32);
337            let sample = (eased - back * 0.12).max(0.0);
338            let col = (fx + dx * sample).round() as i32;
339            let row = (fy + dy * sample).round() as i32;
340            let alpha = 1.0 - back;
341            let blended = blend_rgb(self.cursor_rgb, self.bg_rgb, alpha);
342            Self::paint_cell(buf, col, row, blended);
343        }
344
345        EffectStatus::Running
346    }
347}
348
349fn blend_rgb(fg: (u8, u8, u8), bg: (u8, u8, u8), alpha: f32) -> Color {
350    let a = alpha.clamp(0.0, 1.0);
351    let mix = |f: u8, b: u8| -> u8 {
352        ((f as f32) * a + (b as f32) * (1.0 - a))
353            .round()
354            .clamp(0.0, 255.0) as u8
355    };
356    Color::Rgb(mix(fg.0, bg.0), mix(fg.1, bg.1), mix(fg.2, bg.2))
357}
358
359fn color_to_rgb(color: Color) -> Option<(u8, u8, u8)> {
360    match color {
361        Color::Rgb(r, g, b) => Some((r, g, b)),
362        Color::Black => Some((0, 0, 0)),
363        Color::Red => Some((205, 0, 0)),
364        Color::Green => Some((0, 205, 0)),
365        Color::Yellow => Some((205, 205, 0)),
366        Color::Blue => Some((0, 0, 238)),
367        Color::Magenta => Some((205, 0, 205)),
368        Color::Cyan => Some((0, 205, 205)),
369        Color::Gray => Some((229, 229, 229)),
370        Color::DarkGray => Some((127, 127, 127)),
371        Color::LightRed => Some((255, 0, 0)),
372        Color::LightGreen => Some((0, 255, 0)),
373        Color::LightYellow => Some((255, 255, 0)),
374        Color::LightBlue => Some((92, 92, 255)),
375        Color::LightMagenta => Some((255, 0, 255)),
376        Color::LightCyan => Some((0, 255, 255)),
377        Color::White => Some((255, 255, 255)),
378        // 256-color palette: skip — themes virtually always supply RGB
379        // for cursor/editor_bg, and this would pull in a lookup table for
380        // a vanishingly rare case.
381        Color::Indexed(_) => None,
382        Color::Reset => None,
383    }
384}
385
386struct ActiveEffect {
387    id: AnimationId,
388    area: Rect,
389    started: Instant,
390    delay: Duration,
391    effect: Box<dyn FrameEffect + Send>,
392    status: EffectStatus,
393    deadline: Instant,
394}
395
396pub struct AnimationRunner {
397    next_id: u64,
398    active: Vec<ActiveEffect>,
399    /// Cumulative count of effects accepted by either `start` or
400    /// `start_with_id`. Monotonic; increments before the effect is
401    /// pushed so a sample taken any time after the call sees the
402    /// post-increment value. Tests sample this around the action under
403    /// test to detect that an effect was kicked off without having to
404    /// catch the transient `is_active()` window between polling ticks.
405    total_started: u64,
406    /// Full snapshot of the buffer at the end of the previous render
407    /// pass. Ratatui's swap_buffers resets the "current" buffer, so at
408    /// the start of the next draw `frame.buffer_mut()` is blank — not
409    /// the previous frame. We keep our own copy so `capture_before`
410    /// can see what the user actually saw last frame.
411    last_frame: Option<Buffer>,
412}
413
414impl Default for AnimationRunner {
415    fn default() -> Self {
416        Self::new()
417    }
418}
419
420impl AnimationRunner {
421    pub fn new() -> Self {
422        Self {
423            next_id: 1,
424            active: Vec::new(),
425            total_started: 0,
426            last_frame: None,
427        }
428    }
429
430    pub fn start(&mut self, area: Rect, kind: AnimationKind) -> AnimationId {
431        let id = AnimationId(self.next_id);
432        self.next_id += 1;
433        self.start_with_id(id, area, kind);
434        id
435    }
436
437    /// Start an effect using a caller-supplied ID. Intended for the plugin
438    /// bridge, where the plugin-side counter is the source of truth so the
439    /// JS call can return the ID synchronously.
440    ///
441    /// Replaces any existing active effect covering exactly the same
442    /// area. Without this, rapid re-triggers over the same Rect (tab
443    /// cycling, dashboard data refresh) stack effects whose snapshots
444    /// contaminate each other: the second effect's "after" snapshot is
445    /// taken from a buffer the first effect has already shifted, so
446    /// when both finish the final image is frozen mid-transition.
447    /// Replacement keeps exactly one effect per area — the latest one
448    /// wins, its before-snapshot (read from the runner's last_frame)
449    /// captures whatever the user is actually seeing right now,
450    /// including any in-flight shift, and the new push-over-blanks
451    /// starts from there.
452    pub fn start_with_id(&mut self, id: AnimationId, area: Rect, kind: AnimationKind) {
453        self.active.retain(|e| e.area != area);
454        let now = Instant::now();
455        let (effect, delay, duration): (Box<dyn FrameEffect + Send>, Duration, Duration) =
456            match kind {
457                AnimationKind::SlideIn {
458                    from,
459                    duration,
460                    delay,
461                } => (Box::new(SlideIn::new(from, duration)), delay, duration),
462                AnimationKind::CursorJump {
463                    from,
464                    to,
465                    duration,
466                    cursor_color,
467                    bg_color,
468                } => (
469                    Box::new(CursorJump::new(from, to, duration, cursor_color, bg_color)),
470                    Duration::ZERO,
471                    duration,
472                ),
473            };
474        self.total_started += 1;
475        self.active.push(ActiveEffect {
476            id,
477            area,
478            started: now,
479            delay,
480            effect,
481            status: EffectStatus::Running,
482            deadline: now + delay + duration,
483        });
484    }
485
486    pub fn cancel(&mut self, id: AnimationId) {
487        self.active.retain(|e| e.id != id);
488    }
489
490    /// Let each active effect snapshot the "before" state of its Rect
491    /// from the cached last-frame buffer. Called once per render, at
492    /// the start of the pass. We can't read the live `frame.buffer_mut()`
493    /// here because ratatui resets the current buffer before each draw
494    /// (see `swap_buffers`); our own cache is what actually holds what
495    /// was on screen last frame.
496    ///
497    /// Effects still in their `delay` window are skipped, and effects
498    /// whose Rect falls outside the cached buffer (resize shrank the
499    /// terminal) are skipped too — they fall back to the slide-over-
500    /// blanks path.
501    pub fn capture_before_all(&mut self) {
502        let now = Instant::now();
503        let Some(prev) = self.last_frame.as_ref() else {
504            return;
505        };
506        let prev_area = prev.area;
507        for e in self.active.iter_mut() {
508            if now < e.started + e.delay {
509                continue;
510            }
511            if !rect_contains(prev_area, e.area) {
512                continue;
513            }
514            e.effect.capture_before(prev, e.area);
515        }
516    }
517
518    pub fn apply_all(&mut self, buf: &mut Buffer) {
519        let now = Instant::now();
520        for e in self.active.iter_mut() {
521            let effective_start = e.started + e.delay;
522            if now < effective_start {
523                continue;
524            }
525            let elapsed = now - effective_start;
526            e.status = e.effect.apply(buf, e.area, elapsed);
527        }
528        self.active.retain(|e| e.status == EffectStatus::Running);
529
530        // Cache the final painted buffer so the next frame's
531        // `capture_before_all` can read it. We clone because ratatui
532        // resets the current buffer before the next draw.
533        self.last_frame = Some(buf.clone());
534    }
535
536    pub fn is_active(&self) -> bool {
537        self.active
538            .iter()
539            .any(|e| e.status == EffectStatus::Running)
540    }
541
542    /// Cumulative number of effects accepted by either `start` or
543    /// `start_with_id`, since this runner was constructed. Monotonic —
544    /// never decreases. Tests use this to detect that an effect was
545    /// kicked off without having to catch the transient `is_active()`
546    /// window between two polling ticks.
547    pub fn total_started(&self) -> u64 {
548        self.total_started
549    }
550
551    pub fn next_deadline(&self) -> Option<Instant> {
552        self.active.iter().map(|e| e.deadline).min()
553    }
554
555    /// True if `(col, row)` falls inside the area of any running effect.
556    /// Use this to suppress click routing during an animation.
557    pub fn is_animating_at(&self, col: u16, row: u16) -> bool {
558        self.active.iter().any(|e| {
559            e.status == EffectStatus::Running
560                && col >= e.area.x
561                && col < e.area.x.saturating_add(e.area.width)
562                && row >= e.area.y
563                && row < e.area.y.saturating_add(e.area.height)
564        })
565    }
566}
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571    use ratatui::style::Color;
572
573    fn make_buf(w: u16, h: u16) -> Buffer {
574        Buffer::empty(Rect::new(0, 0, w, h))
575    }
576
577    fn paint(buf: &mut Buffer, area: Rect, ch: char, fg: Color) {
578        for dy in 0..area.height {
579            for dx in 0..area.width {
580                if let Some(cell) = buf.cell_mut((area.x + dx, area.y + dy)) {
581                    cell.set_symbol(&ch.to_string());
582                    cell.set_fg(fg);
583                }
584            }
585        }
586    }
587
588    #[test]
589    fn slide_in_bottom_at_t0_pushes_content_out() {
590        let area = Rect::new(0, 0, 4, 3);
591        let mut buf = make_buf(4, 3);
592        paint(&mut buf, area, 'X', Color::Red);
593
594        let mut runner = AnimationRunner::new();
595        runner.start(
596            area,
597            AnimationKind::SlideIn {
598                from: Edge::Bottom,
599                duration: Duration::from_millis(500),
600                delay: Duration::ZERO,
601            },
602        );
603        // First apply_all snapshots and paints t≈0. Content is shifted down by
604        // area.height rows, so every visible row is blank.
605        runner.apply_all(&mut buf);
606        for dy in 0..area.height {
607            for dx in 0..area.width {
608                let cell = buf.cell((area.x + dx, area.y + dy)).unwrap();
609                assert_eq!(cell.symbol(), " ", "blank at ({}, {}) at t=0", dx, dy);
610            }
611        }
612    }
613
614    #[test]
615    fn slide_in_bottom_at_duration_matches_snapshot() {
616        let area = Rect::new(0, 0, 4, 3);
617        let mut buf = make_buf(4, 3);
618        paint(&mut buf, area, 'X', Color::Red);
619
620        // Construct SlideIn directly so we can drive its clock.
621        let mut effect = SlideIn::new(Edge::Bottom, Duration::from_millis(100));
622        // First apply at t=0 snapshots the buffer.
623        effect.apply(&mut buf, area, Duration::ZERO);
624        // Now drive it to t=duration: result should equal the original painted content.
625        let status = effect.apply(&mut buf, area, Duration::from_millis(100));
626        assert_eq!(status, EffectStatus::Done);
627        for dy in 0..area.height {
628            for dx in 0..area.width {
629                let cell = buf.cell((area.x + dx, area.y + dy)).unwrap();
630                assert_eq!(cell.symbol(), "X");
631                assert_eq!(cell.fg, Color::Red);
632            }
633        }
634    }
635
636    #[test]
637    fn slide_in_with_before_snapshot_pushes_old_out() {
638        // Before: 'O' everywhere. After: 'N' everywhere.
639        let area = Rect::new(0, 0, 3, 4);
640        let mut before_buf = make_buf(3, 4);
641        paint(&mut before_buf, area, 'O', Color::Green);
642        let mut after_buf = make_buf(3, 4);
643        paint(&mut after_buf, area, 'N', Color::Blue);
644
645        let mut effect = SlideIn::new(Edge::Bottom, Duration::from_millis(100));
646        effect.capture_before(&before_buf, area);
647        // Mid-transition: at t=0.5, half of OLD should still be
648        // visible (shifted up) and half of NEW should have entered
649        // (shifted down from the bottom). No blank cells — push
650        // means the edge vacated by OLD is filled by NEW.
651        let mut work = after_buf.clone();
652        effect.apply(&mut work, area, Duration::from_millis(50));
653        for dy in 0..area.height {
654            for dx in 0..area.width {
655                let cell = work.cell((area.x + dx, area.y + dy)).unwrap();
656                let sym = cell.symbol();
657                assert!(
658                    sym == "N" || sym == "O",
659                    "push should paint only OLD or NEW cells, got {:?} at ({},{})",
660                    sym,
661                    dx,
662                    dy
663                );
664            }
665        }
666        // And: at t=duration, the AFTER content is fully in place.
667        let status = effect.apply(&mut work, area, Duration::from_millis(100));
668        assert_eq!(status, EffectStatus::Done);
669        for dy in 0..area.height {
670            for dx in 0..area.width {
671                let cell = work.cell((area.x + dx, area.y + dy)).unwrap();
672                assert_eq!(cell.symbol(), "N");
673            }
674        }
675    }
676
677    #[test]
678    fn runner_caches_last_frame_for_push_transition() {
679        // Simulate two frames:
680        //   frame 1: buf contains OLD content, no effects, runner
681        //            caches this as last_frame.
682        //   frame 2: an effect is started, capture_before_all reads
683        //            OLD from the cache (not the blank live buffer),
684        //            then buf is repainted with NEW, apply_all runs
685        //            the push using OLD as the before.
686        let area = Rect::new(0, 0, 3, 3);
687        let mut runner = AnimationRunner::new();
688
689        // Frame 1: paint OLD into buf, run apply_all (no effects) so
690        // the runner caches it.
691        let mut frame1 = make_buf(3, 3);
692        paint(&mut frame1, area, 'O', Color::Green);
693        runner.apply_all(&mut frame1);
694        assert!(runner.last_frame.is_some());
695
696        // Frame 2: start the effect, capture_before_all (reads cache),
697        // paint NEW into a fresh blank buf (simulating ratatui reset),
698        // then apply_all.
699        let id = runner.start(
700            area,
701            AnimationKind::SlideIn {
702                from: Edge::Bottom,
703                duration: Duration::from_millis(100),
704                delay: Duration::ZERO,
705            },
706        );
707        runner.capture_before_all();
708        let mut frame2 = make_buf(3, 3); // blank, like ratatui's reset
709        paint(&mut frame2, area, 'N', Color::Blue);
710        runner.apply_all(&mut frame2);
711
712        // Mid-transition the painted cells should include OLD pixels
713        // being pushed out — not blanks where OLD used to be.
714        let mut seen_old = false;
715        for dy in 0..area.height {
716            for dx in 0..area.width {
717                let cell = frame2.cell((area.x + dx, area.y + dy)).unwrap();
718                if cell.symbol() == "O" {
719                    seen_old = true;
720                }
721                assert!(
722                    cell.symbol() == "O" || cell.symbol() == "N",
723                    "push should paint only OLD or NEW, got {:?}",
724                    cell.symbol()
725                );
726            }
727        }
728        assert!(
729            seen_old,
730            "at least one OLD cell should still be visible mid-transition"
731        );
732        let _ = id;
733    }
734
735    #[test]
736    fn runner_is_active_flips_after_duration() {
737        let area = Rect::new(0, 0, 2, 2);
738        let mut buf = make_buf(2, 2);
739        let mut runner = AnimationRunner::new();
740        runner.start(
741            area,
742            AnimationKind::SlideIn {
743                from: Edge::Bottom,
744                duration: Duration::from_millis(10),
745                delay: Duration::ZERO,
746            },
747        );
748        assert!(runner.is_active());
749        runner.apply_all(&mut buf);
750        assert!(runner.is_active(), "still running immediately after start");
751        std::thread::sleep(Duration::from_millis(25));
752        runner.apply_all(&mut buf);
753        assert!(
754            !runner.is_active(),
755            "runner should have no active effects after duration elapses"
756        );
757    }
758
759    #[test]
760    fn cancel_removes_effect_and_leaves_buffer_unchanged() {
761        let area = Rect::new(0, 0, 4, 3);
762        let mut buf = make_buf(4, 3);
763        paint(&mut buf, area, 'X', Color::Red);
764
765        let mut runner = AnimationRunner::new();
766        let id = runner.start(
767            area,
768            AnimationKind::SlideIn {
769                from: Edge::Bottom,
770                duration: Duration::from_millis(500),
771                delay: Duration::ZERO,
772            },
773        );
774        runner.cancel(id);
775        assert!(!runner.is_active());
776
777        // A fresh buffer with the same content — apply_all must leave it alone.
778        let mut buf2 = make_buf(4, 3);
779        paint(&mut buf2, area, 'X', Color::Red);
780        runner.apply_all(&mut buf2);
781        for dy in 0..area.height {
782            for dx in 0..area.width {
783                let cell = buf2.cell((area.x + dx, area.y + dy)).unwrap();
784                assert_eq!(cell.symbol(), "X");
785                assert_eq!(cell.fg, Color::Red);
786            }
787        }
788    }
789
790    #[test]
791    fn delay_defers_application() {
792        let area = Rect::new(0, 0, 2, 2);
793        let mut buf = make_buf(2, 2);
794        paint(&mut buf, area, 'X', Color::Red);
795
796        let mut runner = AnimationRunner::new();
797        runner.start(
798            area,
799            AnimationKind::SlideIn {
800                from: Edge::Bottom,
801                duration: Duration::from_millis(10),
802                delay: Duration::from_secs(3600),
803            },
804        );
805        runner.apply_all(&mut buf);
806        // Under the delay, apply is a no-op — buffer retains painted content.
807        for dy in 0..area.height {
808            for dx in 0..area.width {
809                let cell = buf.cell((area.x + dx, area.y + dy)).unwrap();
810                assert_eq!(cell.symbol(), "X");
811            }
812        }
813        assert!(runner.is_active());
814    }
815
816    #[test]
817    fn next_deadline_is_earliest() {
818        // Use two DIFFERENT areas so neither effect replaces the other
819        // (`start_with_id` drops any existing effect on the same Rect).
820        let area_a = Rect::new(0, 0, 2, 2);
821        let area_b = Rect::new(0, 2, 2, 2);
822        let mut runner = AnimationRunner::new();
823        runner.start(
824            area_a,
825            AnimationKind::SlideIn {
826                from: Edge::Bottom,
827                duration: Duration::from_millis(100),
828                delay: Duration::ZERO,
829            },
830        );
831        let d1 = runner.next_deadline().unwrap();
832        runner.start(
833            area_b,
834            AnimationKind::SlideIn {
835                from: Edge::Bottom,
836                duration: Duration::from_millis(1000),
837                delay: Duration::ZERO,
838            },
839        );
840        let d2 = runner.next_deadline().unwrap();
841        assert!(d2 <= d1 + Duration::from_millis(5));
842    }
843
844    #[test]
845    fn starting_effect_on_same_area_replaces_previous() {
846        let area = Rect::new(0, 0, 2, 2);
847        let mut runner = AnimationRunner::new();
848        let first = runner.start(
849            area,
850            AnimationKind::SlideIn {
851                from: Edge::Bottom,
852                duration: Duration::from_millis(500),
853                delay: Duration::ZERO,
854            },
855        );
856        assert_eq!(runner.active.len(), 1);
857        let second = runner.start(
858            area,
859            AnimationKind::SlideIn {
860                from: Edge::Top,
861                duration: Duration::from_millis(500),
862                delay: Duration::ZERO,
863            },
864        );
865        // Exactly one effect still active, and it's the newer one.
866        assert_eq!(runner.active.len(), 1);
867        assert_eq!(runner.active[0].id, second);
868        assert_ne!(first, second);
869    }
870
871    #[test]
872    fn cursor_jump_final_frame_is_clean() {
873        // Cursor jumps from (0,0) to (4,2). At t>=1.0 the effect must
874        // paint nothing and just report Done so the last frame on screen
875        // has no leftover trail (no further redraw is scheduled once the
876        // runner drops the effect).
877        let area = Rect::new(0, 0, 6, 4);
878        let mut buf = make_buf(6, 4);
879        paint(&mut buf, area, '.', Color::White);
880        let bg_before: Vec<_> = (0..area.height)
881            .flat_map(|dy| (0..area.width).map(move |dx| (dx, dy)))
882            .map(|(dx, dy)| buf.cell((area.x + dx, area.y + dy)).unwrap().bg)
883            .collect();
884
885        let mut effect = CursorJump::new(
886            (0, 0),
887            (4, 2),
888            Duration::from_millis(100),
889            Color::Rgb(255, 200, 0),
890            Color::Rgb(20, 20, 20),
891        );
892        let status = effect.apply(&mut buf, area, Duration::from_millis(100));
893        assert_eq!(status, EffectStatus::Done);
894
895        let mut idx = 0;
896        for dy in 0..area.height {
897            for dx in 0..area.width {
898                let cell = buf.cell((area.x + dx, area.y + dy)).unwrap();
899                assert_eq!(
900                    cell.bg, bg_before[idx],
901                    "no cell bg should change at t>=1.0, but ({}, {}) did",
902                    dx, dy
903                );
904                idx += 1;
905            }
906        }
907    }
908
909    #[test]
910    fn cursor_jump_head_uses_cursor_color() {
911        // Mid-flight, the head cell (sample at the leading edge of the
912        // trail) should be painted with the full cursor color (alpha=1).
913        let area = Rect::new(0, 0, 12, 5);
914        let mut buf = make_buf(12, 5);
915        paint(&mut buf, area, '.', Color::White);
916
917        let cursor = Color::Rgb(255, 100, 0);
918        let bg = Color::Rgb(0, 0, 0);
919        let mut effect = CursorJump::new((0, 0), (10, 4), Duration::from_millis(100), cursor, bg);
920        let status = effect.apply(&mut buf, area, Duration::from_millis(50));
921        assert_eq!(status, EffectStatus::Running);
922
923        let mut found_full_cursor = false;
924        for dy in 0..area.height {
925            for dx in 0..area.width {
926                let cell = buf.cell((area.x + dx, area.y + dy)).unwrap();
927                if cell.bg == cursor {
928                    found_full_cursor = true;
929                }
930            }
931        }
932        assert!(
933            found_full_cursor,
934            "head cell should be painted with the full cursor color"
935        );
936    }
937
938    #[test]
939    fn cursor_jump_trail_fades_toward_bg() {
940        // Tail cells (older positions) must blend toward bg; checking that
941        // among cells the effect touches there is at least one whose bg is
942        // strictly between the cursor color and the bg color (i.e., a true
943        // blend, not just one or the other).
944        let area = Rect::new(0, 0, 20, 5);
945        let mut buf = make_buf(20, 5);
946        paint(&mut buf, area, '.', Color::White);
947
948        let cursor = Color::Rgb(255, 0, 0);
949        let bg = Color::Rgb(0, 0, 0);
950        let mut effect = CursorJump::new((0, 0), (18, 4), Duration::from_millis(100), cursor, bg);
951        let _ = effect.apply(&mut buf, area, Duration::from_millis(70));
952
953        let mut blended_count = 0;
954        for dy in 0..area.height {
955            for dx in 0..area.width {
956                let cell = buf.cell((area.x + dx, area.y + dy)).unwrap();
957                if let Color::Rgb(r, g, b) = cell.bg {
958                    // Strictly between cursor (255,0,0) and bg (0,0,0):
959                    // red channel partially attenuated, others still 0.
960                    if r > 0 && r < 255 && g == 0 && b == 0 {
961                        blended_count += 1;
962                    }
963                }
964            }
965        }
966        assert!(
967            blended_count > 0,
968            "at least one trail cell should be a blend between cursor and bg"
969        );
970    }
971
972    #[test]
973    fn cursor_jump_through_runner() {
974        let mut runner = AnimationRunner::new();
975        let area = Rect::new(0, 0, 10, 5);
976        let id = runner.start(
977            area,
978            AnimationKind::CursorJump {
979                from: (1, 1),
980                to: (8, 4),
981                duration: Duration::from_millis(50),
982                cursor_color: Color::Rgb(255, 255, 0),
983                bg_color: Color::Rgb(0, 0, 0),
984            },
985        );
986        assert!(runner.is_active());
987        let mut buf = make_buf(10, 5);
988        paint(&mut buf, area, ' ', Color::Reset);
989        runner.apply_all(&mut buf);
990        // Should still be running right after start.
991        assert!(runner.is_active());
992        std::thread::sleep(Duration::from_millis(80));
993        runner.apply_all(&mut buf);
994        assert!(
995            !runner.is_active(),
996            "cursor jump should complete after duration"
997        );
998        let _ = id;
999    }
1000
1001    #[test]
1002    fn is_animating_at_covers_area() {
1003        let area = Rect::new(10, 5, 3, 2);
1004        let mut runner = AnimationRunner::new();
1005        runner.start(
1006            area,
1007            AnimationKind::SlideIn {
1008                from: Edge::Bottom,
1009                duration: Duration::from_millis(500),
1010                delay: Duration::ZERO,
1011            },
1012        );
1013        assert!(runner.is_animating_at(10, 5));
1014        assert!(runner.is_animating_at(12, 6));
1015        assert!(!runner.is_animating_at(9, 5));
1016        assert!(!runner.is_animating_at(13, 5));
1017        assert!(!runner.is_animating_at(10, 7));
1018    }
1019}