use rand::rngs::SmallRng;
use rand::{Rng, SeedableRng};
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Style},
widgets::Widget,
};
use crate::tui::theme::solarized;
const NIKA_ASCII: &[&str] = &[
"███╗ ██╗██╗██╗ ██╗ █████╗ ",
"████╗ ██║██║██║ ██╔╝██╔══██╗",
"██╔██╗ ██║██║█████╔╝ ███████║",
"██║╚██╗██║██║██╔═██╗ ██╔══██║",
"██║ ╚████║██║██║ ██╗██║ ██║",
"╚═╝ ╚═══╝╚═╝╚═╝ ╚═╝╚═╝ ╚═╝",
];
const NIKA_SMALL: &[&str] = &["╔╗╔ ╦ ╦╔═ ╔═╗", "║║║ ║ ╠╩╗ ╠═╣", "╝╚╝ ╩ ╩ ╩ ╩ ╩"];
const EXPLOSION_CHARS: &[char] = &[
'█', '▓', '▒', '░', '╬', '╫', '╪', '┼', '╋', '┃', '━', '┏', '┓', '┗', '┛', '◆', '◇', '○', '●',
'◐', '◑', '◒', '◓', '★', '☆', '✦', '✧',
];
const EXPLOSION_COLORS: &[Color] = &[
solarized::CYAN,
solarized::GREEN,
solarized::BLUE,
solarized::VIOLET,
solarized::MAGENTA,
solarized::YELLOW,
solarized::ORANGE,
];
#[derive(Clone)]
struct Particle {
x: f32,
y: f32,
vx: f32,
vy: f32,
char: char,
color: Color,
life: f32, }
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum IntroPhase {
FadeIn,
Hold,
Explode,
Rain,
Done,
}
pub struct NikaIntroState {
pub phase: IntroPhase,
pub frame: u16,
pub opacity: f32,
particles: Vec<Particle>,
rng: SmallRng,
}
impl Default for NikaIntroState {
fn default() -> Self {
Self::new()
}
}
impl NikaIntroState {
pub fn new() -> Self {
Self {
phase: IntroPhase::FadeIn,
frame: 0,
opacity: 0.0,
particles: Vec::new(),
rng: SmallRng::seed_from_u64(42),
}
}
pub fn is_done(&self) -> bool {
self.phase == IntroPhase::Done
}
pub fn tick(&mut self, area: Rect) {
self.frame = self.frame.wrapping_add(1);
match self.phase {
IntroPhase::FadeIn => {
self.opacity += 0.08; if self.opacity >= 1.0 {
self.opacity = 1.0;
self.phase = IntroPhase::Hold;
self.frame = 0; }
}
IntroPhase::Hold => {
if self.frame > 5 {
self.phase = IntroPhase::Explode;
self.frame = 0; self.spawn_particles(area);
}
}
IntroPhase::Explode => {
self.update_particles();
if self.frame > 15 {
self.phase = IntroPhase::Rain;
self.frame = 0; }
}
IntroPhase::Rain => {
self.update_particles();
self.opacity -= 0.04; if self.opacity <= 0.0 || self.frame > 50 {
self.phase = IntroPhase::Done;
}
}
IntroPhase::Done => {}
}
}
fn spawn_particles(&mut self, area: Rect) {
let logo = NIKA_ASCII;
let logo_width = logo[0].chars().count() as u16;
let logo_height = logo.len() as u16;
let start_x = (area.width.saturating_sub(logo_width)) / 2;
let start_y = (area.height.saturating_sub(logo_height)) / 2;
for (row, line) in logo.iter().enumerate() {
for (col, ch) in line.chars().enumerate() {
if ch != ' ' && ch != '╝' && ch != '╚' && ch != '═' {
let x = start_x as f32 + col as f32;
let y = start_y as f32 + row as f32;
let angle: f32 = self.rng.gen_range(0.0..std::f32::consts::TAU);
let speed: f32 = self.rng.gen_range(0.5..2.5);
let vx = angle.cos() * speed;
let vy = angle.sin() * speed + 0.5;
let color_idx = self.rng.gen_range(0..EXPLOSION_COLORS.len());
let char_idx = self.rng.gen_range(0..EXPLOSION_CHARS.len());
self.particles.push(Particle {
x,
y,
vx,
vy,
char: EXPLOSION_CHARS[char_idx],
color: EXPLOSION_COLORS[color_idx],
life: 1.0,
});
}
}
}
}
fn update_particles(&mut self) {
for p in &mut self.particles {
p.x += p.vx;
p.y += p.vy;
p.vy += 0.15; p.vx *= 0.98; p.life -= 0.02;
if self.rng.gen::<f32>() < 0.1 {
let idx = self.rng.gen_range(0..EXPLOSION_CHARS.len());
p.char = EXPLOSION_CHARS[idx];
}
}
self.particles.retain(|p| p.life > 0.0);
}
}
pub struct NikaIntro<'a> {
state: &'a NikaIntroState,
}
impl<'a> NikaIntro<'a> {
pub fn new(state: &'a NikaIntroState) -> Self {
Self { state }
}
}
impl Widget for NikaIntro<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.width == 0 || area.height == 0 {
return;
}
let bg_style = Style::default().bg(solarized::BASE03);
buf.set_style(area, bg_style);
match self.state.phase {
IntroPhase::FadeIn | IntroPhase::Hold => {
let logo = if area.width >= 40 {
NIKA_ASCII
} else {
NIKA_SMALL
};
let logo_width = logo[0].chars().count() as u16;
let logo_height = logo.len() as u16;
let start_x = area.x + (area.width.saturating_sub(logo_width)) / 2;
let start_y = area.y + (area.height.saturating_sub(logo_height)) / 2;
let opacity = self.state.opacity;
let color = apply_opacity(solarized::CYAN, opacity);
for (row, line) in logo.iter().enumerate() {
let y = start_y + row as u16;
if y < area.y + area.height {
buf.set_string(start_x, y, line, Style::default().fg(color));
}
}
if opacity > 0.5 {
let butterfly = "🦋";
if start_x > 4 && start_y > 1 {
buf.set_string(
start_x - 4,
start_y - 1,
butterfly,
Style::default().fg(solarized::MAGENTA),
);
}
if start_x + logo_width + 2 < area.x + area.width {
buf.set_string(
start_x + logo_width + 2,
start_y - 1,
butterfly,
Style::default().fg(solarized::VIOLET),
);
}
if start_y + logo_height < area.y + area.height {
buf.set_string(
start_x + logo_width / 2,
start_y + logo_height,
butterfly,
Style::default().fg(solarized::CYAN),
);
}
}
}
IntroPhase::Explode | IntroPhase::Rain => {
for p in &self.state.particles {
let x = p.x as u16;
let y = p.y as u16;
if x >= area.x
&& x < area.x + area.width
&& y >= area.y
&& y < area.y + area.height
{
let color = apply_opacity(p.color, p.life * self.state.opacity);
buf.set_string(x, y, p.char.to_string(), Style::default().fg(color));
}
}
}
IntroPhase::Done => {}
}
}
}
fn apply_opacity(color: Color, opacity: f32) -> Color {
match color {
Color::Rgb(r, g, b) => Color::Rgb(
(r as f32 * opacity) as u8,
(g as f32 * opacity) as u8,
(b as f32 * opacity) as u8,
),
c => c,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_intro_state_new() {
let state = NikaIntroState::new();
assert_eq!(state.phase, IntroPhase::FadeIn);
assert_eq!(state.frame, 0);
assert!((state.opacity - 0.0).abs() < 0.01);
}
#[test]
fn test_intro_phase_progression() {
let mut state = NikaIntroState::new();
let area = Rect::new(0, 0, 80, 24);
for _ in 0..13 {
state.tick(area);
}
assert_eq!(state.phase, IntroPhase::Hold);
for _ in 0..6 {
state.tick(area);
}
assert_eq!(state.phase, IntroPhase::Explode);
}
#[test]
fn test_intro_is_done() {
let mut state = NikaIntroState::new();
let area = Rect::new(0, 0, 80, 24);
assert!(!state.is_done());
for _ in 0..100 {
state.tick(area);
}
assert!(state.is_done());
}
#[test]
fn test_particle_spawning() {
let mut state = NikaIntroState::new();
let area = Rect::new(0, 0, 80, 24);
state.phase = IntroPhase::Hold;
state.frame = 13;
state.tick(area);
assert!(!state.particles.is_empty());
}
#[test]
fn test_render_empty_area() {
let state = NikaIntroState::new();
let intro = NikaIntro::new(&state);
let area = Rect::new(0, 0, 0, 0);
let mut buf = Buffer::empty(area);
intro.render(area, &mut buf);
}
}