mod sound;
use super::effects::nearest_indexed;
use super::*;
use crossterm::event::KeyCode;
use ratatui::buffer::{Buffer, CellDiffOption};
use ratatui::layout::Rect;
use ratatui::style::Color;
const CODE: [KeyCode; 10] = [
KeyCode::Up,
KeyCode::Up,
KeyCode::Down,
KeyCode::Down,
KeyCode::Left,
KeyCode::Right,
KeyCode::Left,
KeyCode::Right,
KeyCode::Char('b'),
KeyCode::Char('a'),
];
const BPM: f32 = 128.0;
#[derive(Debug, Default)]
pub struct Rave {
heard: usize,
since: Option<Instant>,
sound: Option<sound::Sound>,
}
#[derive(Debug, PartialEq, Eq)]
pub enum Heard {
Pass,
Swallow,
Toggle,
}
impl Rave {
pub fn hear(&mut self, code: KeyCode) -> Heard {
if code != CODE[self.heard] {
self.heard = match code {
KeyCode::Up if self.heard == 2 => 2,
KeyCode::Up => 1,
_ => 0,
};
return Heard::Pass;
}
self.heard += 1;
match self.heard {
n if n == CODE.len() => {
self.heard = 0;
self.since = match self.since {
Some(_) => None,
None => Some(Instant::now()),
};
Heard::Toggle
}
n if n > 8 => Heard::Swallow,
_ => Heard::Pass,
}
}
pub fn elapsed(&self) -> Option<Duration> {
self.since.map(|since| since.elapsed())
}
}
impl App {
pub(super) fn hear_rave(&mut self, key: crossterm::event::KeyEvent) -> bool {
let code = match key.modifiers.is_empty() {
true => key.code,
false => KeyCode::Null,
};
match self.rave.hear(code) {
Heard::Pass => false,
Heard::Swallow => true,
Heard::Toggle => {
match (self.rave.elapsed(), theme::no_color()) {
(Some(_), true) => {
self.rave = Rave::default();
self.set_status("No colour, no party");
}
(Some(_), false) => {
self.rave.sound = sound::Sound::start();
self.set_status(format!(
"♫ Rave mode, with {} if you are online. The same code again to go home",
sound::STATION
))
}
(None, _) => {
self.rave.sound = None;
self.set_status("Lights up. Back to work")
}
}
true
}
}
}
pub fn raving(&self) -> bool {
self.rave.since.is_some()
}
}
const PHRASE: f32 = 32.0;
const BUILD: f32 = 8.0;
fn build(beats: f32) -> f32 {
let into = beats.rem_euclid(PHRASE) - (PHRASE - BUILD);
(into / BUILD).clamp(0.0, 1.0)
}
fn dropping(beats: f32) -> bool {
beats >= PHRASE && beats.rem_euclid(PHRASE) < 2.0
}
pub fn paint(buf: &mut Buffer, footer: Rect, t: Duration, truecolor: bool) {
let secs = t.as_secs_f32();
let beats = secs * BPM / 60.0;
let beat = beats.floor();
let phase = beats - beat;
let thump = (-phase * 5.0).exp();
let build = build(beats);
let ground_hue = (beat * 72.0) % 360.0;
let ground_value = 0.08 + 0.2 * thump + 0.12 * build;
let roll = secs * 180.0 + 720.0 * build * build * build;
let area = buf.area;
let cx = f32::from(area.x) + f32::from(area.width) / 2.0;
let cy = f32::from(area.y) + f32::from(area.height) / 2.0;
let reach = (f32::from(area.width) / 4.0).hypot(f32::from(area.height) / 2.0);
let ring_at = phase * reach * 1.2;
let ring_fade = 1.0 - phase;
let beams = [
(secs * 0.5, 120.0),
(secs * 0.5 + std::f32::consts::FRAC_PI_2, 120.0),
(-secs * 0.35 + 0.4, 300.0),
(-secs * 0.35 + 0.4 + std::f32::consts::FRAC_PI_2, 300.0),
];
let glints = glints(buf, beats);
let mut palette: std::collections::HashMap<(u8, u8, u8), Color> =
std::collections::HashMap::new();
let mut snap = |rgb| match truecolor {
true => snap(rgb, true),
false => *palette.entry(rgb).or_insert_with(|| snap(rgb, false)),
};
for y in area.top()..area.bottom() {
for x in area.left()..area.right() {
let Some(cell) = buf.cell_mut((x, y)) else {
continue;
};
if cell.diff_option == CellDiffOption::Skip {
continue;
}
let dx = (f32::from(x) - cx) / 2.0;
let dy = f32::from(y) - cy;
let ring = (-((dx.hypot(dy) - ring_at) / 1.5).powi(2)).exp() * ring_fade;
let (mut hue, mut value) = match ring > 0.05 {
true => ((ground_hue + 180.0) % 360.0, ground_value + 0.25 * ring),
false => (ground_hue, ground_value),
};
for (angle, beam_hue) in beams {
let off = (dx * angle.sin() - dy * angle.cos()).abs();
if off < 0.6 {
hue = beam_hue;
value = value.max(0.45 * (1.0 - off / 0.6) + 0.1);
}
}
cell.bg = snap(hsv(hue, 0.9, value.min(0.55)));
let hue = (f32::from(x) * 6.0 + f32::from(y) * 12.0 - roll).rem_euclid(360.0);
cell.fg = snap(hsv(hue, 0.85, 1.0));
}
}
for (x, y, glint) in glints {
if let Some(cell) = buf.cell_mut((x, y)) {
cell.set_symbol(glint);
cell.fg = snap(hsv(0.0, 0.0, 1.0));
}
}
equaliser(
buf,
footer.intersection(area),
beats,
thump,
build,
truecolor,
);
}
fn glints(buf: &Buffer, beats: f32) -> Vec<(u16, u16, &'static str)> {
const GLINTS: [&str; 4] = ["✦", "✧", "·", "*"];
let tick = (beats * 2.0).floor() as u32;
let area = buf.area;
let blank = |x: u16, y: u16| {
buf.cell((x, y))
.is_some_and(|c| c.symbol() == " " && c.diff_option == CellDiffOption::None)
};
let mut out = Vec::new();
for y in area.top()..area.bottom() {
for x in area.left() + 1..area.right().saturating_sub(1) {
let roll = scatter(u32::from(x), u32::from(y), tick);
if roll % 1000 < 6 && blank(x - 1, y) && blank(x, y) && blank(x + 1, y) {
out.push((x, y, GLINTS[(roll / 1000) as usize % GLINTS.len()]));
}
}
}
out
}
fn scatter(x: u32, y: u32, tick: u32) -> u32 {
let mut h =
x.wrapping_mul(0x9E37_79B1) ^ y.wrapping_mul(0x85EB_CA77) ^ tick.wrapping_mul(0xC2B2_AE3D);
h ^= h >> 15;
h = h.wrapping_mul(0x2C1B_3C6D);
h ^= h >> 12;
h
}
fn equaliser(buf: &mut Buffer, footer: Rect, beats: f32, thump: f32, build: f32, truecolor: bool) {
const BARS: [&str; 8] = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
const MOVES: [&str; 4] = ["\\o/", "\\o_", "_o/", "|o|"];
let secs = beats * 60.0 / BPM;
let y = footer.y;
for x in footer.left()..footer.right() {
let fx = f32::from(x);
let level = 0.5
+ 0.2 * (fx * 0.9 + secs * 7.0).sin()
+ 0.15 * (fx * 0.37 - secs * 3.1).sin()
+ 0.15 * (fx * 1.7 + secs * 11.3).sin();
let level = (level * (0.55 + 0.45 * thump) + 0.5 * build).clamp(0.0, 0.999);
let hue = (fx * 8.0 + secs * 90.0).rem_euclid(360.0);
if let Some(cell) = buf.cell_mut((x, y)) {
cell.set_diff_option(CellDiffOption::None);
cell.set_symbol(BARS[(level * BARS.len() as f32) as usize]);
cell.fg = snap(hsv(hue, 1.0, 1.0), truecolor);
cell.bg = Color::Indexed(16);
}
}
let label = match (dropping(beats), build > 0.0) {
(true, _) => " ♫ D R O P ♫ ",
(false, true) => " ♫ here it comes… ♫ ",
(false, false) => " ♫ ↑↑↓↓←→←→ba to go home ♫ ",
};
let width = label.chars().count() as u16;
if footer.width <= width {
return;
}
let x = footer.x + (footer.width - width) / 2;
let style = ratatui::style::Style::default()
.fg(Color::Indexed(16))
.bg(snap(
hsv((secs * 90.0).rem_euclid(360.0), 0.7, 1.0),
truecolor,
))
.add_modifier(ratatui::style::Modifier::BOLD);
buf.set_string(x, y, label, style);
const DANCER: u16 = 4;
if footer.width < width + 4 * DANCER + 2 {
return;
}
let dancer = ratatui::style::Style::default()
.fg(snap(hsv(0.0, 0.0, 1.0), truecolor))
.bg(Color::Indexed(16))
.add_modifier(ratatui::style::Modifier::BOLD);
let beat = beats.floor() as usize;
let spots = [x - 2 * DANCER, x - DANCER, x + width, x + width + DANCER];
for (i, at) in spots.into_iter().enumerate() {
let step = MOVES[(beat + i) % MOVES.len()];
buf.set_string(at, y, format!(" {step}"), dancer);
}
}
fn snap(rgb: (u8, u8, u8), truecolor: bool) -> Color {
match truecolor {
true => Color::Rgb(rgb.0, rgb.1, rgb.2),
false => Color::Indexed(nearest_indexed(rgb)),
}
}
fn hsv(hue: f32, saturation: f32, value: f32) -> (u8, u8, u8) {
let c = value * saturation;
let h = hue.rem_euclid(360.0) / 60.0;
let x = c * (1.0 - (h % 2.0 - 1.0).abs());
let (r, g, b) = match h as u8 {
0 => (c, x, 0.0),
1 => (x, c, 0.0),
2 => (0.0, c, x),
3 => (0.0, x, c),
4 => (x, 0.0, c),
_ => (c, 0.0, x),
};
let m = value - c;
let byte = |v: f32| ((v + m) * 255.0).round().clamp(0.0, 255.0) as u8;
(byte(r), byte(g), byte(b))
}
#[cfg(test)]
mod tests {
use super::*;
fn type_code(rave: &mut Rave) -> Vec<Heard> {
CODE.iter().map(|&key| rave.hear(key)).collect()
}
#[test]
fn the_code_starts_the_party_and_ends_it() {
let mut rave = Rave::default();
let heard = type_code(&mut rave);
assert!(heard[..8].iter().all(|h| *h == Heard::Pass), "{heard:?}");
assert_eq!(heard[8], Heard::Swallow);
assert_eq!(heard[9], Heard::Toggle);
assert!(rave.elapsed().is_some());
type_code(&mut rave);
assert!(rave.elapsed().is_none());
}
#[test]
fn a_wrong_key_starts_over_but_a_third_up_does_not() {
let mut rave = Rave::default();
rave.hear(KeyCode::Up);
rave.hear(KeyCode::Up);
rave.hear(KeyCode::Down);
assert_eq!(rave.hear(KeyCode::Char('x')), Heard::Pass);
assert_eq!(rave.heard, 0);
assert_eq!(rave.hear(KeyCode::Char('b')), Heard::Pass);
for _ in 0..5 {
rave.hear(KeyCode::Up);
}
assert_eq!(rave.heard, 2);
let rest: Vec<_> = CODE[2..].iter().map(|&key| rave.hear(key)).collect();
assert_eq!(rest.last(), Some(&Heard::Toggle));
}
#[test]
fn the_code_is_heard_away_from_the_table() {
let mut app = crate::ui::tests::test_app();
app.mode = Mode::Help;
for code in CODE {
app.on_key(crate::ui::tests::key(code));
}
assert!(app.raving());
}
#[test]
fn a_phrase_builds_for_eight_beats_and_drops() {
assert_eq!(build(0.0), 0.0);
assert!(!dropping(0.5), "the first phrase has no build to drop from");
assert_eq!(build(23.9), 0.0);
assert!((build(28.0) - 0.5).abs() < 1e-4);
assert!(build(31.99) > 0.99);
assert_eq!(build(32.0), 0.0);
assert!(dropping(32.0) && dropping(33.9) && !dropping(34.0));
}
#[test]
fn the_mirror_ball_keeps_off_the_text() {
let area = Rect::new(0, 0, 60, 20);
let mut buf = Buffer::empty(area);
for y in 0..20 {
buf.set_string(0, y, "a b c d e f g", ratatui::style::Style::default());
}
let mut seen = 0;
for tick in 0..64 {
for (x, y, _) in glints(&buf, tick as f32 / 2.0) {
assert!(x > 13, "a glint at ({x}, {y}) is inside the text");
seen += 1;
}
}
assert!(seen > 0, "the ball never threw any light");
}
#[test]
fn hsv_lands_on_the_primaries() {
assert_eq!(hsv(0.0, 1.0, 1.0), (255, 0, 0));
assert_eq!(hsv(120.0, 1.0, 1.0), (0, 255, 0));
assert_eq!(hsv(240.0, 1.0, 1.0), (0, 0, 255));
assert_eq!(hsv(360.0, 1.0, 1.0), (255, 0, 0));
assert_eq!(hsv(42.0, 0.0, 0.0), (0, 0, 0));
}
#[test]
fn the_party_repaints_colours_and_keeps_the_text() {
let area = Rect::new(0, 0, 40, 3);
let mut buf = Buffer::empty(area);
buf.set_string(0, 0, "$12.34 spent", ratatui::style::Style::default());
let footer = Rect::new(0, 2, 40, 1);
paint(&mut buf, footer, Duration::from_millis(1234), false);
let text: String = (0..12).map(|x| buf[(x, 0)].symbol().to_string()).collect();
assert_eq!(text, "$12.34 spent");
for cell in buf.content() {
assert!(
!matches!(cell.fg, Color::Rgb(..)) && !matches!(cell.bg, Color::Rgb(..)),
"{cell:?}"
);
}
assert!(buf.content()[80..].iter().any(|c| c.symbol() != " "));
}
}