use ratatui::style::{Color, Style};
use ratatui::widgets::{Block, BorderType, Borders};
use crate::sampler::PressureLevel;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Theme {
pub accent: Color,
pub amber: Color,
pub red: Color,
pub green: Color,
pub text: Color,
pub dim: Color,
pub bg_cell: Color,
pub base: Color,
pub bg_modal: Color,
pub grad_from: (u8, u8, u8),
pub grad_to: (u8, u8, u8),
pub paint_bg: bool,
}
impl Default for Theme {
fn default() -> Self {
Self::dark()
}
}
impl Theme {
pub const fn dark() -> Self {
Self {
accent: Color::Rgb(45, 225, 194),
amber: Color::Rgb(255, 180, 84),
red: Color::Rgb(255, 92, 87),
green: Color::Rgb(74, 214, 109),
text: Color::Rgb(205, 214, 217),
dim: Color::Rgb(90, 105, 110),
bg_cell: Color::Rgb(18, 32, 36),
base: Color::Rgb(10, 14, 18),
bg_modal: Color::Rgb(22, 30, 36),
grad_from: (14, 58, 58),
grad_to: (45, 225, 194),
paint_bg: true,
}
}
pub const fn light() -> Self {
Self {
accent: Color::Rgb(0, 132, 112),
amber: Color::Rgb(160, 90, 0),
red: Color::Rgb(190, 40, 38),
green: Color::Rgb(28, 122, 54),
text: Color::Rgb(28, 38, 44),
dim: Color::Rgb(118, 132, 138),
bg_cell: Color::Rgb(224, 231, 233),
base: Color::Rgb(246, 248, 249),
bg_modal: Color::Rgb(255, 255, 255),
grad_from: (200, 228, 222),
grad_to: (0, 132, 112),
paint_bg: true,
}
}
pub fn gradient(&self, t: f32) -> Color {
let t = t.clamp(0.0, 1.0);
let lerp = |a: u8, b: u8| (a as f32 + (b as f32 - a as f32) * t) as u8;
Color::Rgb(
lerp(self.grad_from.0, self.grad_to.0),
lerp(self.grad_from.1, self.grad_to.1),
lerp(self.grad_from.2, self.grad_to.2),
)
}
pub fn ramp(&self, color: Color, t: f32) -> Color {
let t = t.clamp(0.0, 1.0);
let floored = if t > 0.0 { t.max(MIN_RAMP) } else { 0.0 };
blend(self.base, color, floored)
}
pub fn temp_color(&self, c: f32) -> Color {
if c >= 95.0 {
self.red
} else if c >= 85.0 {
self.amber
} else {
self.accent
}
}
pub fn pressure_color(&self, p: PressureLevel) -> Color {
match p {
PressureLevel::Normal => self.green,
PressureLevel::Warn => self.amber,
PressureLevel::Critical => self.red,
}
}
pub fn panel_block(&self, title: &str, focused: bool) -> Block<'static> {
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(if focused { self.accent } else { self.dim }))
.title(format!(" {title} "))
.title_style(Style::default().fg(self.text))
}
}
const MIN_RAMP: f32 = 0.5;
pub fn blend(from: Color, to: Color, t: f32) -> Color {
let t = t.clamp(0.0, 1.0);
let ch = |c: Color| match c {
Color::Rgb(r, g, b) => (r, g, b),
other => panic!("blend expects Color::Rgb, got {other:?}"),
};
let (fr, fg, fb) = ch(from);
let (tr, tg, tb) = ch(to);
let lerp = |a: u8, b: u8| (a as f32 + (b as f32 - a as f32) * t) as u8;
Color::Rgb(lerp(fr, tr), lerp(fg, tg), lerp(fb, tb))
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::style::Color;
const TH: Theme = Theme::dark();
fn ch(c: Color) -> (u8, u8, u8) {
match c {
Color::Rgb(r, g, b) => (r, g, b),
other => panic!("expected Rgb, got {other:?}"),
}
}
#[test]
fn gradient_is_monotonic_and_clamped() {
let (r0, g0, b0) = ch(TH.gradient(0.0));
let (r1, g1, b1) = ch(TH.gradient(1.0));
assert_eq!((r1, g1, b1), (45, 225, 194));
assert!(g0 < g1 && b0 < b1 && r0 <= r1);
assert_eq!(TH.gradient(-1.0), TH.gradient(0.0));
assert_eq!(TH.gradient(2.0), TH.gradient(1.0));
}
#[test]
fn temp_thresholds() {
assert_eq!(TH.temp_color(60.0), TH.accent);
assert_eq!(TH.temp_color(85.0), TH.amber);
assert_eq!(TH.temp_color(95.0), TH.red);
}
#[test]
fn blend_hits_both_endpoints_and_the_midpoint() {
let a = Color::Rgb(0, 0, 0);
let b = Color::Rgb(100, 200, 50);
assert_eq!(blend(a, b, 0.0), a);
assert_eq!(blend(a, b, 1.0), b);
assert_eq!(blend(a, b, 0.5), Color::Rgb(50, 100, 25));
}
#[test]
fn blend_clamps_out_of_range_t() {
let a = Color::Rgb(10, 20, 30);
let b = Color::Rgb(200, 210, 220);
assert_eq!(blend(a, b, -1.0), a);
assert_eq!(blend(a, b, 5.0), b);
}
#[test]
fn ramp_hits_base_at_zero_and_the_full_colour_at_one() {
assert_eq!(TH.ramp(TH.accent, 0.0), TH.base);
assert_eq!(TH.ramp(TH.accent, 1.0), TH.accent);
}
#[test]
fn ramp_is_dimmer_at_low_factors_than_high_ones() {
let (r_dim, g_dim, b_dim) = ch(TH.ramp(TH.accent, 0.2));
let (r_bright, g_bright, b_bright) = ch(TH.ramp(TH.accent, 1.0));
assert!(r_dim <= r_bright && g_dim < g_bright && b_dim < b_bright);
}
#[test]
fn ramp_floors_the_lowest_nonzero_bar_away_from_base() {
let dimmest = TH.ramp(TH.accent, 0.01);
assert_ne!(dimmest, TH.base);
let d = |a: u8, b: u8| i32::from(a).abs_diff(i32::from(b)) as i32;
let (r0, g0, b0) = ch(TH.base);
let (r1, g1, b1) = ch(dimmest);
let dist = d(r1, r0) + d(g1, g0) + d(b1, b0);
assert!(dist > 20, "lowest non-zero bar colour {dimmest:?} too close to base");
assert_eq!(TH.ramp(TH.accent, 0.0), TH.base);
}
#[test]
fn ramp_stays_within_a_colours_own_hue_not_teal() {
assert_ne!(TH.ramp(TH.red, 0.5), TH.ramp(TH.accent, 0.5));
}
#[test]
fn a_theme_is_copied_not_shared() {
let mut other = Theme::dark();
other.accent = Color::Rgb(1, 2, 3);
assert_eq!(TH.accent, Theme::dark().accent, "mutating a copy must not touch the original");
assert_ne!(other.accent, TH.accent);
}
}