mod effects;
mod easing;
pub use easing::Easing;
use std::io::Write;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::task::JoinHandle;
use crate::gradient::Gradient;
pub struct Animation {
running: Arc<AtomicBool>,
text: Arc<Mutex<String>>,
handle: Option<JoinHandle<()>>,
frame: Arc<AtomicUsize>,
last_rendered: Arc<Mutex<String>>,
lines_printed: Arc<AtomicUsize>,
clear_on_stop: Arc<AtomicBool>,
}
impl Animation {
pub fn stop(&self) {
self.running.store(false, Ordering::SeqCst);
}
pub fn replace(&self, new_text: &str) {
let mut text = self.text.lock().unwrap();
*text = new_text.to_string();
}
pub fn start(&self) {
self.running.store(true, Ordering::SeqCst);
}
pub fn frame(&self) -> usize {
self.frame.load(Ordering::SeqCst)
}
pub async fn join(mut self) {
if let Some(h) = self.handle.take() {
let _ = h.await;
}
}
pub async fn fade_to_foreground(self, duration: Duration) {
self.fade_out_to(FadeTarget::Foreground, duration, true).await;
}
pub async fn fade_to_gradient(self, gradient: Gradient, duration: Duration) {
self.fade_out_to(FadeTarget::Gradient(gradient), duration, true).await;
}
pub async fn fade_to_background(self, duration: Duration) {
self.fade_out_to(FadeTarget::Background, duration, false).await;
}
async fn fade_out_to(mut self, target: FadeTarget, duration: Duration, settle: bool) {
self.clear_on_stop.store(false, Ordering::SeqCst);
self.running.store(false, Ordering::SeqCst);
if let Some(h) = self.handle.take() {
let _ = h.await;
}
let last = self.last_rendered.lock().unwrap().clone();
let text = self.text.lock().unwrap().clone();
let mut lines_printed = self.lines_printed.load(Ordering::SeqCst);
let delay = Duration::from_millis(30);
let total_frames = (duration.as_millis() / 30).max(1) as usize;
let easing = Easing::EaseOut;
for frame in 0..=total_frames {
let raw_t = frame as f64 / total_frames as f64;
let eased_t = easing.apply(raw_t);
let opacity = 1.0 - eased_t;
let faded = match &target {
FadeTarget::Background => apply_fade_toward(&last, opacity, crate::terminal::bg_color()),
FadeTarget::Foreground => apply_fade_toward(&last, opacity, crate::terminal::fg_color()),
FadeTarget::Color(c) => apply_fade_toward(&last, opacity, *c),
FadeTarget::Gradient(g) => apply_fade_toward_gradient(&last, opacity, g, &text),
};
let mut buf = String::new();
lines_printed = render_frame(&mut buf, &faded, lines_printed);
{
let mut stderr = std::io::stderr().lock();
let _ = write!(stderr, "{}", buf);
let _ = stderr.flush();
}
if frame < total_frames {
tokio::time::sleep(delay).await;
}
}
if settle {
let mut stderr = std::io::stderr().lock();
let _ = write!(stderr, "\n\x1B[?25h");
let _ = stderr.flush();
} else {
let mut buf = String::new();
if lines_printed > 0 {
buf.push_str(&format!("\x1B[{}F", lines_printed));
}
buf.push_str("\r\x1B[J\x1B[?25h\n");
let mut stderr = std::io::stderr().lock();
let _ = write!(stderr, "{}", buf);
let _ = stderr.flush();
}
}
}
impl Drop for Animation {
fn drop(&mut self) {
self.running.store(false, Ordering::SeqCst);
}
}
fn spawn_animation<F>(text: &str, effect: F, delay_ms: u64, speed: f64) -> Animation
where
F: Fn(&str, usize) -> String + Send + 'static,
{
let running = Arc::new(AtomicBool::new(true));
let text = Arc::new(Mutex::new(text.to_string()));
let frame = Arc::new(AtomicUsize::new(0));
let last_rendered = Arc::new(Mutex::new(String::new()));
let lines_printed = Arc::new(AtomicUsize::new(0));
let clear_on_stop = Arc::new(AtomicBool::new(true));
let r = running.clone();
let t = text.clone();
let f = frame.clone();
let lr = last_rendered.clone();
let lp = lines_printed.clone();
let cs = clear_on_stop.clone();
let delay = Duration::from_millis((delay_ms as f64 / speed) as u64);
let handle = tokio::spawn(async move {
let mut local_lines_printed: usize = 0;
{
let mut stderr = std::io::stderr().lock();
let _ = write!(stderr, "\x1B[?25l");
let _ = stderr.flush();
}
while r.load(Ordering::SeqCst) {
let current_frame = f.fetch_add(1, Ordering::SeqCst);
let current_text = t.lock().unwrap().clone();
let rendered = effect(¤t_text, current_frame);
*lr.lock().unwrap() = rendered.clone();
let rendered = rendered.trim_end_matches('\n');
let rendered_lines: Vec<&str> = rendered.split('\n').collect();
let line_count = rendered_lines.len();
let mut buf = String::new();
if local_lines_printed > 0 {
buf.push_str(&format!("\x1B[{}F", local_lines_printed));
}
for (i, line) in rendered_lines.iter().enumerate() {
buf.push('\r');
buf.push_str(line);
buf.push_str("\x1B[K");
if i < line_count - 1 {
buf.push('\n');
}
}
{
let mut stderr = std::io::stderr().lock();
let _ = write!(stderr, "{}", buf);
let _ = stderr.flush();
}
local_lines_printed = line_count - 1;
lp.store(local_lines_printed, Ordering::SeqCst);
tokio::time::sleep(delay).await;
}
if cs.load(Ordering::SeqCst) {
let mut buf = String::new();
if local_lines_printed > 0 {
buf.push_str(&format!("\x1B[{}F", local_lines_printed));
}
for i in 0..=local_lines_printed {
buf.push_str("\r\x1B[K");
if i < local_lines_printed {
buf.push('\n');
}
}
if local_lines_printed > 0 {
buf.push_str(&format!("\x1B[{}F", local_lines_printed));
}
buf.push_str("\x1B[?25h\n");
let mut stderr = std::io::stderr().lock();
let _ = write!(stderr, "{}", buf);
let _ = stderr.flush();
}
});
Animation {
running,
text,
handle: Some(handle),
frame,
last_rendered,
lines_printed,
clear_on_stop,
}
}
fn render_frame(buf: &mut String, rendered: &str, lines_printed: usize) -> usize {
let rendered = rendered.trim_end_matches('\n');
let rendered_lines: Vec<&str> = rendered.split('\n').collect();
let line_count = rendered_lines.len();
if lines_printed > 0 {
buf.push_str(&format!("\x1B[{}F", lines_printed));
}
for (i, line) in rendered_lines.iter().enumerate() {
buf.push('\r');
buf.push_str(line);
buf.push_str("\x1B[K");
if i < line_count - 1 {
buf.push('\n');
}
}
line_count - 1
}
type BoxEffect = Box<dyn Fn(&str, usize) -> String + Send + 'static>;
pub enum FadeTarget {
Background,
Foreground,
Color(crate::color::Color),
Gradient(Gradient),
}
pub struct TimeRange {
pub start: f64,
pub end: f64,
}
impl TimeRange {
pub fn new(start: f64, end: f64) -> Self {
Self { start, end }
}
pub fn from_duration(start: Duration, end: Duration) -> Self {
Self {
start: start.as_secs_f64(),
end: end.as_secs_f64(),
}
}
fn contains(&self, t: f64) -> bool {
t >= self.start && t < self.end
}
fn progress(&self, t: f64) -> f64 {
let d = self.end - self.start;
if d <= 0.0 {
return 1.0;
}
((t - self.start) / d).clamp(0.0, 1.0)
}
}
struct EffectLayer {
time: TimeRange,
effect: BoxEffect,
delay_ms: u64,
}
pub enum FadeKind {
FadeFrom(FadeTarget),
FadeTo(FadeTarget),
}
struct FadeLayer {
time: TimeRange,
kind: FadeKind,
easing: Easing,
}
pub struct Sequence {
text: String,
effect_layers: Vec<EffectLayer>,
fade_layers: Vec<FadeLayer>,
cursor: f64,
}
impl Sequence {
pub fn new(text: &str) -> Self {
Self {
text: text.to_string(),
effect_layers: Vec::new(),
fade_layers: Vec::new(),
cursor: 0.0,
}
}
fn push_effect(&mut self, effect: BoxEffect, duration: Duration, delay_ms: u64) {
let start = self.cursor;
let end = start + duration.as_secs_f64();
self.effect_layers.push(EffectLayer {
time: TimeRange::new(start, end),
effect,
delay_ms,
});
self.cursor = end;
}
fn last_effect_range(&self) -> Option<(f64, f64)> {
self.effect_layers.last().map(|e| (e.time.start, e.time.end))
}
pub fn fade_in(self, duration: Duration) -> Self {
self.fade_in_color(crate::color::Color::new(255, 255, 255), duration)
}
pub fn fade_in_color(mut self, color: crate::color::Color, duration: Duration) -> Self {
let total = (duration.as_millis() / 30) as usize;
self.push_effect(
Box::new(move |text, frame| effects::fade_in(text, frame, total, color)),
duration,
30,
);
self
}
pub fn fade_out(self, duration: Duration) -> Self {
self.fade_out_color(crate::color::Color::new(255, 255, 255), duration)
}
pub fn fade_out_color(mut self, color: crate::color::Color, duration: Duration) -> Self {
let total = (duration.as_millis() / 30) as usize;
self.push_effect(
Box::new(move |text, frame| effects::fade_out(text, frame, total, color)),
duration,
30,
);
self
}
pub fn glow(mut self, grad: Gradient, duration: Duration) -> Self {
use crate::color::Color;
let palette = grad.palette(3);
let bright = palette[0];
let mid = palette[palette.len() / 2];
let dark = palette[palette.len() - 1];
self.push_effect(
Box::new(move |text, frame| {
use colored::Colorize;
let lines: Vec<&str> = text.split('\n').collect();
let max_col = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0);
if max_col == 0 { return String::new(); }
let glow_width = (max_col as f64 * 0.3).max(4.0);
let period = max_col as f64 + glow_width * 2.0;
let center = (frame as f64 * 0.4) % period - glow_width;
lines.iter().map(|line| {
line.chars().enumerate().map(|(col, ch)| {
let dist = (col as f64 - center).abs();
let t = (dist / glow_width).min(1.0);
let t = t * t * (3.0 - 2.0 * t);
let c = if t < 0.5 {
Color::lerp_rgb(bright, mid, t * 2.0)
} else {
Color::lerp_rgb(mid, dark, (t - 0.5) * 2.0)
};
ch.to_string().truecolor(c.r, c.g, c.b).to_string()
}).collect::<String>()
}).collect::<Vec<_>>().join("\n")
}),
duration,
30,
);
self
}
pub fn rainbow(mut self, duration: Duration) -> Self {
self.push_effect(
Box::new(|text, frame| effects::rainbow(text, frame)),
duration,
15,
);
self
}
pub fn flap(mut self, duration: Duration) -> Self {
use crate::color::Color;
let settled = Color::new(0xff, 0xcc, 0x00);
let flipping = Color::new(0x99, 0x7a, 0x00);
self.push_effect(
Box::new(move |text, frame| effects::flap(text, frame, settled, flipping)),
duration,
60,
);
self
}
pub fn flap_with(mut self, grad: Gradient, duration: Duration) -> Self {
let palette = grad.palette(2);
let settled = palette[0];
let flipping = palette[1];
self.push_effect(
Box::new(move |text, frame| effects::flap(text, frame, settled, flipping)),
duration,
60,
);
self
}
pub fn cycle(mut self, grad: Gradient, duration: Duration) -> Self {
self.push_effect(
Box::new(move |text, frame| {
use colored::Colorize;
let len = text.chars().filter(|c| !c.is_whitespace()).count().max(2);
let palette = grad.palette(len * 2);
let offset = frame % palette.len();
let mut result = String::new();
let mut color_idx = 0;
for ch in text.chars() {
if ch.is_whitespace() {
result.push(ch);
} else {
let c = palette[(color_idx + offset) % palette.len()];
result.push_str(&ch.to_string().truecolor(c.r, c.g, c.b).to_string());
color_idx += 1;
}
}
result
}),
duration,
15,
);
self
}
pub fn with_fade(mut self, fade_in: Duration, fade_out: Duration) -> Self {
if let Some((start, end)) = self.last_effect_range() {
self.fade_layers.retain(|f| {
let is_from_here = matches!(&f.kind, FadeKind::FadeFrom(_))
&& (f.time.start - start).abs() < 0.001;
let is_to_here = matches!(&f.kind, FadeKind::FadeTo(_))
&& (f.time.end - end).abs() < 0.001;
!is_from_here && !is_to_here
});
if fade_in > Duration::ZERO {
self.fade_layers.push(FadeLayer {
time: TimeRange::new(start, start + fade_in.as_secs_f64()),
kind: FadeKind::FadeFrom(FadeTarget::Background),
easing: Easing::Linear,
});
}
if fade_out > Duration::ZERO {
self.fade_layers.push(FadeLayer {
time: TimeRange::new(end - fade_out.as_secs_f64(), end),
kind: FadeKind::FadeTo(FadeTarget::Background),
easing: Easing::Linear,
});
}
}
self
}
pub fn fade_to_foreground(mut self, duration: Duration) -> Self {
if let Some((_, end)) = self.last_effect_range() {
self.fade_layers.retain(|f| {
!(matches!(&f.kind, FadeKind::FadeTo(_)) && (f.time.end - end).abs() < 0.001)
});
self.fade_layers.push(FadeLayer {
time: TimeRange::new(end - duration.as_secs_f64(), end),
kind: FadeKind::FadeTo(FadeTarget::Foreground),
easing: Easing::Linear,
});
}
self
}
pub fn fade_to_color(mut self, color: crate::color::Color, duration: Duration) -> Self {
if let Some((_, end)) = self.last_effect_range() {
self.fade_layers.retain(|f| {
!(matches!(&f.kind, FadeKind::FadeTo(_)) && (f.time.end - end).abs() < 0.001)
});
self.fade_layers.push(FadeLayer {
time: TimeRange::new(end - duration.as_secs_f64(), end),
kind: FadeKind::FadeTo(FadeTarget::Color(color)),
easing: Easing::Linear,
});
}
self
}
pub fn fade_to_gradient(mut self, grad: Gradient, duration: Duration) -> Self {
if let Some((_, end)) = self.last_effect_range() {
self.fade_layers.retain(|f| {
!(matches!(&f.kind, FadeKind::FadeTo(_)) && (f.time.end - end).abs() < 0.001)
});
self.fade_layers.push(FadeLayer {
time: TimeRange::new(end - duration.as_secs_f64(), end),
kind: FadeKind::FadeTo(FadeTarget::Gradient(grad)),
easing: Easing::Linear,
});
}
self
}
pub fn hold(mut self, color: crate::color::Color, duration: Duration) -> Self {
self.push_effect(
Box::new(move |text, _frame| {
use colored::Colorize;
text.split('\n').map(|line| {
line.chars().map(|ch| {
ch.to_string().truecolor(color.r, color.g, color.b).to_string()
}).collect::<String>()
}).collect::<Vec<_>>().join("\n")
}),
duration,
30,
);
self
}
pub fn effect<F>(mut self, time: TimeRange, delay_ms: u64, effect: F) -> Self
where
F: Fn(&str, usize) -> String + Send + 'static,
{
if time.end > self.cursor {
self.cursor = time.end;
}
self.effect_layers.push(EffectLayer {
time,
effect: Box::new(effect),
delay_ms,
});
self
}
pub fn fade(mut self, time: TimeRange, kind: FadeKind, easing: Easing) -> Self {
self.fade_layers.push(FadeLayer {
time,
kind,
easing,
});
self
}
pub fn eased(mut self, easing: Easing) -> Self {
if let Some(fade) = self.fade_layers.last_mut() {
fade.easing = easing;
}
self
}
pub async fn run(self, speed: f64) {
let text = self.text;
{
let mut stderr = std::io::stderr().lock();
let _ = write!(stderr, "\x1B[?25l");
let _ = stderr.flush();
}
let total_duration = self.effect_layers.iter()
.map(|l| l.time.end)
.chain(self.fade_layers.iter().map(|l| l.time.end))
.fold(0.0f64, f64::max);
let mut lines_printed: usize = 0;
let mut t = 0.0;
while t < total_duration {
let active = self.effect_layers.iter()
.rev()
.find(|l| l.time.contains(t));
let Some(active) = active else {
t += 0.030;
continue;
};
let delay_ms = active.delay_ms;
let frame = ((t - active.time.start) * 1000.0 / delay_ms as f64) as usize;
let rendered = (active.effect)(&text, frame);
let active_from = self.fade_layers.iter()
.find(|f| f.time.contains(t) && matches!(f.kind, FadeKind::FadeFrom(_)));
let active_to = self.fade_layers.iter()
.find(|f| f.time.contains(t) && matches!(f.kind, FadeKind::FadeTo(_)));
let final_rendered = if let Some(fade) = active_from {
let raw_t = fade.time.progress(t);
let eased_t = fade.easing.apply(raw_t);
apply_fade(&rendered, eased_t, &fade.kind, &text)
} else if let Some(fade) = active_to {
let raw_t = fade.time.progress(t);
let eased_t = fade.easing.apply(raw_t);
apply_fade(&rendered, eased_t, &fade.kind, &text)
} else {
rendered
};
let mut buf = String::new();
lines_printed = render_frame(&mut buf, &final_rendered, lines_printed);
{
let mut stderr = std::io::stderr().lock();
let _ = write!(stderr, "{}", buf);
let _ = stderr.flush();
}
let delay = Duration::from_millis((delay_ms as f64 / speed) as u64);
tokio::time::sleep(delay).await;
t += delay_ms as f64 / 1000.0;
}
let settled = self.fade_layers.iter()
.filter(|f| (f.time.end - total_duration).abs() < 0.001)
.any(|f| matches!(&f.kind, FadeKind::FadeTo(target) if !matches!(target, FadeTarget::Background)));
if settled {
let mut buf = String::new();
buf.push_str("\n\x1B[?25h");
let mut stderr = std::io::stderr().lock();
let _ = write!(stderr, "{}", buf);
let _ = stderr.flush();
} else {
let mut buf = String::new();
if lines_printed > 0 {
buf.push_str(&format!("\x1B[{}F", lines_printed));
}
buf.push_str("\r\x1B[J\x1B[?25h");
let mut stderr = std::io::stderr().lock();
let _ = write!(stderr, "{}", buf);
let _ = stderr.flush();
}
}
}
fn apply_fade_toward(s: &str, opacity: f64, target: crate::color::Color) -> String {
let bg = target;
let mut result = String::with_capacity(s.len());
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == 0x1B && i + 1 < bytes.len() && bytes[i + 1] == b'[' {
let start = i;
i += 2;
let seq_start = i;
while i < bytes.len() && bytes[i] != b'm' {
i += 1;
}
if i < bytes.len() {
let seq = &s[seq_start..i];
if seq.starts_with("38;2;") {
let parts: Vec<&str> = seq[5..].split(';').collect();
if parts.len() == 3 {
if let (Ok(r), Ok(g), Ok(b)) = (
parts[0].parse::<u8>(),
parts[1].parse::<u8>(),
parts[2].parse::<u8>(),
) {
let c = crate::color::Color::lerp_rgb(
bg,
crate::color::Color::new(r, g, b),
opacity,
);
result.push_str(&format!("\x1B[38;2;{};{};{}m", c.r, c.g, c.b));
i += 1;
continue;
}
}
}
result.push_str(&s[start..=i]);
i += 1;
}
} else {
let byte = bytes[i];
let char_len = if byte < 0x80 { 1 }
else if byte < 0xE0 { 2 }
else if byte < 0xF0 { 3 }
else { 4 };
let end = (i + char_len).min(bytes.len());
result.push_str(&s[i..end]);
i = end;
}
}
result
}
fn apply_fade_toward_gradient(s: &str, opacity: f64, grad: &Gradient, text: &str) -> String {
let char_count = text.chars().filter(|c| *c != '\n').count().max(2);
let palette = grad.palette(char_count);
let mut result = String::with_capacity(s.len());
let bytes = s.as_bytes();
let mut i = 0;
let mut color_idx: usize = 0;
while i < bytes.len() {
if bytes[i] == 0x1B && i + 1 < bytes.len() && bytes[i + 1] == b'[' {
let start = i;
i += 2;
let seq_start = i;
while i < bytes.len() && bytes[i] != b'm' {
i += 1;
}
if i < bytes.len() {
let seq = &s[seq_start..i];
if seq.starts_with("38;2;") {
let parts: Vec<&str> = seq[5..].split(';').collect();
if parts.len() == 3 {
if let (Ok(r), Ok(g), Ok(b)) = (
parts[0].parse::<u8>(),
parts[1].parse::<u8>(),
parts[2].parse::<u8>(),
) {
let target = palette[color_idx.min(palette.len() - 1)];
color_idx += 1;
let c = crate::color::Color::lerp_rgb(
target,
crate::color::Color::new(r, g, b),
opacity,
);
result.push_str(&format!("\x1B[38;2;{};{};{}m", c.r, c.g, c.b));
i += 1;
continue;
}
}
}
result.push_str(&s[start..=i]);
i += 1;
}
} else {
let byte = bytes[i];
let char_len = if byte < 0x80 { 1 }
else if byte < 0xE0 { 2 }
else if byte < 0xF0 { 3 }
else { 4 };
let end = (i + char_len).min(bytes.len());
result.push_str(&s[i..end]);
i = end;
}
}
result
}
fn apply_fade(rendered: &str, progress: f64, kind: &FadeKind, text: &str) -> String {
match kind {
FadeKind::FadeFrom(target) => {
let opacity = progress;
match target {
FadeTarget::Background => apply_fade_toward(rendered, opacity, crate::terminal::bg_color()),
FadeTarget::Foreground => apply_fade_toward(rendered, opacity, crate::terminal::fg_color()),
FadeTarget::Color(c) => apply_fade_toward(rendered, opacity, *c),
FadeTarget::Gradient(g) => apply_fade_toward_gradient(rendered, opacity, g, text),
}
}
FadeKind::FadeTo(target) => {
let opacity = 1.0 - progress;
match target {
FadeTarget::Background => apply_fade_toward(rendered, opacity, crate::terminal::bg_color()),
FadeTarget::Foreground => apply_fade_toward(rendered, opacity, crate::terminal::fg_color()),
FadeTarget::Color(c) => apply_fade_toward(rendered, opacity, *c),
FadeTarget::Gradient(g) => apply_fade_toward_gradient(rendered, opacity, g, text),
}
}
}
}
pub fn rainbow_effect() -> impl Fn(&str, usize) -> String + Send + 'static {
|text, frame| effects::rainbow(text, frame)
}
pub fn glow_effect(grad: Gradient) -> impl Fn(&str, usize) -> String + Send + 'static {
use crate::color::Color;
let palette = grad.palette(3);
let bright = palette[0];
let mid = palette[palette.len() / 2];
let dark = palette[palette.len() - 1];
move |text, frame| {
use colored::Colorize;
let lines: Vec<&str> = text.split('\n').collect();
let max_col = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0);
if max_col == 0 { return String::new(); }
let glow_width = (max_col as f64 * 0.3).max(4.0);
let period = max_col as f64 + glow_width * 2.0;
let center = (frame as f64 * 0.4) % period - glow_width;
lines.iter().map(|line| {
line.chars().enumerate().map(|(col, ch)| {
let dist = (col as f64 - center).abs();
let t = (dist / glow_width).min(1.0);
let t = t * t * (3.0 - 2.0 * t);
let c = if t < 0.5 {
Color::lerp_rgb(bright, mid, t * 2.0)
} else {
Color::lerp_rgb(mid, dark, (t - 0.5) * 2.0)
};
ch.to_string().truecolor(c.r, c.g, c.b).to_string()
}).collect::<String>()
}).collect::<Vec<_>>().join("\n")
}
}
pub fn cycle_effect(grad: Gradient) -> impl Fn(&str, usize) -> String + Send + 'static {
move |text, frame| {
use colored::Colorize;
let len = text.chars().filter(|c| !c.is_whitespace()).count().max(2);
let palette = grad.palette(len * 2);
let offset = frame % palette.len();
let mut result = String::new();
let mut color_idx = 0;
for ch in text.chars() {
if ch.is_whitespace() {
result.push(ch);
} else {
let c = palette[(color_idx + offset) % palette.len()];
result.push_str(&ch.to_string().truecolor(c.r, c.g, c.b).to_string());
color_idx += 1;
}
}
result
}
}
pub fn flap_effect(settled: crate::color::Color, flipping: crate::color::Color) -> impl Fn(&str, usize) -> String + Send + 'static {
move |text, frame| effects::flap(text, frame, settled, flipping)
}
pub fn rainbow(text: &str, speed: f64) -> Animation {
spawn_animation(text, effects::rainbow, 15, speed)
}
pub fn pulse(text: &str, speed: f64) -> Animation {
spawn_animation(text, effects::pulse, 16, speed)
}
pub fn glitch(text: &str, speed: f64) -> Animation {
spawn_animation(text, effects::glitch, 55, speed)
}
pub fn radar(text: &str, speed: f64) -> Animation {
spawn_animation(text, effects::radar, 50, speed)
}
pub fn neon(text: &str, speed: f64) -> Animation {
spawn_animation(text, effects::neon, 500, speed)
}
pub fn karaoke(text: &str, speed: f64) -> Animation {
spawn_animation(text, effects::karaoke, 50, speed)
}
pub fn glow(grad: Gradient, text: &str, speed: f64) -> Animation {
spawn_animation(
text,
move |text, frame| {
use colored::Colorize;
use crate::color::Color;
let palette = grad.palette(3);
let bright = palette[0];
let mid = palette[palette.len() / 2];
let dark = palette[palette.len() - 1];
let lines: Vec<&str> = text.split('\n').collect();
let max_col = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0);
if max_col == 0 {
return String::new();
}
let glow_width = (max_col as f64 * 0.3).max(4.0);
let period = max_col as f64 + glow_width * 2.0;
let center = (frame as f64 * 0.4) % period - glow_width;
lines.iter().map(|line| {
line.chars().enumerate().map(|(col, ch)| {
let dist = (col as f64 - center).abs();
let t = (dist / glow_width).min(1.0);
let t = t * t * (3.0 - 2.0 * t);
let c = if t < 0.5 {
Color::lerp_rgb(bright, mid, t * 2.0)
} else {
Color::lerp_rgb(mid, dark, (t - 0.5) * 2.0)
};
ch.to_string().truecolor(c.r, c.g, c.b).to_string()
}).collect::<String>()
}).collect::<Vec<_>>().join("\n")
},
30,
speed,
)
}
pub fn flap(text: &str, speed: f64) -> Animation {
use crate::color::Color;
let settled = Color::new(0xff, 0xcc, 0x00); let flipping = Color::new(0x99, 0x7a, 0x00); spawn_animation(
text,
move |text, frame| effects::flap(text, frame, settled, flipping),
60,
speed,
)
}
pub fn flap_with(grad: Gradient, text: &str, speed: f64) -> Animation {
let palette = grad.palette(2);
let settled = palette[0];
let flipping = palette[1];
spawn_animation(
text,
move |text, frame| effects::flap(text, frame, settled, flipping),
60,
speed,
)
}
pub fn cycle(grad: Gradient, text: &str, speed: f64) -> Animation {
spawn_animation(
text,
move |text, frame| {
use colored::Colorize;
let len = text.chars().filter(|c| !c.is_whitespace()).count().max(2);
let palette = grad.palette(len * 2);
let offset = frame % palette.len();
let mut result = String::new();
let mut color_idx = 0;
for ch in text.chars() {
if ch.is_whitespace() {
result.push(ch);
} else {
let c = palette[(color_idx + offset) % palette.len()];
result.push_str(&ch.to_string().truecolor(c.r, c.g, c.b).to_string());
color_idx += 1;
}
}
result
},
15,
speed,
)
}