Feat: add tachyonFX animations

This commit is contained in:
2026-02-04 00:40:15 +01:00
parent 65736ccf84
commit bbbd8ff64a
11 changed files with 201 additions and 85 deletions

View File

@@ -2,6 +2,15 @@
All notable changes to this project will be documented in this file. 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 ## [0.0.5] - Unreleased
### Added ### Added

View File

@@ -58,6 +58,7 @@ clap = { version = "4", features = ["derive"] }
rand = "0.8" rand = "0.8"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
tachyonfx = { version = "0.22", features = ["std-duration"] }
tui-big-text = "0.8" tui-big-text = "0.8"
arboard = "3" arboard = "3"
minimad = "0.13" minimad = "0.13"

View File

@@ -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 t = theme::get();
let inner = ModalFrame::new(self.title) let inner = ModalFrame::new(self.title)
.width(30) .width(30)
@@ -58,5 +58,7 @@ impl<'a> ConfirmModal<'a> {
Paragraph::new(buttons).alignment(Alignment::Center), Paragraph::new(buttons).alignment(Alignment::Center),
rows[1], rows[1],
); );
inner
} }
} }

View File

@@ -57,7 +57,7 @@ impl<'a> FileBrowserModal<'a> {
self 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 colors = theme::get();
let border_color = self.border_color.unwrap_or(colors.ui.text_primary); let border_color = self.border_color.unwrap_or(colors.ui.text_primary);
@@ -112,5 +112,7 @@ impl<'a> FileBrowserModal<'a> {
.collect(); .collect();
frame.render_widget(Paragraph::new(lines), rows[1]); frame.render_widget(Paragraph::new(lines), rows[1]);
inner
} }
} }

View File

@@ -41,7 +41,7 @@ impl<'a> TextInputModal<'a> {
self 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 colors = theme::get();
let border_color = self.border_color.unwrap_or(colors.ui.text_primary); let border_color = self.border_color.unwrap_or(colors.ui.text_primary);
let height = if self.hint.is_some() { 6 } else { 5 }; let height = if self.hint.is_some() { 6 } else { 5 };
@@ -81,5 +81,7 @@ impl<'a> TextInputModal<'a> {
inner, inner,
); );
} }
inner
} }
} }

View File

@@ -148,6 +148,7 @@ struct CagireDesktop {
mouse_x: Arc<AtomicU32>, mouse_x: Arc<AtomicU32>,
mouse_y: Arc<AtomicU32>, mouse_y: Arc<AtomicU32>,
mouse_down: Arc<AtomicU32>, mouse_down: Arc<AtomicU32>,
last_frame: std::time::Instant,
} }
impl CagireDesktop { impl CagireDesktop {
@@ -285,6 +286,7 @@ impl CagireDesktop {
mouse_x, mouse_x,
mouse_y, mouse_y,
mouse_down, 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()); 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 link = &self.link;
let app = &self.app; let app = &self.app;
self.terminal 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"); .expect("Failed to draw");
ui.add(self.terminal.backend_mut()); ui.add(self.terminal.backend_mut());

View File

@@ -16,7 +16,7 @@ use std::io;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, Ordering}; use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::{Duration, Instant};
use clap::Parser; use clap::Parser;
use crossterm::event::{self, DisableBracketedPaste, EnableBracketedPaste, Event}; use crossterm::event::{self, DisableBracketedPaste, EnableBracketedPaste, Event};
@@ -213,6 +213,8 @@ fn main() -> io::Result<()> {
let mut terminal = Terminal::new(backend)?; let mut terminal = Terminal::new(backend)?;
terminal.clear()?; terminal.clear()?;
let mut last_frame = Instant::now();
loop { loop {
if app.audio.restart_pending { if app.audio.restart_pending {
app.audio.restart_pending = false; 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 { if app.ui.show_title {
app.ui.sparkles.tick(terminal.get_frame().area()); 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))?;
} }
} }

54
src/state/effects.rs Normal file
View File

@@ -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
}
}

View File

@@ -16,6 +16,7 @@ pub trait CyclicEnum: Sized + Copy + PartialEq + 'static {
pub mod audio; pub mod audio;
pub mod color_scheme; pub mod color_scheme;
pub mod editor; pub mod editor;
pub mod effects;
pub mod file_browser; pub mod file_browser;
pub mod live_keys; pub mod live_keys;
pub mod modal; pub mod modal;

View File

