use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Style};
use std::time::Duration;
use tachyonfx::{ColorSpace, Interpolation, Motion, fx};
pub const PULSE_PERIOD: Duration = Duration::from_millis(1200);
pub const FLASH: Duration = Duration::from_millis(450);
pub const FRAME: Duration = Duration::from_millis(33);
pub const DASHBOARD_FRAME: Duration = Duration::from_millis(100);
pub fn frame_for(tab: usize) -> Duration {
match tab {
0 => DASHBOARD_FRAME,
_ => FRAME,
}
}
pub fn pulse_level(elapsed: Duration) -> f32 {
let period = PULSE_PERIOD.as_millis();
let t = (elapsed.as_millis() % period) as f32 / period as f32;
let triangle = 1.0 - (2.0 * t - 1.0).abs();
Interpolation::SineInOut.alpha(triangle)
}
pub fn to_rgb(color: Color) -> Option<(u8, u8, u8)> {
match color {
Color::Rgb(r, g, b) => Some((r, g, b)),
Color::Indexed(i @ 16..=231) => {
let i = i - 16;
Some((level(i / 36), level((i / 6) % 6), level(i % 6)))
}
Color::Indexed(i @ 232..=255) => {
let grey = 8 + (i - 232) * 10;
Some((grey, grey, grey))
}
_ => None,
}
}
fn level(step: u8) -> u8 {
match step {
0 => 0,
n => 55 + 40 * n,
}
}
pub fn nearest_indexed((r, g, b): (u8, u8, u8)) -> u8 {
let distance = |(x, y, z): (u8, u8, u8)| {
let d = |a: u8, b: u8| (i32::from(a) - i32::from(b)).pow(2);
d(x, r) + d(y, g) + d(z, b)
};
let step = |v: u8| (0..6u8).min_by_key(|&n| (i32::from(level(n)) - i32::from(v)).abs());
let (sr, sg, sb) = (
step(r).unwrap_or(0),
step(g).unwrap_or(0),
step(b).unwrap_or(0),
);
let cube = 16 + 36 * sr + 6 * sg + sb;
let grey = (0..24u8)
.min_by_key(|&n| distance((8 + n * 10, 8 + n * 10, 8 + n * 10)))
.map_or(232, |n| 232 + n);
let rgb = |i: u8| to_rgb(Color::Indexed(i)).unwrap_or((0, 0, 0));
match distance(rgb(grey)) < distance(rgb(cube)) {
true => grey,
false => cube,
}
}
pub fn mix(from: Color, to: Color, level: f32, truecolor: bool) -> Option<Color> {
let ((r0, g0, b0), (r1, g1, b1)) = (to_rgb(from)?, to_rgb(to)?);
let mixed = ColorSpace::Rgb.lerp(
&Color::Rgb(r0, g0, b0),
&Color::Rgb(r1, g1, b1),
level.clamp(0.0, 1.0),
);
Some(match (truecolor, mixed) {
(true, rgb) => rgb,
(false, Color::Rgb(r, g, b)) => Color::Indexed(nearest_indexed((r, g, b))),
(false, other) => other,
})
}
pub fn pulse(rest: Style, lit: Style, ground: Color, level: f32, truecolor: bool) -> Option<Style> {
let from = rest.bg.filter(|bg| *bg != Color::Reset).unwrap_or(ground);
let to = lit.bg?;
let mixed = mix(from, to, level, truecolor)?;
Some(match level {
l if l < 1.0 / 16.0 => rest,
l if l > 15.0 / 16.0 => lit,
l if l < 0.5 => rest.bg(mixed),
_ => lit.bg(mixed),
})
}
pub fn restart_sweep(
buf: &mut Buffer,
area: Rect,
since: Duration,
color: Color,
ground: Color,
truecolor: bool,
) {
if since >= FLASH {
return;
}
let area = area.intersection(buf.area);
each(buf, area, |cell| {
if cell.bg == Color::Reset {
cell.bg = ground;
}
});
let mut sweep = fx::sweep_in(
Motion::LeftToRight,
area.width.max(4),
0,
color,
(FLASH.as_millis() as u32, Interpolation::QuadOut),
)
.with_color_space(ColorSpace::Rgb);
sweep.process(since.into(), buf, area);
if !truecolor {
each(buf, area, |cell| {
if let Color::Rgb(r, g, b) = cell.fg {
cell.fg = Color::Indexed(nearest_indexed((r, g, b)));
}
if let Color::Rgb(r, g, b) = cell.bg {
cell.bg = Color::Indexed(nearest_indexed((r, g, b)));
}
});
}
}
fn each(buf: &mut Buffer, area: Rect, mut f: impl FnMut(&mut ratatui::buffer::Cell)) {
for position in area.positions() {
if let Some(cell) = buf.cell_mut(position) {
f(cell);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::style::Modifier;
#[test]
fn a_fixed_index_round_trips_through_rgb() {
for i in 16..=255u8 {
let rgb = to_rgb(Color::Indexed(i)).expect("a fixed index has an RGB");
let back = nearest_indexed(rgb);
if back != i {
let (r, g, b) = rgb;
assert!(r == g && g == b, "{i} came back as {back}");
let there = to_rgb(Color::Indexed(back)).unwrap();
let d = |(x, y, z): (u8, u8, u8)| {
(i32::from(x) - i32::from(r)).abs()
+ (i32::from(y) - i32::from(g)).abs()
+ (i32::from(z) - i32::from(b)).abs()
};
assert!(d(there) <= d(rgb), "{i} came back as the farther {back}");
}
}
assert_eq!(to_rgb(Color::Indexed(203)), Some((255, 95, 95)));
assert_eq!(to_rgb(Color::Indexed(232)), Some((8, 8, 8)));
assert_eq!(to_rgb(Color::Indexed(255)), Some((238, 238, 238)));
}
#[test]
fn a_themed_colour_has_no_rgb() {
for color in [Color::Reset, Color::Black, Color::White, Color::Indexed(7)] {
assert_eq!(to_rgb(color), None, "{color:?}");
}
assert_eq!(mix(Color::White, Color::Indexed(203), 0.5, true), None);
let lit = Style::default().bg(Color::Indexed(7));
assert_eq!(
pulse(Style::default(), lit, Color::Indexed(234), 0.5, true),
None
);
}
#[test]
fn the_pulse_breathes_rest_to_alert_and_back() {
let at = |ms: u64| pulse_level(Duration::from_millis(ms));
assert!(at(0) < 0.01);
assert!((at(300) - 0.5).abs() < 0.01, "{}", at(300));
assert!(at(600) > 0.99);
assert!((at(900) - 0.5).abs() < 0.01);
assert!(at(1200) < 0.01, "a period on is not back at rest");
assert!(at(100) - at(0) < at(350) - at(250));
}
#[test]
fn a_painted_pulse_eases_its_ground_and_swaps_its_ink_halfway() {
let rest = Style::default()
.bg(Color::Indexed(97))
.fg(Color::Indexed(254))
.add_modifier(Modifier::BOLD);
let lit = Style::default()
.bg(Color::Indexed(135))
.fg(Color::Black)
.add_modifier(Modifier::BOLD);
let ground = Color::Indexed(234);
assert_eq!(pulse(rest, lit, ground, 0.0, true), Some(rest));
assert_eq!(pulse(rest, lit, ground, 1.0, true), Some(lit));
let quarter = pulse(rest, lit, ground, 0.25, true).unwrap();
assert_eq!(quarter.fg, rest.fg, "the ink swapped before halfway");
let Some(Color::Rgb(r, g, b)) = quarter.bg else {
panic!("truecolor was not used: {quarter:?}");
};
assert!((135..175).contains(&r) && g == 95 && (175..255).contains(&b));
assert!(quarter.add_modifier.contains(Modifier::BOLD));
let three = pulse(rest, lit, ground, 0.75, true).unwrap();
assert_eq!(three.fg, lit.fg, "the ink had not swapped past halfway");
let snapped = pulse(rest, lit, ground, 0.5, false).unwrap();
let Some(Color::Indexed(i)) = snapped.bg else {
panic!("an RGB colour reached a 256-colour terminal: {snapped:?}");
};
assert!(
[97, 98, 134, 135].contains(&i),
"the midpoint snapped to {i}"
);
}
#[test]
fn an_unpainted_pulse_eases_from_the_ground() {
let rest = Style::default().fg(Color::Indexed(221));
let lit = Style::default().bg(Color::Indexed(221)).fg(Color::Black);
let mid = pulse(rest, lit, Color::Indexed(234), 0.5, false).unwrap();
let Some(Color::Indexed(i)) = mid.bg else {
panic!("{mid:?}");
};
assert_ne!(i, 221, "the midpoint is already the full amber");
assert_ne!(i, 234, "the midpoint is still the ground");
assert_eq!(
pulse(rest, lit, Color::Indexed(234), 0.0, false),
Some(rest)
);
}
#[test]
fn the_restart_sweep_washes_in_and_leaves_the_tab_as_drawn() {
let area = Rect::new(0, 0, 20, 1);
let tab = Rect::new(4, 0, 8, 1);
let drawn = || {
let mut buf = Buffer::empty(area);
buf.set_string(4, 0, " 2:one ", Style::default().bg(Color::Indexed(97)));
buf
};
let wash = Color::Indexed(75);
let mut start = drawn();
restart_sweep(
&mut start,
tab,
Duration::ZERO,
wash,
Color::Indexed(234),
false,
);
assert_eq!(start.cell((4, 0)).unwrap().bg, wash);
assert_eq!(start.cell((3, 0)).unwrap(), drawn().cell((3, 0)).unwrap());
let mut mid = drawn();
restart_sweep(&mut mid, tab, FLASH / 2, wash, Color::Indexed(234), false);
for x in 4..12 {
let cell = mid.cell((x, 0)).unwrap();
assert!(
!matches!(cell.bg, Color::Rgb(..)) && !matches!(cell.fg, Color::Rgb(..)),
"column {x} carries RGB to a 256-colour terminal: {cell:?}"
);
}
let mut done = drawn();
restart_sweep(&mut done, tab, FLASH, wash, Color::Indexed(234), true);
assert_eq!(done, drawn());
}
}