use std::time::Duration;
use figlet_rs::FIGlet;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Paragraph, Widget};
use super::super::theme::{lerp_rgb, scale_rgb, Knobs, Palette};
const FLICKER_DIM: f32 = 0.6;
const FLICKER_PERIOD_MS: u64 = 9000;
const FLICKER_WINDOW_MS: u64 = 40;
#[derive(Clone, Copy)]
struct Cell {
ch: char,
glyph_idx: usize,
}
pub struct Banner {
grid: Vec<Vec<Option<Cell>>>,
width: usize,
height: usize,
eye_glyphs: [Option<usize>; 2],
}
impl Banner {
pub fn from_text(text: &str, font: &FIGlet) -> Self {
let mut glyph_lines: Vec<Vec<String>> = text
.chars()
.map(|ch| {
let fig = font
.convert(&ch.to_string())
.unwrap_or_else(|| panic!("figlet failed to render {:?}", ch));
trim_blank_rows(fig.to_string().lines().map(str::to_string).collect())
})
.collect();
let height = glyph_lines.iter().map(Vec::len).max().unwrap_or(0);
for lines in &mut glyph_lines {
while lines.len() < height {
lines.push(String::new());
}
}
let widths: Vec<usize> = glyph_lines
.iter()
.map(|lines| lines.iter().map(|l| l.chars().count()).max().unwrap_or(0))
.collect();
let total_width: usize = widths.iter().sum();
let mut grid: Vec<Vec<Option<Cell>>> = vec![vec![None; total_width]; height];
let mut eye_glyphs: [Option<usize>; 2] = [None, None];
let mut eye_cursor = 0;
let mut x_cursor = 0;
for (gi, (lines, ch)) in glyph_lines.iter().zip(text.chars()).enumerate() {
if ch == 'o' && eye_cursor < eye_glyphs.len() {
eye_glyphs[eye_cursor] = Some(gi);
eye_cursor += 1;
}
for (y, line) in lines.iter().enumerate() {
for (x, c) in line.chars().enumerate() {
if !c.is_whitespace() {
let gx = x_cursor + x;
if gx < total_width {
grid[y][gx] = Some(Cell { ch: c, glyph_idx: gi });
}
}
}
}
x_cursor += widths[gi];
}
Self { grid, width: total_width, height, eye_glyphs }
}
pub fn width(&self) -> u16 {
self.width as u16
}
pub fn height(&self) -> u16 {
self.height as u16
}
}
fn trim_blank_rows(mut lines: Vec<String>) -> Vec<String> {
while lines.last().is_some_and(|l| l.trim().is_empty()) {
lines.pop();
}
while lines.first().is_some_and(|l| l.trim().is_empty()) {
lines.remove(0);
}
lines
}
pub struct Splash<'a> {
banner: &'a Banner,
tagline: &'a str,
pulse: bool,
scanline: bool,
flicker: bool,
elapsed: Duration,
palette: &'a Palette,
knobs: &'a Knobs,
}
impl<'a> Splash<'a> {
pub fn new(banner: &'a Banner, palette: &'a Palette, knobs: &'a Knobs) -> Self {
Self {
banner,
tagline: "beautiful terminal UI components",
pulse: true,
scanline: true,
flicker: true,
elapsed: Duration::ZERO,
palette,
knobs,
}
}
pub fn tagline(mut self, tagline: &'a str) -> Self {
self.tagline = tagline;
self
}
pub fn pulse(mut self, pulse: bool) -> Self {
self.pulse = pulse;
self
}
pub fn scanline(mut self, scanline: bool) -> Self {
self.scanline = scanline;
self
}
pub fn flicker(mut self, flicker: bool) -> Self {
self.flicker = flicker;
self
}
pub fn elapsed(mut self, elapsed: Duration) -> Self {
self.elapsed = elapsed;
self
}
fn banner_lines(&self) -> Vec<Line<'static>> {
let t = self.elapsed.as_secs_f32();
let banner = self.banner;
let knobs = self.knobs;
let palette = self.palette;
let scanline_on = self.scanline && knobs.scanline && banner.height > 0;
let scanline_row = if scanline_on {
Some(((t * knobs.scanline_speed_rows_per_sec) as usize) % banner.height)
} else {
None
};
let flicker_on = self.flicker
&& knobs.idle_flicker
&& (self.elapsed.as_millis() as u64 % FLICKER_PERIOD_MS) < FLICKER_WINDOW_MS;
let pulse_amp = if self.pulse { knobs.pulse_amplitude } else { 0.0 };
let period = knobs.gradient_period_secs.max(0.5);
let width = banner.width.max(1) as f32;
let g_start = color_rgb(palette.secondary);
let g_mid = color_rgb(palette.primary);
let g_end = color_rgb(palette.bright);
banner
.grid
.iter()
.enumerate()
.map(|(y, row)| {
let spans: Vec<Span<'static>> = row
.iter()
.enumerate()
.map(|(x, cell)| match cell {
None => Span::raw(" "),
Some(c) => {
let phase = x as f32 / width + t / period;
let mut rgb = tri_gradient(g_start, g_mid, g_end, phase);
let mut brightness =
pulse_factor(c.glyph_idx, banner.eye_glyphs, t, pulse_amp);
if flicker_on {
brightness *= FLICKER_DIM;
}
if scanline_row == Some(y) {
brightness *= knobs.scanline_boost;
}
rgb = scale_rgb(rgb, brightness);
Span::styled(
c.ch.to_string(),
Style::default()
.fg(Color::Rgb(rgb.0, rgb.1, rgb.2))
.bg(palette.bg),
)
}
})
.collect();
Line::from(spans)
})
.collect()
}
}
fn pulse_factor(glyph_idx: usize, eyes: [Option<usize>; 2], t: f32, amp: f32) -> f32 {
use std::f32::consts::TAU;
if amp == 0.0 {
return 1.0;
}
if eyes[0] == Some(glyph_idx) {
1.0 + amp * (t * TAU / 2.3).sin()
} else if eyes[1] == Some(glyph_idx) {
1.0 + amp * (t * TAU / 2.9 + 1.0).sin()
} else {
1.0
}
}
fn color_rgb(c: Color) -> (u8, u8, u8) {
match c {
Color::Rgb(r, g, b) => (r, g, b),
_ => (255, 255, 255),
}
}
fn tri_gradient(a: (u8, u8, u8), b: (u8, u8, u8), c: (u8, u8, u8), phase: f32) -> (u8, u8, u8) {
let p = phase.rem_euclid(1.0);
let tri = if p < 0.5 { p * 2.0 } else { 2.0 - p * 2.0 };
if tri < 0.5 {
lerp_rgb(a, b, tri * 2.0)
} else {
lerp_rgb(b, c, (tri - 0.5) * 2.0)
}
}
impl Widget for Splash<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.height == 0 || area.width == 0 {
return;
}
let palette = self.palette;
let banner_w = self.banner.width();
let banner_h = self.banner.height();
let tag_w = self.tagline.chars().count() as u16;
let total_h = banner_h + if tag_w > 0 { 2 } else { 0 };
let vpad = area.height.saturating_sub(total_h) / 2;
let hpad = area.width.saturating_sub(banner_w) / 2;
let banner_area = Rect {
x: area.x + hpad,
y: area.y + vpad,
width: banner_w.min(area.width.saturating_sub(hpad)),
height: banner_h.min(area.height.saturating_sub(vpad)),
};
let lines = self.banner_lines();
Paragraph::new(lines).render(banner_area, buf);
if tag_w > 0 {
let tag_y = banner_area.y + banner_area.height + 1;
if tag_y < area.y + area.height {
let tag_hpad = area.width.saturating_sub(tag_w) / 2;
let tag_area = Rect {
x: area.x + tag_hpad,
y: tag_y,
width: tag_w.min(area.width.saturating_sub(tag_hpad)),
height: 1,
};
let tag = Paragraph::new(Line::from(Span::styled(
self.tagline.to_string(),
Style::default().fg(palette.dim).bg(palette.bg),
)));
tag.render(tag_area, buf);
}
}
}
}