@@ -1,7 +1,11 @@
use std::cell::RefCell;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use cagire_ratatui::Sparkles; use cagire_ratatui::Sparkles;
use tachyonfx::{fx, Effect, EffectManager, Interpolation};
use crate::page::Page;
use crate::state::effects::FxId;
use crate::state::{ColorScheme, Modal}; use crate::state::{ColorScheme, Modal};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
@@ -48,6 +52,12 @@ pub struct UiState {
pub minimap_until: Option<Instant>, pub minimap_until: Option<Instant>,
pub color_scheme: ColorScheme, pub color_scheme: ColorScheme,
pub hue_rotation: f32, pub hue_rotation: f32,
pub effects: RefCell<EffectManager<FxId>>,
pub modal_fx: RefCell<Option<Effect>>,
pub title_fx: RefCell<Option<Effect>>,
pub prev_modal_open: bool,
pub prev_page: Page,
pub prev_show_title: bool,
} }
impl Default for UiState { impl Default for UiState {
@@ -74,6 +84,12 @@ impl Default for UiState {
minimap_until: None, minimap_until: None,
color_scheme: ColorScheme::default(), color_scheme: ColorScheme::default(),
hue_rotation: 0.0, 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,
} }
} }
} }

View File

@@ -4,14 +4,14 @@ use std::collections::hash_map::DefaultHasher;
use std::collections::HashMap; use std::collections::HashMap;
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
use std::sync::Arc; use std::sync::Arc;
use std::time::Instant; use std::time::{Duration, Instant};
use rand::rngs::StdRng; use rand::rngs::StdRng;
use rand::SeedableRng; use rand::SeedableRng;
use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style}; use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span}; 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 ratatui::Frame;
use crate::app::App; use crate::app::App;
@@ -150,7 +150,7 @@ fn adjust_spans_for_line(
.collect() .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 term = frame.area();
let theme = theme::get(); 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 { if app.ui.show_title {
title_view::render(frame, term, &app.ui); 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; return;
} }
@@ -218,7 +226,7 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, snapshot: &Sequenc
} }
render_footer(frame, app, footer_area); 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 let show_minimap = app
.ui .ui
@@ -241,6 +249,21 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, snapshot: &Sequenc
let selected = app.page.grid_pos(); let selected = app.page.grid_pos();
NavMinimap::new(&tiles, selected).render_centered(frame, term); 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 { 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); 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<Rect> {
let theme = theme::get(); let theme = theme::get();
match &app.ui.modal { let inner = match &app.ui.modal {
Modal::None => {} Modal::None => return None,
Modal::ConfirmQuit { selected } => { 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, .. } => { Modal::ConfirmDeleteStep { step, selected, .. } => {
ConfirmModal::new("Confirm", &format!("Delete step {}?", step + 1), *selected) ConfirmModal::new("Confirm", &format!("Delete step {}?", step + 1), *selected)
.render_centered(frame, term); .render_centered(frame, term)
} }
Modal::ConfirmDeleteSteps { Modal::ConfirmDeleteSteps {
steps, selected, .. steps, selected, ..
} => { } => {
let nums: Vec<String> = steps.iter().map(|s| format!("{:02}", s + 1)).collect(); let nums: Vec<String> = steps.iter().map(|s| format!("{:02}", s + 1)).collect();
let label = format!("Delete steps {}?", nums.join(", ")); 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 { Modal::ConfirmResetPattern {
pattern, selected, .. pattern, selected, ..
} => { } => ConfirmModal::new(
ConfirmModal::new( "Confirm",
"Confirm", &format!("Reset pattern {}?", pattern + 1),
&format!("Reset pattern {}?", pattern + 1), *selected,
*selected, )
) .render_centered(frame, term),
.render_centered(frame, term);
}
Modal::ConfirmResetBank { bank, selected } => { Modal::ConfirmResetBank { bank, selected } => {
ConfirmModal::new("Confirm", &format!("Reset bank {}?", bank + 1), *selected) ConfirmModal::new("Confirm", &format!("Reset bank {}?", bank + 1), *selected)
.render_centered(frame, term); .render_centered(frame, term)
} }
Modal::FileBrowser(state) => { Modal::FileBrowser(state) => {
use crate::state::file_browser::FileBrowserMode; 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) .border_color(border_color)
.width(60) .width(60)
.height(18) .height(18)
.render_centered(frame, term); .render_centered(frame, term)
} }
Modal::RenameBank { bank, name } => { Modal::RenameBank { bank, name } => {
TextInputModal::new(&format!("Rename Bank {:02}", bank + 1), name) TextInputModal::new(&format!("Rename Bank {:02}", bank + 1), name)
.width(40) .width(40)
.border_color(theme.modal.rename) .border_color(theme.modal.rename)
.render_centered(frame, term); .render_centered(frame, term)
} }
Modal::RenamePattern { Modal::RenamePattern {
bank, bank,
pattern, pattern,
name, name,
} => { } => TextInputModal::new(
TextInputModal::new( &format!("Rename B{:02}:P{:02}", bank + 1, pattern + 1),
&format!("Rename B{:02}:P{:02}", bank + 1, pattern + 1), name,
name, )
) .width(40)
.width(40) .border_color(theme.modal.rename)
.border_color(theme.modal.rename) .render_centered(frame, term),
.render_centered(frame, term);
}
Modal::RenameStep { step, name, .. } => { Modal::RenameStep { step, name, .. } => {
TextInputModal::new(&format!("Name Step {:02}", step + 1), name) TextInputModal::new(&format!("Name Step {:02}", step + 1), name)
.width(40) .width(40)
.border_color(theme.modal.input) .border_color(theme.modal.input)
.render_centered(frame, term); .render_centered(frame, term)
} }
Modal::SetPattern { field, input } => { Modal::SetPattern { field, input } => {
let (title, hint) = match field { let (title, hint) = match field {
@@ -615,15 +634,13 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
.hint(hint) .hint(hint)
.width(45) .width(45)
.border_color(theme.modal.confirm) .border_color(theme.modal.confirm)
.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::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) => { Modal::AddSamplePath(state) => {
use crate::widgets::FileBrowserModal; use crate::widgets::FileBrowserModal;
let entries: Vec<(String, bool, bool)> = state 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) .border_color(theme.modal.rename)
.width(60) .width(60)
.height(18) .height(18)
.render_centered(frame, term); .render_centered(frame, term)
} }
Modal::Preview => { Modal::Preview => {
let width = (term.width * 80 / 100).max(40); 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); let paragraph = Paragraph::new(lines);
frame.render_widget(paragraph, inner); frame.render_widget(paragraph, inner);
} }
inner
} }
Modal::Editor => { Modal::Editor => {
let width = (term.width * 80 / 100).max(40); 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); frame.render_widget(Paragraph::new(hint).alignment(Alignment::Right), hint_area);
} }
inner
} }
Modal::PatternProps { Modal::PatternProps {
bank, bank,
@@ -884,19 +905,12 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
} => { } => {
use crate::state::PatternPropsField; use crate::state::PatternPropsField;
let width = 50u16; let inner =
let height = 12u16; ModalFrame::new(&format!(" Pattern B{:02}:P{:02} ", bank + 1, pattern + 1))
let x = (term.width.saturating_sub(width)) / 2; .width(50)
let y = (term.height.saturating_sub(height)) / 2; .height(12)
let area = Rect::new(x, y, width, height); .border_color(theme.modal.input)
.render_centered(frame, term);
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 fields = [ let fields = [
("Name", name.as_str(), *field == PatternPropsField::Name), ("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)), Span::styled(" cancel", Style::default().fg(theme.hint.text)),
]); ]);
frame.render_widget(Paragraph::new(hint_line), hint_area); frame.render_widget(Paragraph::new(hint_line), hint_area);
inner
} }
Modal::KeybindingsHelp { scroll } => { Modal::KeybindingsHelp { scroll } => {
let width = (term.width * 80 / 100).clamp(60, 100); 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), Paragraph::new(keybind_hint).alignment(Alignment::Right),
hint_area, hint_area,
); );
inner
} }
Modal::EuclideanDistribution { Modal::EuclideanDistribution {
source_step, source_step,
@@ -1042,30 +1060,14 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
rotation, rotation,
.. ..
} => { } => {
let width = 50u16; let inner = ModalFrame::new(&format!(
let height = 11u16; " Euclidean Distribution (Step {:02}) ",
let x = (term.width.saturating_sub(width)) / 2; source_step + 1
let y = (term.height.saturating_sub(height)) / 2; ))
let area = Rect::new(x, y, width, height); .width(50)
.height(11)
let block = Block::bordered() .border_color(theme.modal.input)
.title(format!(" Euclidean Distribution (Step {:02}) ", source_step + 1)) .render_centered(frame, term);
.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 fields = [ 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)), Span::styled(" cancel", Style::default().fg(theme.hint.text)),
]); ]);
frame.render_widget(Paragraph::new(hint_line), hint_area); 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 { fn format_euclidean_preview(pulses: usize, steps: usize, rotation: usize) -> String {