use std::time::{Duration, Instant};
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::Color;
use tachyonfx::{EffectManager, Interpolation, Motion, fx};
use crate::app::State;
use crate::ui;
const BACKGROUND: Color = Color::Rgb(26, 27, 38);
const ACCENT: Color = Color::Rgb(122, 162, 247);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Scene {
active: usize,
pages: Vec<String>,
dialog: bool,
sidebar: bool,
}
impl Scene {
pub fn of(state: &State) -> Self {
Self {
active: state.active,
pages: state.stack.iter().map(|l| l.title.clone()).collect(),
dialog: state.help || state.confirm.is_some(),
sidebar: state.sidebar,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Transition {
Startup,
Page,
Dialog,
}
pub fn transition(previous: Option<&Scene>, next: &Scene) -> Option<Transition> {
let Some(previous) = previous else { return Some(Transition::Startup) };
if next.dialog && !previous.dialog {
Some(Transition::Dialog)
} else if !next.dialog && (next.active != previous.active || next.pages != previous.pages) {
Some(Transition::Page)
} else {
None
}
}
#[derive(Default)]
pub struct Animations {
effects: EffectManager<()>,
scene: Option<Scene>,
last_frame: Option<Instant>,
}
impl Animations {
pub fn running(&self) -> bool {
self.effects.is_running()
}
pub fn observe(&mut self, state: &State, screen: Rect) {
let scene = Scene::of(state);
let change = transition(self.scene.as_ref(), &scene);
self.scene = Some(scene);
let effects = &state.config.effects;
let enabled = |change| match change {
Transition::Startup => effects.startup,
Transition::Page => effects.pages,
Transition::Dialog => effects.dialogs,
};
let Some(change) = change.filter(|c| enabled(*c)) else { return };
if ui::video_area(state, screen).is_some() {
return;
}
let areas = ui::areas(screen, state.sidebar);
let timer = |ms| (ms, Interpolation::QuadOut);
match change {
Transition::Startup => {
if let Some(sidebar) = areas.sidebar {
self.add(fx::sweep_in(Motion::UpToDown, 8, 0, BACKGROUND, timer(450)).with_area(sidebar));
}
self.add(fx::coalesce(timer(500)).with_area(areas.content));
}
Transition::Page => {
self.add(fx::sweep_in(Motion::LeftToRight, 12, 0, BACKGROUND, timer(300)).with_area(areas.content));
}
Transition::Dialog => {
if let Some(dialog) = ui::dialog_area(state, screen) {
let flash = fx::fade_from_fg(ACCENT, timer(350));
let reveal = fx::sweep_in(Motion::UpToDown, 6, 0, BACKGROUND, timer(250));
self.add(fx::parallel(&[reveal, flash]).with_area(dialog));
}
}
}
}
pub fn render(&mut self, frame: &mut Frame) {
let now = Instant::now();
let elapsed = self.last_frame.map_or(Duration::ZERO, |t| now - t);
self.last_frame = self.running().then_some(now);
let area = frame.area();
self.effects.process_effects(elapsed.into(), frame.buffer_mut(), area);
}
fn add(&mut self, effect: tachyonfx::Effect) {
self.effects.add_effect(effect);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn scene(active: usize, pages: &[&str], dialog: bool) -> Scene {
Scene { active, pages: pages.iter().map(|p| p.to_string()).collect(), dialog, sidebar: true }
}
#[test]
fn first_frame_is_the_startup() {
assert_eq!(transition(None, &scene(0, &[], false)), Some(Transition::Startup));
}
#[test]
fn new_pages_and_tabs_animate_the_content() {
let home = scene(0, &["Inicio"], false);
assert_eq!(transition(Some(&home), &scene(0, &["Inicio", "Canal"], false)), Some(Transition::Page));
assert_eq!(transition(Some(&home), &scene(1, &["Inicio"], false)), Some(Transition::Page));
assert_eq!(transition(Some(&home), &home), None, "moving the selection is not a page");
}
#[test]
fn dialogs_animate_when_they_open_not_when_they_close() {
let home = scene(0, &[], false);
let help = scene(0, &[], true);
assert_eq!(transition(Some(&home), &help), Some(Transition::Dialog));
assert_eq!(transition(Some(&help), &home), None);
}
#[test]
fn disabled_or_over_video_nothing_runs() {
use crate::app::Action;
use crate::config::Config;
let screen = Rect::new(0, 0, 100, 30);
let mut config = Config::default();
config.effects.startup = false;
let mut animations = Animations::default();
animations.observe(&State::new(config, Vec::new()), screen);
assert!(!animations.running());
let mut state = State::new(Config::default(), Vec::new());
let mut animations = Animations::default();
animations.observe(&state, screen);
assert!(animations.running(), "startup animates");
let mut animations = Animations::default();
let id = match state.update(Action::Enter).as_slice() {
[crate::app::Effect::Load { id, .. }] => *id, other => panic!("{other:?}"),
};
state.update(Action::Loaded(id, Ok(crate::youtube::Items::Videos(vec![crate::domain::video("a")]))));
state.update(Action::Enter); animations.observe(&state, screen);
assert!(!animations.running(), "the video owns the screen");
}
}