diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cb1337..59eabba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ All notable changes to this project will be documented in this file. +## [0.0.6] - Unreleased + +### Added +- TachyonFX based animations + + +### Fixed +- PatternProps and EuclideanDistribution modals now use the global theme background instead of the terminal default. + ## [0.0.5] - Unreleased ### Added diff --git a/Cargo.toml b/Cargo.toml index e6ca46a..29abead 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,6 +58,7 @@ clap = { version = "4", features = ["derive"] } rand = "0.8" serde = { version = "1", features = ["derive"] } serde_json = "1" +tachyonfx = { version = "0.22", features = ["std-duration"] } tui-big-text = "0.8" arboard = "3" minimad = "0.13" diff --git a/crates/ratatui/src/confirm.rs b/crates/ratatui/src/confirm.rs index 6858ead..89914e9 100644 --- a/crates/ratatui/src/confirm.rs +++ b/crates/ratatui/src/confirm.rs @@ -22,7 +22,7 @@ impl<'a> ConfirmModal<'a> { } } - pub fn render_centered(self, frame: &mut Frame, term: Rect) { + pub fn render_centered(self, frame: &mut Frame, term: Rect) -> Rect { let t = theme::get(); let inner = ModalFrame::new(self.title) .width(30) @@ -58,5 +58,7 @@ impl<'a> ConfirmModal<'a> { Paragraph::new(buttons).alignment(Alignment::Center), rows[1], ); + + inner } } diff --git a/crates/ratatui/src/file_browser.rs b/crates/ratatui/src/file_browser.rs index 26f976d..6aa10c4 100644 --- a/crates/ratatui/src/file_browser.rs +++ b/crates/ratatui/src/file_browser.rs @@ -57,7 +57,7 @@ impl<'a> FileBrowserModal<'a> { self } - pub fn render_centered(self, frame: &mut Frame, term: Rect) { + pub fn render_centered(self, frame: &mut Frame, term: Rect) -> Rect { let colors = theme::get(); let border_color = self.border_color.unwrap_or(colors.ui.text_primary); @@ -112,5 +112,7 @@ impl<'a> FileBrowserModal<'a> { .collect(); frame.render_widget(Paragraph::new(lines), rows[1]); + + inner } } diff --git a/crates/ratatui/src/text_input.rs b/crates/ratatui/src/text_input.rs index 9fd0f14..1df2fbe 100644 --- a/crates/ratatui/src/text_input.rs +++ b/crates/ratatui/src/text_input.rs @@ -41,7 +41,7 @@ impl<'a> TextInputModal<'a> { self } - pub fn render_centered(self, frame: &mut Frame, term: Rect) { + pub fn render_centered(self, frame: &mut Frame, term: Rect) -> Rect { let colors = theme::get(); let border_color = self.border_color.unwrap_or(colors.ui.text_primary); let height = if self.hint.is_some() { 6 } else { 5 }; @@ -81,5 +81,7 @@ impl<'a> TextInputModal<'a> { inner, ); } + + inner } } diff --git a/src/bin/desktop.rs b/src/bin/desktop.rs index 790796c..b3e8d0f 100644 --- a/src/bin/desktop.rs +++ b/src/bin/desktop.rs @@ -148,6 +148,7 @@ struct CagireDesktop { mouse_x: Arc, mouse_y: Arc, mouse_down: Arc, + last_frame: std::time::Instant, } impl CagireDesktop { @@ -285,6 +286,7 @@ impl CagireDesktop { mouse_x, mouse_y, mouse_down, + last_frame: std::time::Instant::now(), } } @@ -491,10 +493,15 @@ impl eframe::App for CagireDesktop { self.app.ui.sparkles.tick(self.terminal.get_frame().area()); } + cagire::state::effects::tick_effects(&mut self.app.ui, self.app.page); + + let elapsed = self.last_frame.elapsed(); + self.last_frame = std::time::Instant::now(); + let link = &self.link; let app = &self.app; self.terminal - .draw(|frame| views::render(frame, app, link, &seq_snapshot)) + .draw(|frame| views::render(frame, app, link, &seq_snapshot, elapsed)) .expect("Failed to draw"); ui.add(self.terminal.backend_mut()); diff --git a/src/main.rs b/src/main.rs index 40ea374..319ecfe 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,7 +16,7 @@ use std::io; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, Ordering}; use std::sync::Arc; -use std::time::Duration; +use std::time::{Duration, Instant}; use clap::Parser; use crossterm::event::{self, DisableBracketedPaste, EnableBracketedPaste, Event}; @@ -213,6 +213,8 @@ fn main() -> io::Result<()> { let mut terminal = Terminal::new(backend)?; terminal.clear()?; + let mut last_frame = Instant::now(); + loop { if app.audio.restart_pending { app.audio.restart_pending = false; @@ -369,11 +371,19 @@ fn main() -> io::Result<()> { } } - if app.playback.playing || had_event || app.ui.show_title { + state::effects::tick_effects(&mut app.ui, app.page); + + let elapsed = last_frame.elapsed(); + last_frame = Instant::now(); + + let effects_active = app.ui.effects.borrow().is_running() + || app.ui.modal_fx.borrow().is_some() + || app.ui.title_fx.borrow().is_some(); + if app.playback.playing || had_event || app.ui.show_title || effects_active { if app.ui.show_title { app.ui.sparkles.tick(terminal.get_frame().area()); } - terminal.draw(|frame| views::render(frame, &app, &link, &seq_snapshot))?; + terminal.draw(|frame| views::render(frame, &app, &link, &seq_snapshot, elapsed))?; } } diff --git a/src/state/effects.rs b/src/state/effects.rs new file mode 100644 index 0000000..d858749 --- /dev/null +++ b/src/state/effects.rs @@ -0,0 +1,54 @@ +use tachyonfx::{fx, Interpolation, Motion}; + +use crate::page::Page; +use crate::state::ui::UiState; +use crate::state::Modal; +use crate::theme; + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum FxId { + #[default] + PageTransition, +} + +pub fn tick_effects(ui: &mut UiState, page: Page) { + if !ui.show_title && ui.prev_show_title { + ui.effects.borrow_mut().add_unique_effect( + FxId::PageTransition, + fx::coalesce((200, Interpolation::QuadOut)), + ); + } + ui.prev_show_title = ui.show_title; + + let modal_open = !matches!(ui.modal, Modal::None); + + if modal_open && !ui.prev_modal_open { + let bg = theme::get().ui.bg; + *ui.modal_fx.borrow_mut() = Some(fx::fade_from_fg(bg, (50, Interpolation::QuadOut))); + } + ui.prev_modal_open = modal_open; + + if page != ui.prev_page { + let direction = page_direction(ui.prev_page, page); + let bg = theme::get().ui.bg; + ui.effects.borrow_mut().add_unique_effect( + FxId::PageTransition, + fx::sweep_in(direction, 10, 0, bg, (200, Interpolation::QuadOut)), + ); + ui.prev_page = page; + } +} + +fn page_direction(from: Page, to: Page) -> Motion { + let (fc, fr) = from.grid_pos(); + let (tc, tr) = to.grid_pos(); + if tc > fc { + Motion::LeftToRight + } else if tc < fc { + Motion::RightToLeft + } else if tr > fr { + Motion::UpToDown + } else { + Motion::DownToUp + } +} diff --git a/src/state/mod.rs b/src/state/mod.rs index e02053a..52b4afa 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -16,6 +16,7 @@ pub trait CyclicEnum: Sized + Copy + PartialEq + 'static { pub mod audio; pub mod color_scheme; pub mod editor; +pub mod effects; pub mod file_browser; pub mod live_keys; pub mod modal; diff --git a/src/state/ui.rs b/src/state/ui.rs index 63556dc..726e9db 100644 --- a/src/state/ui.rs +++ b/src/state/ui.rs @@ -1,7 +1,11 @@ +use std::cell::RefCell; use std::time::{Duration, Instant}; use cagire_ratatui::Sparkles; +use tachyonfx::{fx, Effect, EffectManager, Interpolation}; +use crate::page::Page; +use crate::state::effects::FxId; use crate::state::{ColorScheme, Modal}; #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] @@ -48,6 +52,12 @@ pub struct UiState { pub minimap_until: Option, pub color_scheme: ColorScheme, pub hue_rotation: f32, + pub effects: RefCell>, + pub modal_fx: RefCell>, + pub title_fx: RefCell>, + pub prev_modal_open: bool, + pub prev_page: Page, + pub prev_show_title: bool, } impl Default for UiState { @@ -74,6 +84,12 @@ impl Default for UiState { minimap_until: None, color_scheme: ColorScheme::default(), hue_rotation: 0.0, + effects: RefCell::new(EffectManager::default()), + modal_fx: RefCell::new(None), + title_fx: RefCell::new(Some(fx::coalesce((400, Interpolation::QuadOut)))), + prev_modal_open: false, + prev_page: Page::default(), + prev_show_title: true, } } } diff --git a/src/views/render.rs b/src/views/render.rs index f0579bf..d993208 100644 --- a/src/views/render.rs +++ b/src/views/render.rs @@ -4,14 +4,14 @@ use std::collections::hash_map::DefaultHasher; use std::collections::HashMap; use std::hash::{Hash, Hasher}; use std::sync::Arc; -use std::time::Instant; +use std::time::{Duration, Instant}; use rand::rngs::StdRng; use rand::SeedableRng; use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table}; +use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table}; use ratatui::Frame; use crate::app::App; @@ -150,7 +150,7 @@ fn adjust_spans_for_line( .collect() } -pub fn render(frame: &mut Frame, app: &App, link: &LinkState, snapshot: &SequencerSnapshot) { +pub fn render(frame: &mut Frame, app: &App, link: &LinkState, snapshot: &SequencerSnapshot, elapsed: Duration) { let term = frame.area(); let theme = theme::get(); @@ -165,6 +165,14 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, snapshot: &Sequenc if app.ui.show_title { title_view::render(frame, term, &app.ui); + + let mut fx = app.ui.title_fx.borrow_mut(); + if let Some(effect) = fx.as_mut() { + effect.process(elapsed, frame.buffer_mut(), term); + if !effect.running() { + *fx = None; + } + } return; } @@ -218,7 +226,7 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, snapshot: &Sequenc } render_footer(frame, app, footer_area); - render_modal(frame, app, snapshot, term); + let modal_area = render_modal(frame, app, snapshot, term); let show_minimap = app .ui @@ -241,6 +249,21 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, snapshot: &Sequenc let selected = app.page.grid_pos(); NavMinimap::new(&tiles, selected).render_centered(frame, term); } + + app.ui + .effects + .borrow_mut() + .process_effects(elapsed, frame.buffer_mut(), term); + + if let Some(area) = modal_area { + let mut fx = app.ui.modal_fx.borrow_mut(); + if let Some(effect) = fx.as_mut() { + effect.process(elapsed, frame.buffer_mut(), area); + if !effect.running() { + *fx = None; + } + } + } } fn header_height(width: u16) -> u16 { @@ -529,37 +552,35 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) { frame.render_widget(footer, area); } -fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term: Rect) { +fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term: Rect) -> Option { let theme = theme::get(); - match &app.ui.modal { - Modal::None => {} + let inner = match &app.ui.modal { + Modal::None => return None, Modal::ConfirmQuit { selected } => { - ConfirmModal::new("Confirm", "Quit?", *selected).render_centered(frame, term); + ConfirmModal::new("Confirm", "Quit?", *selected).render_centered(frame, term) } Modal::ConfirmDeleteStep { step, selected, .. } => { ConfirmModal::new("Confirm", &format!("Delete step {}?", step + 1), *selected) - .render_centered(frame, term); + .render_centered(frame, term) } Modal::ConfirmDeleteSteps { steps, selected, .. } => { let nums: Vec = steps.iter().map(|s| format!("{:02}", s + 1)).collect(); let label = format!("Delete steps {}?", nums.join(", ")); - ConfirmModal::new("Confirm", &label, *selected).render_centered(frame, term); + ConfirmModal::new("Confirm", &label, *selected).render_centered(frame, term) } Modal::ConfirmResetPattern { pattern, selected, .. - } => { - ConfirmModal::new( - "Confirm", - &format!("Reset pattern {}?", pattern + 1), - *selected, - ) - .render_centered(frame, term); - } + } => ConfirmModal::new( + "Confirm", + &format!("Reset pattern {}?", pattern + 1), + *selected, + ) + .render_centered(frame, term), Modal::ConfirmResetBank { bank, selected } => { ConfirmModal::new("Confirm", &format!("Reset bank {}?", bank + 1), *selected) - .render_centered(frame, term); + .render_centered(frame, term) } Modal::FileBrowser(state) => { use crate::state::file_browser::FileBrowserMode; @@ -579,32 +600,30 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term .border_color(border_color) .width(60) .height(18) - .render_centered(frame, term); + .render_centered(frame, term) } Modal::RenameBank { bank, name } => { TextInputModal::new(&format!("Rename Bank {:02}", bank + 1), name) .width(40) .border_color(theme.modal.rename) - .render_centered(frame, term); + .render_centered(frame, term) } Modal::RenamePattern { bank, pattern, name, - } => { - TextInputModal::new( - &format!("Rename B{:02}:P{:02}", bank + 1, pattern + 1), - name, - ) - .width(40) - .border_color(theme.modal.rename) - .render_centered(frame, term); - } + } => TextInputModal::new( + &format!("Rename B{:02}:P{:02}", bank + 1, pattern + 1), + name, + ) + .width(40) + .border_color(theme.modal.rename) + .render_centered(frame, term), Modal::RenameStep { step, name, .. } => { TextInputModal::new(&format!("Name Step {:02}", step + 1), name) .width(40) .border_color(theme.modal.input) - .render_centered(frame, term); + .render_centered(frame, term) } Modal::SetPattern { field, input } => { let (title, hint) = match field { @@ -615,15 +634,13 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term .hint(hint) .width(45) .border_color(theme.modal.confirm) - .render_centered(frame, term); - } - Modal::SetTempo(input) => { - TextInputModal::new("Set Tempo (20-300 BPM)", input) - .hint("Enter BPM") - .width(30) - .border_color(theme.modal.rename) - .render_centered(frame, term); + .render_centered(frame, term) } + Modal::SetTempo(input) => TextInputModal::new("Set Tempo (20-300 BPM)", input) + .hint("Enter BPM") + .width(30) + .border_color(theme.modal.rename) + .render_centered(frame, term), Modal::AddSamplePath(state) => { use crate::widgets::FileBrowserModal; let entries: Vec<(String, bool, bool)> = state @@ -637,7 +654,7 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term .border_color(theme.modal.rename) .width(60) .height(18) - .render_centered(frame, term); + .render_centered(frame, term) } Modal::Preview => { let width = (term.width * 80 / 100).max(40); @@ -714,6 +731,8 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term let paragraph = Paragraph::new(lines); frame.render_widget(paragraph, inner); } + + inner } Modal::Editor => { let width = (term.width * 80 / 100).max(40); @@ -871,6 +890,8 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term ]); frame.render_widget(Paragraph::new(hint).alignment(Alignment::Right), hint_area); } + + inner } Modal::PatternProps { bank, @@ -884,19 +905,12 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term } => { use crate::state::PatternPropsField; - let width = 50u16; - let height = 12u16; - let x = (term.width.saturating_sub(width)) / 2; - let y = (term.height.saturating_sub(height)) / 2; - let area = Rect::new(x, y, width, height); - - let block = Block::bordered() - .title(format!(" Pattern B{:02}:P{:02} ", bank + 1, pattern + 1)) - .border_style(Style::default().fg(theme.modal.input)); - - let inner = block.inner(area); - frame.render_widget(Clear, area); - frame.render_widget(block, area); + let inner = + ModalFrame::new(&format!(" Pattern B{:02}:P{:02} ", bank + 1, pattern + 1)) + .width(50) + .height(12) + .border_color(theme.modal.input) + .render_centered(frame, term); let fields = [ ("Name", name.as_str(), *field == PatternPropsField::Name), @@ -962,6 +976,8 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term Span::styled(" cancel", Style::default().fg(theme.hint.text)), ]); frame.render_widget(Paragraph::new(hint_line), hint_area); + + inner } Modal::KeybindingsHelp { scroll } => { let width = (term.width * 80 / 100).clamp(60, 100); @@ -1033,6 +1049,8 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term Paragraph::new(keybind_hint).alignment(Alignment::Right), hint_area, ); + + inner } Modal::EuclideanDistribution { source_step, @@ -1042,30 +1060,14 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term rotation, .. } => { - let width = 50u16; - let height = 11u16; - let x = (term.width.saturating_sub(width)) / 2; - let y = (term.height.saturating_sub(height)) / 2; - let area = Rect::new(x, y, width, height); - - let block = Block::bordered() - .title(format!(" Euclidean Distribution (Step {:02}) ", source_step + 1)) - .border_style(Style::default().fg(theme.modal.input)); - - let inner = block.inner(area); - frame.render_widget(Clear, area); - - // Fill background with theme color - let bg_fill = " ".repeat(area.width as usize); - for row in 0..area.height { - let line_area = Rect::new(area.x, area.y + row, area.width, 1); - frame.render_widget( - Paragraph::new(bg_fill.clone()).style(Style::new().bg(theme.ui.bg)), - line_area, - ); - } - - frame.render_widget(block, area); + let inner = ModalFrame::new(&format!( + " Euclidean Distribution (Step {:02}) ", + source_step + 1 + )) + .width(50) + .height(11) + .border_color(theme.modal.input) + .render_centered(frame, term); let fields = [ ( @@ -1140,8 +1142,18 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term Span::styled(" cancel", Style::default().fg(theme.hint.text)), ]); frame.render_widget(Paragraph::new(hint_line), hint_area); + + inner } - } + }; + + // Expand inner rect to include the border + Some(Rect::new( + inner.x.saturating_sub(1), + inner.y.saturating_sub(1), + inner.width + 2, + inner.height + 2, + )) } fn format_euclidean_preview(pulses: usize, steps: usize, rotation: usize) -> String {