use crate::render::gradients;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::OnceLock;
fn get_gradient_stops_cache() -> &'static HashMap<Palette, Vec<GradientStop>> {
static CACHE: OnceLock<HashMap<Palette, Vec<GradientStop>>> = OnceLock::new();
CACHE.get_or_init(|| {
let mut cache = HashMap::new();
for spec in PALETTES {
let oklch_stops = gradients::get_oklch_gradient(spec.palette.clone());
let stops = oklch_stops_to_gradient(oklch_stops, 64);
cache.insert(spec.palette.clone(), stops);
}
cache
})
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Palette {
Organic,
Heat,
Ocean,
Mono,
Forest,
Neon,
Warm,
Vibrant,
LegibleMono,
Slime,
Mold,
Fungus,
Swamp,
Moss,
Cosmic,
Ethereal,
Jade,
Amber,
Slate,
Pastel,
Ink,
Copper,
Custom(Vec<RgbColor>),
}
impl Palette {
pub fn name(&self) -> &'static str {
PALETTES
.iter()
.find(|spec| &spec.palette == self)
.map_or("Custom", |spec| spec.name)
}
}
pub const ALL_PALETTES: [Palette; 22] = [
Palette::Organic,
Palette::Heat,
Palette::Ocean,
Palette::Mono,
Palette::Forest,
Palette::Neon,
Palette::Warm,
Palette::Vibrant,
Palette::LegibleMono,
Palette::Slime,
Palette::Mold,
Palette::Fungus,
Palette::Swamp,
Palette::Moss,
Palette::Cosmic,
Palette::Ethereal,
Palette::Jade,
Palette::Amber,
Palette::Slate,
Palette::Pastel,
Palette::Ink,
Palette::Copper,
];
pub const NUM_PALETTES: usize = ALL_PALETTES.len();
pub fn num_palettes() -> usize {
NUM_PALETTES
}
pub struct PaletteSpec {
pub palette: Palette,
pub name: &'static str,
}
pub const PALETTES: &[PaletteSpec] = &[
PaletteSpec {
palette: Palette::Organic,
name: "Organic",
},
PaletteSpec {
palette: Palette::Heat,
name: "Heat",
},
PaletteSpec {
palette: Palette::Ocean,
name: "Ocean",
},
PaletteSpec {
palette: Palette::Mono,
name: "Mono",
},
PaletteSpec {
palette: Palette::Forest,
name: "Forest",
},
PaletteSpec {
palette: Palette::Neon,
name: "Neon",
},
PaletteSpec {
palette: Palette::Warm,
name: "Warm",
},
PaletteSpec {
palette: Palette::Vibrant,
name: "Vibrant",
},
PaletteSpec {
palette: Palette::LegibleMono,
name: "LegibleMono",
},
PaletteSpec {
palette: Palette::Slime,
name: "Slime",
},
PaletteSpec {
palette: Palette::Mold,
name: "Mold",
},
PaletteSpec {
palette: Palette::Fungus,
name: "Fungus",
},
PaletteSpec {
palette: Palette::Swamp,
name: "Swamp",
},
PaletteSpec {
palette: Palette::Moss,
name: "Moss",
},
PaletteSpec {
palette: Palette::Cosmic,
name: "Cosmic",
},
PaletteSpec {
palette: Palette::Ethereal,
name: "Ethereal",
},
PaletteSpec {
palette: Palette::Jade,
name: "Jade",
},
PaletteSpec {
palette: Palette::Amber,
name: "Amber",
},
PaletteSpec {
palette: Palette::Slate,
name: "Slate",
},
PaletteSpec {
palette: Palette::Pastel,
name: "Pastel",
},
PaletteSpec {
palette: Palette::Ink,
name: "Ink",
},
PaletteSpec {
palette: Palette::Copper,
name: "Copper",
},
];
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct RgbColor {
pub r: u8,
pub g: u8,
pub b: u8,
}
impl RgbColor {
pub const fn new(r: u8, g: u8, b: u8) -> Self {
Self { r, g, b }
}
pub const fn from_hex(hex: u32) -> Self {
Self {
r: ((hex >> 16) & 0xFF) as u8,
g: ((hex >> 8) & 0xFF) as u8,
b: (hex & 0xFF) as u8,
}
}
pub fn with_alpha(&self, alpha: f32) -> Self {
let alpha = alpha.clamp(0.0, 1.0);
Self {
r: (self.r as f32 * alpha) as u8,
g: (self.g as f32 * alpha) as u8,
b: (self.b as f32 * alpha) as u8,
}
}
pub fn blend(&self, other: &Self, factor: f32) -> Self {
let factor = factor.clamp(0.0, 1.0);
let inv = 1.0 - factor;
Self {
r: (self.r as f32 * inv + other.r as f32 * factor) as u8,
g: (self.g as f32 * inv + other.g as f32 * factor) as u8,
b: (self.b as f32 * inv + other.b as f32 * factor) as u8,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct HsvColor {
pub h: f32,
pub s: f32,
pub v: f32,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct OklchColor {
pub l: f32,
pub c: f32,
pub h: f32,
}
pub const OKLCH_EPSILON: f32 = 0.0001;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct GradientStop {
pub position: f32,
pub color: RgbColor,
}
#[derive(Clone, Copy, Debug)]
pub struct OklchStop {
pub position: f32,
pub l: f32,
pub c: f32,
pub h: f32,
}
pub fn oklch_to_srgb(l: f32, c: f32, h_deg: f32) -> RgbColor {
let h_rad = if h_deg.is_nan() {
0.0
} else {
h_deg.to_radians()
};
let a = c * h_rad.cos();
let b = c * h_rad.sin();
let l_ = l + 0.396_337_8 * a + 0.215_803_76 * b;
let m_ = l - 0.105_561_35 * a - 0.063_854_17 * b;
let s_ = l - 0.089_484_18 * a - 1.291_485_5 * b;
let lms_l = l_ * l_ * l_;
let lms_m = m_ * m_ * m_;
let lms_s = s_ * s_ * s_;
let r_lin = 4.076_741_7 * lms_l - 3.307_736_3 * lms_m + 0.230_910_13 * lms_s;
let g_lin = -1.268_438 * lms_l + 2.609_757_4 * lms_m - 0.341_319_4 * lms_s;
let b_lin = -0.004_196_077 * lms_l - 0.703_418_6 * lms_m + 1.707_614_7 * lms_s;
let gamma = |x: f32| -> f32 {
if x <= 0.0031308 {
12.92 * x
} else {
1.055 * x.powf(1.0 / 2.4) - 0.055
}
};
RgbColor {
r: (gamma(r_lin).clamp(0.0, 1.0) * 255.0).round() as u8,
g: (gamma(g_lin).clamp(0.0, 1.0) * 255.0).round() as u8,
b: (gamma(b_lin).clamp(0.0, 1.0) * 255.0).round() as u8,
}
}
pub fn srgb_to_oklch(rgb: RgbColor) -> OklchColor {
let inv_gamma = |x: f32| -> f32 {
if x <= 0.04045 {
x / 12.92
} else {
((x + 0.055) / 1.055).powf(2.4)
}
};
let r = inv_gamma(rgb.r as f32 / 255.0);
let g = inv_gamma(rgb.g as f32 / 255.0);
let b = inv_gamma(rgb.b as f32 / 255.0);
let l = 0.412_221_47 * r + 0.536_332_54 * g + 0.051_445_99 * b;
let m = 0.211_903_5 * r + 0.680_699_5 * g + 0.107_396_96 * b;
let s = 0.088_302_46 * r + 0.281_718_84 * g + 0.629_978_7 * b;
let l_ = l.cbrt();
let m_ = m.cbrt();
let s_ = s.cbrt();
let lab_l = 0.210_454_26 * l_ + 0.793_617_8 * m_ - 0.004_072_047 * s_;
let lab_a = 1.977_998_5 * l_ - 2.428_592_2 * m_ + 0.450_593_7 * s_;
let lab_b = 0.025_904_037 * l_ + 0.782_771_77 * m_ - 0.808_675_77 * s_;
let c = (lab_a * lab_a + lab_b * lab_b).sqrt();
let h = if c < OKLCH_EPSILON {
f32::NAN
} else {
let h = lab_b.atan2(lab_a).to_degrees();
if h < 0.0 {
h + 360.0
} else {
h
}
};
OklchColor { l: lab_l, c, h }
}
pub fn oklch_to_rgb(oklch: OklchColor) -> RgbColor {
oklch_to_srgb(oklch.l, oklch.c, oklch.h)
}
fn interpolate_oklch_stops(stops: &[OklchStop], t: f32) -> RgbColor {
let t = t.clamp(0.0, 1.0);
if stops.is_empty() {
return RgbColor { r: 0, g: 0, b: 0 };
}
if stops.len() == 1 {
return oklch_to_srgb(stops[0].l, stops[0].c, stops[0].h);
}
let mut lo = 0;
let mut hi = stops.len() - 1;
while lo < hi {
let mid = (lo + hi + 1) / 2;
if stops[mid].position <= t {
lo = mid;
} else {
hi = mid - 1;
}
}
let lo = lo.min(stops.len() - 1);
let hi = (lo + 1).min(stops.len() - 1);
if lo == hi {
let s = &stops[lo];
return oklch_to_srgb(s.l, s.c, s.h);
}
let range = stops[hi].position - stops[lo].position;
if range < f32::EPSILON {
let s = &stops[lo];
return oklch_to_srgb(s.l, s.c, s.h);
}
let local_t = (t - stops[lo].position) / range;
let l = stops[lo].l + (stops[hi].l - stops[lo].l) * local_t;
let c = stops[lo].c + (stops[hi].c - stops[lo].c) * local_t;
let h0 = stops[lo].h;
let h1 = stops[hi].h;
let dh = {
let diff = (h1 - h0).rem_euclid(360.0);
if diff > 180.0 {
diff - 360.0
} else {
diff
}
};
let h = (h0 + dh * local_t).rem_euclid(360.0);
oklch_to_srgb(l, c, h)
}
fn oklch_stops_to_gradient(stops: &[OklchStop], n: usize) -> Vec<GradientStop> {
let n = n.max(2);
(0..n)
.map(|i| {
let t = i as f32 / (n - 1) as f32;
GradientStop {
position: t,
color: interpolate_oklch_stops(stops, t),
}
})
.collect()
}
#[derive(Clone, Debug, PartialEq, Default)]
pub enum MappingFunction {
#[default]
Linear,
Logarithmic {
base: f32,
},
Exponential {
base: f32,
},
Power {
gamma: f32,
},
SquareRoot,
Square,
Sigmoid {
steepness: f32,
},
Smoothstep,
Quantize {
levels: u8,
},
Perlin {
amplitude: f32,
frequency: f32,
seed: u64,
},
}
impl MappingFunction {
#[inline]
pub fn apply(&self, x: f32) -> f32 {
let x = x.clamp(0.0, 1.0);
match self {
MappingFunction::Linear => x,
MappingFunction::Logarithmic { base } => {
let base = base.max(0.001);
(1.0 + x * base).ln() / (1.0 + base).ln()
}
MappingFunction::Exponential { base } => {
let base = base.max(1.001);
(base.powf(x) - 1.0) / (base - 1.0)
}
MappingFunction::Power { gamma } => {
let gamma = gamma.max(0.001);
x.powf(gamma)
}
MappingFunction::SquareRoot => x.sqrt(),
MappingFunction::Square => x * x,
MappingFunction::Sigmoid { steepness } => {
let k = steepness.max(0.1);
let sigmoid = |t: f32| 1.0 / (1.0 + (-t).exp());
let s_min = sigmoid(-k * 0.5);
let s_max = sigmoid(k * 0.5);
let raw = sigmoid(k * (x - 0.5));
(raw - s_min) / (s_max - s_min)
}
MappingFunction::Smoothstep => {
x * x * (3.0 - 2.0 * x)
}
MappingFunction::Quantize { levels } => {
let levels = (*levels).max(2) as f32;
let quantized = (x * levels).floor() / (levels - 1.0);
quantized.clamp(0.0, 1.0)
}
MappingFunction::Perlin {
amplitude,
frequency,
seed,
} => Self::apply_perlin(x, *amplitude, *frequency, *seed),
}
}
fn apply_perlin(x: f32, amplitude: f32, frequency: f32, seed: u64) -> f32 {
let noise_val = Self::perlin_1d(x * frequency, seed);
let endpoint_weight = 4.0 * x * (1.0 - x);
let distorted = x + noise_val * amplitude * endpoint_weight;
distorted.clamp(0.0, 1.0)
}
fn perlin_1d(x: f32, seed: u64) -> f32 {
let x0 = x.floor() as i32;
let x1 = x0 + 1;
let t = x - x.floor();
let t_smooth = t * t * (3.0 - 2.0 * t);
let g0 = Self::hash_gradient(x0, seed);
let g1 = Self::hash_gradient(x1, seed);
let d0 = t * g0;
let d1 = (t - 1.0) * g1;
d0 + t_smooth * (d1 - d0)
}
fn hash_gradient(x: i32, seed: u64) -> f32 {
let mut h = (x as u64).wrapping_mul(0x9E3779B97F4A7C15);
h ^= seed;
h = h.wrapping_mul(0x517CC1B727220A95);
h ^= h >> 32;
(h as f32 / u64::MAX as f32) * 2.0 - 1.0
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct MappingSegment {
pub start: f32,
pub end: f32,
pub function: MappingFunction,
}
#[derive(Debug, Clone, PartialEq)]
pub enum MappingError {
NoSegments,
DomainNotCovered {
reason: String,
},
SegmentGap {
segment_index: usize,
gap_start: f32,
gap_end: f32,
},
InvalidSegment {
segment_index: usize,
reason: String,
},
}
impl std::fmt::Display for MappingError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MappingError::NoSegments => write!(f, "No mapping segments provided"),
MappingError::DomainNotCovered { reason } => {
write!(f, "Domain [0,1] not fully covered: {}", reason)
}
MappingError::SegmentGap {
segment_index,
gap_start,
gap_end,
} => write!(
f,
"Gap between segments {} and {}: [{}, {}]",
segment_index,
segment_index + 1,
gap_start,
gap_end
),
MappingError::InvalidSegment {
segment_index,
reason,
} => write!(f, "Invalid segment {}: {}", segment_index, reason),
}
}
}
impl std::error::Error for MappingError {}
#[derive(Clone, Debug, PartialEq)]
pub struct IntensityMapping {
segments: Vec<MappingSegment>,
}
impl IntensityMapping {
pub fn new(segments: Vec<MappingSegment>) -> Result<Self, MappingError> {
Self::validate_segments(&segments)?;
Ok(Self { segments })
}
fn validate_segments(segments: &[MappingSegment]) -> Result<(), MappingError> {
use crate::config_defaults::math::EPSILON;
if segments.is_empty() {
return Err(MappingError::NoSegments);
}
if (segments[0].start - 0.0).abs() > EPSILON {
return Err(MappingError::DomainNotCovered {
reason: format!("First segment starts at {}, not 0.0", segments[0].start),
});
}
let last = segments.last().unwrap();
if (last.end - 1.0).abs() > EPSILON {
return Err(MappingError::DomainNotCovered {
reason: format!("Last segment ends at {}, not 1.0", last.end),
});
}
for i in 0..segments.len() - 1 {
let current_end = segments[i].end;
let next_start = segments[i + 1].start;
if (current_end - next_start).abs() > EPSILON {
return Err(MappingError::SegmentGap {
segment_index: i,
gap_start: current_end,
gap_end: next_start,
});
}
}
for (i, seg) in segments.iter().enumerate() {
if seg.end <= seg.start {
return Err(MappingError::InvalidSegment {
segment_index: i,
reason: format!("Segment end ({}) <= start ({})", seg.end, seg.start),
});
}
}
Ok(())
}
#[inline]
pub fn apply(&self, intensity: f32) -> f32 {
let intensity = intensity.clamp(0.0, 1.0);
for segment in &self.segments {
if intensity >= segment.start && intensity <= segment.end {
let segment_width = segment.end - segment.start;
let local_t = if segment_width > 0.0 {
(intensity - segment.start) / segment_width
} else {
0.0
};
let mapped_local = segment.function.apply(local_t);
return segment.start + mapped_local * segment_width;
}
}
intensity
}
pub fn segments(&self) -> &[MappingSegment] {
&self.segments
}
}
impl IntensityMapping {
pub fn linear() -> Self {
Self {
segments: vec![MappingSegment {
start: 0.0,
end: 1.0,
function: MappingFunction::Linear,
}],
}
}
pub fn logarithmic(base: f32) -> Self {
Self {
segments: vec![MappingSegment {
start: 0.0,
end: 1.0,
function: MappingFunction::Logarithmic { base },
}],
}
}
pub fn exponential(base: f32) -> Self {
Self {
segments: vec![MappingSegment {
start: 0.0,
end: 1.0,
function: MappingFunction::Exponential { base },
}],
}
}
pub fn power(gamma: f32) -> Self {
Self {
segments: vec![MappingSegment {
start: 0.0,
end: 1.0,
function: MappingFunction::Power { gamma },
}],
}
}
pub fn linear_log_split(log_base: f32) -> Self {
use crate::config_defaults::palette::{DEFAULT_PALETTE_STEPS, LINEAR_COLOR_COUNT};
let split_point = LINEAR_COLOR_COUNT / DEFAULT_PALETTE_STEPS as f32; Self {
segments: vec![
MappingSegment {
start: 0.0,
end: split_point,
function: MappingFunction::Linear,
},
MappingSegment {
start: split_point,
end: 1.0,
function: MappingFunction::Logarithmic { base: log_base },
},
],
}
}
pub fn split_at(
split_point: f32,
lower_fn: MappingFunction,
upper_fn: MappingFunction,
) -> Result<Self, MappingError> {
let split_point = split_point.clamp(0.001, 0.999);
Self::new(vec![
MappingSegment {
start: 0.0,
end: split_point,
function: lower_fn,
},
MappingSegment {
start: split_point,
end: 1.0,
function: upper_fn,
},
])
}
pub fn perlin(amplitude: f32, frequency: f32, seed: u64) -> Self {
Self {
segments: vec![MappingSegment {
start: 0.0,
end: 1.0,
function: MappingFunction::Perlin {
amplitude,
frequency,
seed,
},
}],
}
}
pub fn smoothstep() -> Self {
Self {
segments: vec![MappingSegment {
start: 0.0,
end: 1.0,
function: MappingFunction::Smoothstep,
}],
}
}
pub fn quantize(levels: u8) -> Self {
Self {
segments: vec![MappingSegment {
start: 0.0,
end: 1.0,
function: MappingFunction::Quantize { levels },
}],
}
}
pub fn sigmoid(steepness: f32) -> Self {
Self {
segments: vec![MappingSegment {
start: 0.0,
end: 1.0,
function: MappingFunction::Sigmoid { steepness },
}],
}
}
}
impl Default for IntensityMapping {
fn default() -> Self {
Self::logarithmic(10.0)
}
}
#[inline]
pub fn interpolate_gradient(stops: &[GradientStop], t: f32) -> RgbColor {
let t = t.clamp(0.0, 1.0);
if stops.is_empty() {
return RgbColor { r: 0, g: 0, b: 0 };
}
if stops.len() == 1 {
return stops[0].color;
}
let mut lo = 0;
let mut hi = stops.len() - 1;
while lo < hi {
let mid = (lo + hi + 1) / 2;
if stops[mid].position <= t {
lo = mid;
} else {
hi = mid - 1;
}
}
let lower_idx = lo.min(stops.len() - 1);
let upper_idx = (lo + 1).min(stops.len() - 1);
if (stops[lower_idx].position - t).abs() < f32::EPSILON {
return stops[lower_idx].color;
}
if (stops[upper_idx].position - t).abs() < f32::EPSILON {
return stops[upper_idx].color;
}
let lower_stop = stops[lower_idx];
let upper_stop = stops[upper_idx];
let range = upper_stop.position - lower_stop.position;
if range < f32::EPSILON {
return lower_stop.color;
}
let local_t = (t - lower_stop.position) / range;
RgbColor {
r: (lower_stop.color.r as f32
+ (upper_stop.color.r as f32 - lower_stop.color.r as f32) * local_t) as u8,
g: (lower_stop.color.g as f32
+ (upper_stop.color.g as f32 - lower_stop.color.g as f32) * local_t) as u8,
b: (lower_stop.color.b as f32
+ (upper_stop.color.b as f32 - lower_stop.color.b as f32) * local_t) as u8,
}
}
pub const ANSI_256_TO_RGB: [RgbColor; 256] = {
[
RgbColor { r: 0, g: 0, b: 0 }, RgbColor { r: 128, g: 0, b: 0 }, RgbColor { r: 0, g: 128, b: 0 }, RgbColor {
r: 128,
g: 128,
b: 0,
}, RgbColor { r: 0, g: 0, b: 128 }, RgbColor {
r: 128,
g: 0,
b: 128,
}, RgbColor {
r: 0,
g: 128,
b: 128,
}, RgbColor {
r: 192,
g: 192,
b: 192,
}, RgbColor {
r: 128,
g: 128,
b: 128,
}, RgbColor { r: 255, g: 0, b: 0 }, RgbColor { r: 0, g: 255, b: 0 }, RgbColor {
r: 255,
g: 255,
b: 0,
}, RgbColor { r: 0, g: 0, b: 255 }, RgbColor {
r: 255,
g: 0,
b: 255,
}, RgbColor {
r: 0,
g: 255,
b: 255,
}, RgbColor {
r: 255,
g: 255,
b: 255,
}, RgbColor { r: 0, g: 0, b: 0 },
RgbColor { r: 0, g: 0, b: 95 },
RgbColor { r: 0, g: 0, b: 135 },
RgbColor { r: 0, g: 0, b: 175 },
RgbColor { r: 0, g: 0, b: 215 },
RgbColor { r: 0, g: 0, b: 255 },
RgbColor { r: 0, g: 95, b: 0 },
RgbColor { r: 0, g: 95, b: 95 },
RgbColor {
r: 0,
g: 95,
b: 135,
},
RgbColor {
r: 0,
g: 95,
b: 175,
},
RgbColor {
r: 0,
g: 95,
b: 215,
},
RgbColor {
r: 0,
g: 95,
b: 255,
},
RgbColor { r: 0, g: 135, b: 0 },
RgbColor {
r: 0,
g: 135,
b: 95,
},
RgbColor {
r: 0,
g: 135,
b: 135,
},
RgbColor {
r: 0,
g: 135,
b: 175,
},
RgbColor {
r: 0,
g: 135,
b: 215,
},
RgbColor {
r: 0,
g: 135,
b: 255,
},
RgbColor { r: 0, g: 175, b: 0 },
RgbColor {
r: 0,
g: 175,
b: 95,
},
RgbColor {
r: 0,
g: 175,
b: 135,
},
RgbColor {
r: 0,
g: 175,
b: 175,
},
RgbColor {
r: 0,
g: 175,
b: 215,
},
RgbColor {
r: 0,
g: 175,
b: 255,
},
RgbColor { r: 0, g: 215, b: 0 },
RgbColor {
r: 0,
g: 215,
b: 95,
},
RgbColor {
r: 0,
g: 215,
b: 135,
},
RgbColor {
r: 0,
g: 215,
b: 175,
},
RgbColor {
r: 0,
g: 215,
b: 215,
},
RgbColor {
r: 0,
g: 215,
b: 255,
},
RgbColor { r: 0, g: 255, b: 0 },
RgbColor {
r: 0,
g: 255,
b: 95,
},
RgbColor {
r: 0,
g: 255,
b: 135,
},
RgbColor {
r: 0,
g: 255,
b: 175,
},
RgbColor {
r: 0,
g: 255,
b: 215,
},
RgbColor {
r: 0,
g: 255,
b: 255,
},
RgbColor { r: 95, g: 0, b: 0 },
RgbColor { r: 95, g: 0, b: 95 },
RgbColor {
r: 95,
g: 0,
b: 135,
},
RgbColor {
r: 95,
g: 0,
b: 175,
},
RgbColor {
r: 95,
g: 0,
b: 215,
},
RgbColor {
r: 95,
g: 0,
b: 255,
},
RgbColor { r: 95, g: 95, b: 0 },
RgbColor {
r: 95,
g: 95,
b: 95,
},
RgbColor {
r: 95,
g: 95,
b: 135,
},
RgbColor {
r: 95,
g: 95,
b: 175,
},
RgbColor {
r: 95,
g: 95,
b: 215,
},
RgbColor {
r: 95,
g: 95,
b: 255,
},
RgbColor {
r: 95,
g: 135,
b: 0,
},
RgbColor {
r: 95,
g: 135,
b: 95,
},
RgbColor {
r: 95,
g: 135,
b: 135,
},
RgbColor {
r: 95,
g: 135,
b: 175,
},
RgbColor {
r: 95,
g: 135,
b: 215,
},
RgbColor {
r: 95,
g: 135,
b: 255,
},
RgbColor {
r: 95,
g: 175,
b: 0,
},
RgbColor {
r: 95,
g: 175,
b: 95,
},
RgbColor {
r: 95,
g: 175,
b: 135,
},
RgbColor {
r: 95,
g: 175,
b: 175,
},
RgbColor {
r: 95,
g: 175,
b: 215,
},
RgbColor {
r: 95,
g: 175,
b: 255,
},
RgbColor {
r: 95,
g: 215,
b: 0,
},
RgbColor {
r: 95,
g: 215,
b: 95,
},
RgbColor {
r: 95,
g: 215,
b: 135,
},
RgbColor {
r: 95,
g: 215,
b: 175,
},
RgbColor {
r: 95,
g: 215,
b: 215,
},
RgbColor {
r: 95,
g: 215,
b: 255,
},
RgbColor {
r: 95,
g: 255,
b: 0,
},
RgbColor {
r: 95,
g: 255,
b: 95,
},
RgbColor {
r: 95,
g: 255,
b: 135,
},
RgbColor {
r: 95,
g: 255,
b: 175,
},
RgbColor {
r: 95,
g: 255,
b: 215,
},
RgbColor {
r: 95,
g: 255,
b: 255,
},
RgbColor { r: 135, g: 0, b: 0 },
RgbColor {
r: 135,
g: 0,
b: 95,
},
RgbColor {
r: 135,
g: 0,
b: 135,
},
RgbColor {
r: 135,
g: 0,
b: 175,
},
RgbColor {
r: 135,
g: 0,
b: 215,
},
RgbColor {
r: 135,
g: 0,
b: 255,
},
RgbColor {
r: 135,
g: 95,
b: 0,
},
RgbColor {
r: 135,
g: 95,
b: 95,
},
RgbColor {
r: 135,
g: 95,
b: 135,
},
RgbColor {
r: 135,
g: 95,
b: 175,
},
RgbColor {
r: 135,
g: 95,
b: 215,
},
RgbColor {
r: 135,
g: 95,
b: 255,
},
RgbColor {
r: 135,
g: 135,
b: 0,
},
RgbColor {
r: 135,
g: 135,
b: 95,
},
RgbColor {
r: 135,
g: 135,
b: 135,
},
RgbColor {
r: 135,
g: 135,
b: 175,
},
RgbColor {
r: 135,
g: 135,
b: 215,
},
RgbColor {
r: 135,
g: 135,
b: 255,
},
RgbColor {
r: 135,
g: 175,
b: 0,
},
RgbColor {
r: 135,
g: 175,
b: 95,
},
RgbColor {
r: 135,
g: 175,
b: 135,
},
RgbColor {
r: 135,
g: 175,
b: 175,
},
RgbColor {
r: 135,
g: 175,
b: 215,
},
RgbColor {
r: 135,
g: 175,
b: 255,
},
RgbColor {
r: 135,
g: 215,
b: 0,
},
RgbColor {
r: 135,
g: 215,
b: 95,
},
RgbColor {
r: 135,
g: 215,
b: 135,
},
RgbColor {
r: 135,
g: 215,
b: 175,
},
RgbColor {
r: 135,
g: 215,
b: 215,
},
RgbColor {
r: 135,
g: 215,
b: 255,
},
RgbColor {
r: 135,
g: 255,
b: 0,
},
RgbColor {
r: 135,
g: 255,
b: 95,
},
RgbColor {
r: 135,
g: 255,
b: 135,
},
RgbColor {
r: 135,
g: 255,
b: 175,
},
RgbColor {
r: 135,
g: 255,
b: 215,
},
RgbColor {
r: 135,
g: 255,
b: 255,
},
RgbColor { r: 175, g: 0, b: 0 },
RgbColor {
r: 175,
g: 0,
b: 95,
},
RgbColor {
r: 175,
g: 0,
b: 135,
},
RgbColor {
r: 175,
g: 0,
b: 175,
},
RgbColor {
r: 175,
g: 0,
b: 215,
},
RgbColor {
r: 175,
g: 0,
b: 255,
},
RgbColor {
r: 175,
g: 95,
b: 0,
},
RgbColor {
r: 175,
g: 95,
b: 95,
},
RgbColor {
r: 175,
g: 95,
b: 135,
},
RgbColor {
r: 175,
g: 95,
b: 175,
},
RgbColor {
r: 175,
g: 95,
b: 215,
},
RgbColor {
r: 175,
g: 95,
b: 255,
},
RgbColor {
r: 175,
g: 135,
b: 0,
},
RgbColor {
r: 175,
g: 135,
b: 95,
},
RgbColor {
r: 175,
g: 135,
b: 135,
},
RgbColor {
r: 175,
g: 135,
b: 175,
},
RgbColor {
r: 175,
g: 135,
b: 215,
},
RgbColor {
r: 175,
g: 135,
b: 255,
},
RgbColor {
r: 175,
g: 175,
b: 0,
},
RgbColor {
r: 175,
g: 175,
b: 95,
},
RgbColor {
r: 175,
g: 175,
b: 135,
},
RgbColor {
r: 175,
g: 175,
b: 175,
},
RgbColor {
r: 175,
g: 175,
b: 215,
},
RgbColor {
r: 175,
g: 175,
b: 255,
},
RgbColor {
r: 175,
g: 215,
b: 0,
},
RgbColor {
r: 175,
g: 215,
b: 95,
},
RgbColor {
r: 175,
g: 215,
b: 135,
},
RgbColor {
r: 175,
g: 215,
b: 175,
},
RgbColor {
r: 175,
g: 215,
b: 215,
},
RgbColor {
r: 175,
g: 215,
b: 255,
},
RgbColor {
r: 175,
g: 255,
b: 0,
},
RgbColor {
r: 175,
g: 255,
b: 95,
},
RgbColor {
r: 175,
g: 255,
b: 135,
},
RgbColor {
r: 175,
g: 255,
b: 175,
},
RgbColor {
r: 175,
g: 255,
b: 215,
},
RgbColor {
r: 175,
g: 255,
b: 255,
},
RgbColor { r: 215, g: 0, b: 0 },
RgbColor {
r: 215,
g: 0,
b: 95,
},
RgbColor {
r: 215,
g: 0,
b: 135,
},
RgbColor {
r: 215,
g: 0,
b: 175,
},
RgbColor {
r: 215,
g: 0,
b: 215,
},
RgbColor {
r: 215,
g: 0,
b: 255,
},
RgbColor {
r: 215,
g: 95,
b: 0,
},
RgbColor {
r: 215,
g: 95,
b: 95,
},
RgbColor {
r: 215,
g: 95,
b: 135,
},
RgbColor {
r: 215,
g: 95,
b: 175,
},
RgbColor {
r: 215,
g: 95,
b: 215,
},
RgbColor {
r: 215,
g: 95,
b: 255,
},
RgbColor {
r: 215,
g: 135,
b: 0,
},
RgbColor {
r: 215,
g: 135,
b: 95,
},
RgbColor {
r: 215,
g: 135,
b: 135,
},
RgbColor {
r: 215,
g: 135,
b: 175,
},
RgbColor {
r: 215,
g: 135,
b: 215,
},
RgbColor {
r: 215,
g: 135,
b: 255,
},
RgbColor {
r: 215,
g: 175,
b: 0,
},
RgbColor {
r: 215,
g: 175,
b: 95,
},
RgbColor {
r: 215,
g: 175,
b: 135,
},
RgbColor {
r: 215,
g: 175,
b: 175,
},
RgbColor {
r: 215,
g: 175,
b: 215,
},
RgbColor {
r: 215,
g: 175,
b: 255,
},
RgbColor {
r: 215,
g: 215,
b: 0,
},
RgbColor {
r: 215,
g: 215,
b: 95,
},
RgbColor {
r: 215,
g: 215,
b: 135,
},
RgbColor {
r: 215,
g: 215,
b: 175,
},
RgbColor {
r: 215,
g: 215,
b: 215,
},
RgbColor {
r: 215,
g: 215,
b: 255,
},
RgbColor {
r: 215,
g: 255,
b: 0,
},
RgbColor {
r: 215,
g: 255,
b: 95,
},
RgbColor {
r: 215,
g: 255,
b: 135,
},
RgbColor {
r: 215,
g: 255,
b: 175,
},
RgbColor {
r: 215,
g: 255,
b: 215,
},
RgbColor {
r: 215,
g: 255,
b: 255,
},
RgbColor { r: 255, g: 0, b: 0 },
RgbColor {
r: 255,
g: 0,
b: 95,
},
RgbColor {
r: 255,
g: 0,
b: 135,
},
RgbColor {
r: 255,
g: 0,
b: 175,
},
RgbColor {
r: 255,
g: 0,
b: 215,
},
RgbColor {
r: 255,
g: 0,
b: 255,
},
RgbColor {
r: 255,
g: 95,
b: 0,
},
RgbColor {
r: 255,
g: 95,
b: 95,
},
RgbColor {
r: 255,
g: 95,
b: 135,
},
RgbColor {
r: 255,
g: 95,
b: 175,
},
RgbColor {
r: 255,
g: 95,
b: 215,
},
RgbColor {
r: 255,
g: 95,
b: 255,
},
RgbColor {
r: 255,
g: 135,
b: 0,
},
RgbColor {
r: 255,
g: 135,
b: 95,
},
RgbColor {
r: 255,
g: 135,
b: 135,
},
RgbColor {
r: 255,
g: 135,
b: 175,
},
RgbColor {
r: 255,
g: 135,
b: 215,
},
RgbColor {
r: 255,
g: 135,
b: 255,
},
RgbColor {
r: 255,
g: 175,
b: 0,
},
RgbColor {
r: 255,
g: 175,
b: 95,
},
RgbColor {
r: 255,
g: 175,
b: 135,
},
RgbColor {
r: 255,
g: 175,
b: 175,
},
RgbColor {
r: 255,
g: 175,
b: 215,
},
RgbColor {
r: 255,
g: 175,
b: 255,
},
RgbColor {
r: 255,
g: 215,
b: 0,
},
RgbColor {
r: 255,
g: 215,
b: 95,
},
RgbColor {
r: 255,
g: 215,
b: 135,
},
RgbColor {
r: 255,
g: 215,
b: 175,
},
RgbColor {
r: 255,
g: 215,
b: 215,
},
RgbColor {
r: 255,
g: 215,
b: 255,
},
RgbColor {
r: 255,
g: 255,
b: 0,
},
RgbColor {
r: 255,
g: 255,
b: 95,
},
RgbColor {
r: 255,
g: 255,
b: 135,
},
RgbColor {
r: 255,
g: 255,
b: 175,
},
RgbColor {
r: 255,
g: 255,
b: 215,
},
RgbColor {
r: 255,
g: 255,
b: 255,
},
RgbColor { r: 8, g: 8, b: 8 },
RgbColor {
r: 18,
g: 18,
b: 18,
},
RgbColor {
r: 28,
g: 28,
b: 28,
},
RgbColor {
r: 38,
g: 38,
b: 38,
},
RgbColor {
r: 48,
g: 48,
b: 48,
},
RgbColor {
r: 58,
g: 58,
b: 58,
},
RgbColor {
r: 68,
g: 68,
b: 68,
},
RgbColor {
r: 78,
g: 78,
b: 78,
},
RgbColor {
r: 88,
g: 88,
b: 88,
},
RgbColor {
r: 98,
g: 98,
b: 98,
},
RgbColor {
r: 108,
g: 108,
b: 108,
},
RgbColor {
r: 118,
g: 118,
b: 118,
},
RgbColor {
r: 128,
g: 128,
b: 128,
},
RgbColor {
r: 138,
g: 138,
b: 138,
},
RgbColor {
r: 148,
g: 148,
b: 148,
},
RgbColor {
r: 158,
g: 158,
b: 158,
},
RgbColor {
r: 168,
g: 168,
b: 168,
},
RgbColor {
r: 178,
g: 178,
b: 178,
},
RgbColor {
r: 188,
g: 188,
b: 188,
},
RgbColor {
r: 198,
g: 198,
b: 198,
},
RgbColor {
r: 208,
g: 208,
b: 208,
},
RgbColor {
r: 218,
g: 218,
b: 218,
},
RgbColor {
r: 228,
g: 228,
b: 228,
},
RgbColor {
r: 238,
g: 238,
b: 238,
},
]
};
#[inline]
pub fn rgb_to_hsv(rgb: RgbColor) -> HsvColor {
let r = rgb.r as f32 / 255.0;
let g = rgb.g as f32 / 255.0;
let b = rgb.b as f32 / 255.0;
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let delta = max - min;
let h = if delta == 0.0 {
0.0
} else if max == r {
60.0 * (((g - b) / delta) % 6.0)
} else if max == g {
60.0 * (((b - r) / delta) + 2.0)
} else {
60.0 * (((r - g) / delta) + 4.0)
};
let s = if max == 0.0 { 0.0 } else { delta / max };
let v = max;
HsvColor {
h: if h < 0.0 { h + 360.0 } else { h },
s,
v,
}
}
#[inline]
pub fn hsv_to_rgb(hsv: HsvColor) -> RgbColor {
let h = hsv.h;
let s = hsv.s;
let v = hsv.v;
let c = v * s;
let x = c * (1.0 - ((h / 60.0) % 2.0 - 1.0).abs());
let m = v - c;
let (r, g, b) = if h < 60.0 {
(c, x, 0.0)
} else if h < 120.0 {
(x, c, 0.0)
} else if h < 180.0 {
(0.0, c, x)
} else if h < 240.0 {
(0.0, x, c)
} else if h < 300.0 {
(x, 0.0, c)
} else {
(c, 0.0, x)
};
RgbColor {
r: ((r + m) * 255.0).clamp(0.0, 255.0) as u8,
g: ((g + m) * 255.0).clamp(0.0, 255.0) as u8,
b: ((b + m) * 255.0).clamp(0.0, 255.0) as u8,
}
}
pub fn rotate_hue(hsv: HsvColor, degrees: f32) -> HsvColor {
use crate::config_defaults::math::DEGREES_IN_CIRCLE;
HsvColor {
h: (hsv.h + degrees) % DEGREES_IN_CIRCLE,
s: hsv.s,
v: hsv.v,
}
}
#[inline]
pub fn rgb_to_256(rgb: RgbColor) -> u8 {
let gray_diff = (rgb.r as i16 - rgb.g as i16).abs()
+ (rgb.g as i16 - rgb.b as i16).abs()
+ (rgb.b as i16 - rgb.r as i16).abs();
if gray_diff < 3 {
if rgb.r < 8 {
return 16;
} else if rgb.r > 248 {
return 231;
} else {
let gray_level = (rgb.r - 8) / 10;
return 232 + gray_level;
}
}
for (i, c) in ANSI_256_TO_RGB.iter().enumerate().take(16) {
let dist = ((rgb.r as i32 - c.r as i32).pow(2)
+ (rgb.g as i32 - c.g as i32).pow(2)
+ (rgb.b as i32 - c.b as i32).pow(2)) as u32;
if dist < 2000 {
return i as u8;
}
}
let r_idx = ((rgb.r as f32 / 255.0) * 5.0).round() as u8;
let g_idx = ((rgb.g as f32 / 255.0) * 5.0).round() as u8;
let b_idx = ((rgb.b as f32 / 255.0) * 5.0).round() as u8;
let r_idx = r_idx.clamp(0, 5);
let g_idx = g_idx.clamp(0, 5);
let b_idx = b_idx.clamp(0, 5);
16 + (r_idx * 36 + g_idx * 6 + b_idx)
}
pub fn invert_256_color(color_code: u8) -> u8 {
use crate::config_defaults::math::DEGREES_HALF_CIRCLE;
let rgb = ANSI_256_TO_RGB[color_code as usize];
let hsv = rgb_to_hsv(rgb);
let rotated = rotate_hue(hsv, DEGREES_HALF_CIRCLE);
let new_rgb = hsv_to_rgb(rotated);
rgb_to_256(new_rgb)
}
pub fn hex_to_rgb(hex: &str) -> Option<RgbColor> {
let hex = hex.trim_start_matches('#');
if hex.len() != 6 {
return None;
}
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
Some(RgbColor { r, g, b })
}
fn compute_species_hsv(brightness: f32, base_color: RgbColor, reverse: bool) -> HsvColor {
use crate::config_defaults::hsv;
let hsv_color = rgb_to_hsv(base_color);
let brightness = if reverse {
1.0 - brightness
} else {
brightness
};
let t = brightness.clamp(0.0, 1.0);
let min_s = hsv::MIN_SATURATION;
let max_s = hsv_color.s.max(hsv::MAX_SATURATION_FLOOR);
let min_v = hsv::MIN_VALUE;
let max_v =
(hsv_color.v * hsv::MAX_VALUE_SCALE + hsv::MAX_VALUE_OFFSET).min(hsv::MAX_VALUE_CAP);
let s = min_s + (max_s - min_s) * t;
let v = min_v + (max_v - min_v) * t;
HsvColor {
h: hsv_color.h,
s,
v,
}
}
pub fn map_species_brightness(brightness: f32, base_color: RgbColor, reverse: bool) -> u8 {
let final_hsv = compute_species_hsv(brightness, base_color, reverse);
let final_rgb = hsv_to_rgb(final_hsv);
rgb_to_256(final_rgb)
}
pub fn map_species_brightness_rgb(
brightness: f32,
base_color: RgbColor,
reverse: bool,
) -> RgbColor {
let final_hsv = compute_species_hsv(brightness, base_color, reverse);
hsv_to_rgb(final_hsv)
}
thread_local! {
static CUSTOM_STOPS_CACHE: std::cell::RefCell<Option<(Vec<RgbColor>, Vec<GradientStop>)>> = const { std::cell::RefCell::new(None) };
}
pub(crate) fn get_gradient_stops(palette: &Palette) -> Vec<GradientStop> {
match palette {
Palette::Custom(colors) => {
CUSTOM_STOPS_CACHE.with(|cache| {
let mut cache = cache.borrow_mut();
if let Some((cached_colors, _)) = cache.as_ref() {
if cached_colors == colors {
return cache.as_ref().unwrap().1.clone();
}
}
let stops: Vec<GradientStop> = colors
.iter()
.enumerate()
.map(|(i, &color)| GradientStop {
position: i as f32 / (colors.len() - 1).max(1) as f32,
color,
})
.collect();
*cache = Some((colors.clone(), stops.clone()));
stops
})
}
built_in => {
get_gradient_stops_cache()
.get(built_in)
.cloned()
.unwrap_or_default()
}
}
}
fn invert_color(color_code: u8) -> u8 {
invert_256_color(color_code)
}
fn interpolate_custom_color(colors: &[RgbColor], brightness: f32) -> RgbColor {
let num_colors = colors.len();
if num_colors == 0 {
return RgbColor { r: 0, g: 0, b: 0 };
}
if num_colors == 1 {
return colors[0];
}
let t = brightness.clamp(0.0, 1.0) * (num_colors - 1) as f32;
let segment = t.floor() as usize;
let segment_t = t.fract();
let start_idx = segment.min(num_colors - 1);
let end_idx = (segment + 1).min(num_colors - 1);
let start_color = colors[start_idx];
let end_color = colors[end_idx];
RgbColor {
r: ((start_color.r as f32 * (1.0 - segment_t) + end_color.r as f32 * segment_t) as u8),
g: ((start_color.g as f32 * (1.0 - segment_t) + end_color.g as f32 * segment_t) as u8),
b: ((start_color.b as f32 * (1.0 - segment_t) + end_color.b as f32 * segment_t) as u8),
}
}
pub fn map_brightness(
brightness: f32,
palette: Palette,
reverse: bool,
invert: bool,
mapping: Option<&IntensityMapping>,
) -> u8 {
map_brightness_cycled(
brightness,
palette,
reverse,
invert,
mapping,
PaletteCycle::default(),
)
}
pub fn map_brightness_cycled(
brightness: f32,
palette: Palette,
reverse: bool,
invert: bool,
mapping: Option<&IntensityMapping>,
cycle: PaletteCycle,
) -> u8 {
let mut brightness = brightness.clamp(0.0, 1.0);
if reverse {
brightness = 1.0 - brightness;
}
if let Some(mapping) = mapping {
brightness = mapping.apply(brightness);
}
let t = cycle.map(brightness);
let color = match &palette {
Palette::Custom(colors) => {
let rgb = interpolate_custom_color(colors, t);
rgb_to_256(rgb)
}
_ => {
let gradient = gradients::get_256_gradient(palette);
let position = t * (gradient.len() - 1) as f32;
let lower = position.floor() as usize;
let upper = position.ceil() as usize;
let fraction = position - lower as f32;
if upper == lower || fraction < 0.5 {
gradient[lower]
} else {
gradient[upper]
}
}
};
if invert {
invert_color(color)
} else {
color
}
}
fn invert_rgb(rgb: RgbColor) -> RgbColor {
RgbColor {
r: 255 - rgb.r,
g: 255 - rgb.g,
b: 255 - rgb.b,
}
}
pub fn map_brightness_rgb(
brightness: f32,
palette: Palette,
reverse: bool,
invert: bool,
hue_shift: f32,
mapping: Option<&IntensityMapping>,
) -> RgbColor {
map_brightness_rgb_cycled(
brightness,
palette,
reverse,
invert,
hue_shift,
mapping,
PaletteCycle::default(),
)
}
#[allow(clippy::too_many_arguments)]
pub fn map_brightness_rgb_cycled(
brightness: f32,
palette: Palette,
reverse: bool,
invert: bool,
hue_shift: f32,
mapping: Option<&IntensityMapping>,
cycle: PaletteCycle,
) -> RgbColor {
let mut brightness = brightness.clamp(0.0, 1.0);
if reverse {
brightness = 1.0 - brightness;
}
if let Some(mapping) = mapping {
brightness = mapping.apply(brightness);
}
let t = cycle.map(brightness);
let stops = get_gradient_stops(&palette);
let mut final_color = interpolate_gradient(&stops, t);
if invert {
final_color = invert_rgb(final_color);
}
if hue_shift == 0.0 {
return final_color;
}
let hsv = rgb_to_hsv(final_color);
let rotated = rotate_hue(hsv, hue_shift);
hsv_to_rgb(rotated)
}
pub fn palette_accent_color(
palette: &Palette,
reverse: bool,
invert: bool,
hue_offset: f32,
mapping: Option<&IntensityMapping>,
) -> RgbColor {
map_brightness_rgb(0.85, palette.clone(), reverse, invert, hue_offset, mapping)
}
pub fn truecolor_ansi(r: u8, g: u8, b: u8, is_fg: bool) -> String {
format!("\x1b[{};2;{};{};{}m", if is_fg { 38 } else { 48 }, r, g, b)
}
pub fn truecolor_ansi_fg(r: u8, g: u8, b: u8) -> String {
truecolor_ansi(r, g, b, true)
}
pub fn truecolor_ansi_bg(r: u8, g: u8, b: u8) -> String {
truecolor_ansi(r, g, b, false)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TemporalMode {
Hue,
Accent,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PaletteCycleMode {
Wrap,
#[default]
Mirror,
}
impl std::fmt::Display for PaletteCycleMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PaletteCycleMode::Wrap => write!(f, "wrap"),
PaletteCycleMode::Mirror => write!(f, "mirror"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaletteCycle {
pub cycles: u32,
pub mode: PaletteCycleMode,
}
impl Default for PaletteCycle {
fn default() -> Self {
Self {
cycles: 1,
mode: PaletteCycleMode::Mirror,
}
}
}
impl PaletteCycle {
#[inline]
pub fn is_identity(&self) -> bool {
self.cycles <= 1
}
#[inline]
pub fn map(&self, t: f32) -> f32 {
if self.is_identity() {
return t;
}
let n = self.cycles.max(1) as f32;
let saw = (t * n).fract();
match self.mode {
PaletteCycleMode::Wrap => saw,
PaletteCycleMode::Mirror => 1.0 - (1.0 - saw * 2.0).abs(),
}
}
}
#[allow(clippy::too_many_arguments)]
pub fn colorize_subpixel(
brightness: f32,
palette: Palette,
reverse: bool,
invert: bool,
hue_shift: f32,
mapping: Option<&IntensityMapping>,
diff_norm: f32,
temporal_strength: f32,
temporal_mode: TemporalMode,
cycle: PaletteCycle,
temporal_accent: Option<RgbColor>,
) -> RgbColor {
let base = map_brightness_rgb_cycled(
brightness,
palette.clone(),
reverse,
invert,
hue_shift,
mapping,
cycle,
);
if temporal_strength <= 0.0 {
return base;
}
temporal_modulate(
base,
diff_norm,
temporal_mode,
temporal_strength,
palette,
temporal_accent,
)
}
const TEMPORAL_MAX_HUE_DEG: f32 = 60.0;
const TEMPORAL_TANH_K: f32 = 6.0;
fn temporal_modulate(
base: RgbColor,
diff_norm: f32,
mode: TemporalMode,
strength: f32,
palette: Palette,
accent: Option<RgbColor>,
) -> RgbColor {
let blend = (TEMPORAL_TANH_K * diff_norm).tanh(); if blend == 0.0 || strength <= 0.0 {
return base;
}
match mode {
TemporalMode::Hue => {
let oklch = srgb_to_oklch(base);
if oklch.h.is_nan() {
return base;
}
let h = oklch.h + blend * TEMPORAL_MAX_HUE_DEG * strength;
oklch_to_srgb(oklch.l, oklch.c, h)
}
TemporalMode::Accent => {
let accent =
accent.unwrap_or_else(|| palette_accent_color(&palette, false, false, 0.0, None));
let t = blend.max(0.0) * strength;
mix_oklch(base, accent, t)
}
}
}
fn lerp_hue(a: f32, b: f32, t: f32) -> f32 {
match (a.is_nan(), b.is_nan()) {
(true, true) => 0.0, (true, false) => b,
(false, true) => a,
(false, false) => {
let mut delta = b - a;
if delta > 180.0 {
delta -= 360.0;
} else if delta < -180.0 {
delta += 360.0;
}
a + delta * t
}
}
}
fn mix_oklch(a: RgbColor, b: RgbColor, t: f32) -> RgbColor {
let t = t.clamp(0.0, 1.0);
if t <= 0.0 {
return a;
}
if t >= 1.0 {
return b;
}
let oa = srgb_to_oklch(a);
let ob = srgb_to_oklch(b);
let l = oa.l + (ob.l - oa.l) * t;
let c = oa.c + (ob.c - oa.c) * t;
let h = lerp_hue(oa.h, ob.h, t);
oklch_to_srgb(l, c, h)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn palettes_match_all_palettes() {
assert_eq!(
PALETTES.len(),
ALL_PALETTES.len(),
"PALETTES and ALL_PALETTES differ in length"
);
for (i, (spec, palette)) in PALETTES.iter().zip(ALL_PALETTES.iter()).enumerate() {
assert_eq!(
&spec.palette, palette,
"PALETTES[{i}] ({:?}) != ALL_PALETTES[{i}] ({:?})",
spec.palette, palette
);
}
}
#[test]
fn palette_names_round_trip() {
for (i, spec) in PALETTES.iter().enumerate() {
assert_eq!(spec.palette.name(), spec.name);
let parsed = PALETTES
.iter()
.position(|s| s.name.eq_ignore_ascii_case(spec.name));
assert_eq!(parsed, Some(i), "name {} did not round-trip", spec.name);
assert_ne!(spec.name, "Custom", "built-in palette named Custom");
}
}
#[test]
fn custom_palette_name_is_custom() {
assert_eq!(Palette::Custom(vec![]).name(), "Custom");
}
#[test]
fn mix_oklch_achromatic_endpoint_preserves_chromatic_hue() {
let green = RgbColor { r: 0, g: 100, b: 6 };
let white = RgbColor {
r: 255,
g: 255,
b: 255,
};
assert_eq!(
mix_oklch(green, white, 0.0),
green,
"t=0 must be exact base"
);
assert_eq!(
mix_oklch(green, white, 1.0),
white,
"t=1 must be exact target"
);
let mid = mix_oklch(green, white, 0.5);
assert!(
mid.g >= mid.r && mid.g >= mid.b,
"green→white midpoint must stay greenish, got {mid:?}"
);
}
#[test]
fn temporal_modulate_accent_achromatic_hot_end_stays_in_palette() {
let green = RgbColor {
r: 0,
g: 120,
b: 10,
};
let out = temporal_modulate(green, 0.6, TemporalMode::Accent, 0.5, Palette::Slime, None);
assert!(
out.g >= out.r,
"growing front on Slime must not flip to magenta, got {out:?}"
);
}
#[test]
fn temporal_modulate_hue_achromatic_base_is_identity() {
let white = RgbColor {
r: 255,
g: 255,
b: 255,
};
let out = temporal_modulate(white, 0.9, TemporalMode::Hue, 1.0, Palette::Slime, None);
assert_eq!(out, white, "achromatic base must be unchanged in hue mode");
}
#[test]
fn temporal_modulate_zero_diff_is_identity() {
let base = RgbColor {
r: 120,
g: 200,
b: 80,
};
let out = temporal_modulate(base, 0.0, TemporalMode::Hue, 1.0, Palette::Organic, None);
assert_eq!(out, base);
}
#[test]
fn temporal_modulate_hue_shifts_with_positive_diff() {
let base = RgbColor {
r: 120,
g: 200,
b: 80,
};
let out = temporal_modulate(base, 0.5, TemporalMode::Hue, 1.0, Palette::Organic, None);
assert_ne!(out, base, "a growing front should change hue");
}
#[test]
fn temporal_accent_some_overrides_hot_end() {
let base = RgbColor::new(10, 80, 40);
let custom = RgbColor::new(255, 180, 60);
let with_custom = temporal_modulate(
base,
0.9,
TemporalMode::Accent,
1.0,
Palette::Slime,
Some(custom),
);
let palette_default =
temporal_modulate(base, 0.9, TemporalMode::Accent, 1.0, Palette::Slime, None);
assert_ne!(
with_custom, palette_default,
"custom accent must differ from the palette-derived accent"
);
let palette_accent = palette_accent_color(&Palette::Slime, false, false, 0.0, None);
let t = (TEMPORAL_TANH_K * 0.9_f32).tanh() * 1.0;
let expect_none = mix_oklch(base, palette_accent, t);
assert_eq!(
palette_default, expect_none,
"None must blend toward palette_accent_color"
);
}
#[test]
fn test_map_brightness_min() {
assert_eq!(
map_brightness(0.0, Palette::Organic, false, false, None),
232
);
assert_eq!(map_brightness(0.0, Palette::Heat, false, false, None), 232);
assert_eq!(map_brightness(0.0, Palette::Ocean, false, false, None), 232);
assert_eq!(map_brightness(0.0, Palette::Mono, false, false, None), 232);
assert_eq!(map_brightness(0.0, Palette::Forest, false, false, None), 22);
assert_eq!(map_brightness(0.0, Palette::Neon, false, false, None), 17);
assert_eq!(map_brightness(0.0, Palette::Warm, false, false, None), 52);
assert_eq!(
map_brightness(0.0, Palette::Vibrant, false, false, None),
197
);
assert_eq!(
map_brightness(0.0, Palette::LegibleMono, false, false, None),
236
);
}
#[test]
fn test_map_brightness_max() {
assert_eq!(
map_brightness(1.0, Palette::Organic, false, false, None),
226
);
assert_eq!(map_brightness(1.0, Palette::Heat, false, false, None), 226);
assert_eq!(map_brightness(1.0, Palette::Ocean, false, false, None), 51);
assert_eq!(map_brightness(1.0, Palette::Mono, false, false, None), 252);
assert_eq!(map_brightness(1.0, Palette::Forest, false, false, None), 40);
assert_eq!(map_brightness(1.0, Palette::Neon, false, false, None), 195);
assert_eq!(map_brightness(1.0, Palette::Warm, false, false, None), 226);
assert_eq!(
map_brightness(1.0, Palette::Vibrant, false, false, None),
231
);
assert_eq!(
map_brightness(1.0, Palette::LegibleMono, false, false, None),
255
);
}
#[test]
fn test_map_brightness_mid() {
let color = map_brightness(0.5, Palette::Organic, false, false, None);
assert_eq!(color, 46);
let color = map_brightness(0.5, Palette::Heat, false, false, None);
assert_eq!(color, 196);
let color = map_brightness(0.5, Palette::Ocean, false, false, None);
assert_eq!(color, 21);
let color = map_brightness(0.5, Palette::Mono, false, false, None);
assert_eq!(color, 242);
let color = map_brightness(0.5, Palette::Forest, false, false, None);
assert_eq!(color, 40);
let color = map_brightness(0.5, Palette::Neon, false, false, None);
assert_eq!(color, 123);
let color = map_brightness(0.5, Palette::Warm, false, false, None);
assert_eq!(color, 208);
let color = map_brightness(0.5, Palette::Vibrant, false, false, None);
assert_eq!(color, 121);
let color = map_brightness(0.5, Palette::LegibleMono, false, false, None);
assert_eq!(color, 251);
}
#[test]
fn test_map_brightness_clamped() {
assert_eq!(
map_brightness(-0.5, Palette::Organic, false, false, None),
232
);
assert_eq!(
map_brightness(1.5, Palette::Organic, false, false, None),
226
);
assert_eq!(
map_brightness(-0.5, Palette::Forest, false, false, None),
22
);
assert_eq!(map_brightness(1.5, Palette::Forest, false, false, None), 40);
}
#[test]
fn test_map_brightness_quarter() {
let color = map_brightness(0.25, Palette::Organic, false, false, None);
assert_eq!(color, 34);
let color = map_brightness(0.25, Palette::Heat, false, false, None);
assert_eq!(color, 124);
let color = map_brightness(0.25, Palette::Forest, false, false, None);
assert_eq!(color, 34);
let color = map_brightness(0.25, Palette::Neon, false, false, None);
assert_eq!(color, 51);
let color = map_brightness(0.25, Palette::Warm, false, false, None);
assert_eq!(color, 166);
}
#[test]
fn test_map_brightness_three_quarter() {
let color = map_brightness(0.75, Palette::Organic, false, false, None);
assert_eq!(color, 154);
let color = map_brightness(0.75, Palette::Heat, false, false, None);
assert_eq!(color, 214);
let color = map_brightness(0.75, Palette::Forest, false, false, None);
assert_eq!(color, 154);
let color = map_brightness(0.75, Palette::Neon, false, false, None);
assert_eq!(color, 201);
let color = map_brightness(0.75, Palette::Warm, false, false, None);
assert_eq!(color, 226);
}
#[test]
fn test_reverse_palette() {
assert_eq!(
map_brightness(0.0, Palette::Organic, true, false, None),
226
);
assert_eq!(
map_brightness(1.0, Palette::Organic, true, false, None),
232
);
}
#[test]
fn test_invert_palette() {
let normal = map_brightness(0.5, Palette::Organic, false, false, None);
let inverted = map_brightness(0.5, Palette::Organic, false, true, None);
let normal_rgb = ANSI_256_TO_RGB[normal as usize];
let inverted_rgb = ANSI_256_TO_RGB[inverted as usize];
assert_ne!(inverted, normal);
assert_ne!(inverted, 255 - normal);
let hsv_normal = rgb_to_hsv(normal_rgb);
let hsv_inverted = rgb_to_hsv(inverted_rgb);
let hue_diff = (hsv_inverted.h - hsv_normal.h).abs();
assert!(hue_diff > 170.0 && hue_diff < 190.0);
}
#[test]
fn test_reverse_and_invert_palette() {
let reversed = map_brightness(0.0, Palette::Organic, true, false, None);
let reversed_and_inverted = map_brightness(0.0, Palette::Organic, true, true, None);
let inverted = invert_256_color(reversed);
assert_eq!(reversed_and_inverted, inverted);
}
#[test]
fn test_map_brightness_rgb_min() {
let color = map_brightness_rgb(0.0, Palette::Organic, false, false, 0.0, None);
assert_eq!(color.r, 18);
assert_eq!(color.g, 18);
assert_eq!(color.b, 18);
}
#[test]
fn test_map_brightness_rgb_max() {
let color = map_brightness_rgb(1.0, Palette::Organic, false, false, 0.0, None);
assert_eq!(color.r, 150);
assert_eq!(color.g, 220);
assert_eq!(color.b, 200);
}
#[test]
fn test_map_brightness_rgb_interpolation() {
let color = map_brightness_rgb(0.5, Palette::Organic, false, false, 0.0, None);
assert!(color.r >= 18 && color.r <= 160);
assert!(color.g >= 18 && color.g <= 220);
assert!(color.b >= 18 && color.b <= 200);
let color = map_brightness_rgb(0.5, Palette::Ocean, false, false, 0.0, None);
assert!(color.r >= 18 && color.r <= 80);
assert!(color.g >= 18 && color.g <= 170);
assert!(color.b >= 18 && color.b <= 240);
}
#[test]
fn test_map_brightness_rgb_heat() {
let min_color = map_brightness_rgb(0.0, Palette::Heat, false, false, 0.0, None);
let max_color = map_brightness_rgb(1.0, Palette::Heat, false, false, 0.0, None);
assert_eq!(min_color.r, 40);
assert_eq!(min_color.g, 20);
assert_eq!(min_color.b, 20);
assert_eq!(max_color.r, 240);
assert_eq!(max_color.g, 220);
assert_eq!(max_color.b, 180);
}
#[test]
fn test_map_brightness_rgb_ocean() {
let min_color = map_brightness_rgb(0.0, Palette::Ocean, false, false, 0.0, None);
let max_color = map_brightness_rgb(1.0, Palette::Ocean, false, false, 0.0, None);
assert_eq!(min_color.r, 18);
assert_eq!(min_color.g, 18);
assert_eq!(min_color.b, 18);
assert_eq!(max_color.r, 80);
assert_eq!(max_color.g, 170);
assert_eq!(max_color.b, 240);
}
#[test]
fn test_map_brightness_rgb_forest() {
let min_color = map_brightness_rgb(0.0, Palette::Forest, false, false, 0.0, None);
let max_color = map_brightness_rgb(1.0, Palette::Forest, false, false, 0.0, None);
assert_eq!(min_color.r, 20);
assert_eq!(min_color.g, 40);
assert_eq!(min_color.b, 20);
assert_eq!(max_color.r, 180);
assert_eq!(max_color.g, 220);
assert_eq!(max_color.b, 170);
}
#[test]
fn test_map_brightness_rgb_reverse() {
let normal = map_brightness_rgb(0.0, Palette::Organic, false, false, 0.0, None);
let reversed = map_brightness_rgb(1.0, Palette::Organic, true, false, 0.0, None);
assert_eq!(normal.r, reversed.r);
assert_eq!(normal.g, reversed.g);
assert_eq!(normal.b, reversed.b);
}
#[test]
fn test_map_brightness_rgb_invert() {
let normal = map_brightness_rgb(0.5, Palette::Organic, false, false, 0.0, None);
let inverted = map_brightness_rgb(0.5, Palette::Organic, false, true, 0.0, None);
assert_eq!(inverted.r, 255 - normal.r);
assert_eq!(inverted.g, 255 - normal.g);
assert_eq!(inverted.b, 255 - normal.b);
}
#[test]
fn test_map_brightness_rgb_all_palettes() {
let palettes = [
Palette::Organic,
Palette::Heat,
Palette::Ocean,
Palette::Mono,
Palette::Forest,
Palette::Neon,
Palette::Warm,
Palette::Vibrant,
Palette::LegibleMono,
Palette::Slime,
Palette::Mold,
Palette::Fungus,
Palette::Swamp,
Palette::Moss,
];
for palette in palettes {
let _color = map_brightness_rgb(0.5, palette, false, false, 0.0, None);
}
}
#[test]
fn test_map_brightness_rgb_clamped() {
let min = map_brightness_rgb(-0.5, Palette::Heat, false, false, 0.0, None);
let max = map_brightness_rgb(1.5, Palette::Heat, false, false, 0.0, None);
let normal = map_brightness_rgb(0.5, Palette::Heat, false, false, 0.0, None);
assert_eq!(min.r, 40);
assert_eq!(max.r, 240);
assert!(min.r <= normal.r && normal.r <= max.r);
}
#[test]
fn test_truecolor_ansi_fg() {
let code = truecolor_ansi(255, 128, 64, true);
assert_eq!(code, "\x1b[38;2;255;128;64m");
}
#[test]
fn test_truecolor_ansi_bg() {
let code = truecolor_ansi(255, 128, 64, false);
assert_eq!(code, "\x1b[48;2;255;128;64m");
}
#[test]
fn test_truecolor_ansi_fg_specific() {
let code = truecolor_ansi_fg(42, 42, 42);
assert_eq!(code, "\x1b[38;2;42;42;42m");
}
#[test]
fn test_truecolor_ansi_bg_specific() {
let code = truecolor_ansi_bg(42, 42, 42);
assert_eq!(code, "\x1b[48;2;42;42;42m");
}
#[test]
fn test_truecolor_ansi_zeros() {
let code = truecolor_ansi(0, 0, 0, true);
assert_eq!(code, "\x1b[38;2;0;0;0m");
}
#[test]
fn test_truecolor_ansi_max_values() {
let code = truecolor_ansi(255, 255, 255, true);
assert_eq!(code, "\x1b[38;2;255;255;255m");
}
#[test]
fn test_rgb_to_hsv_red() {
let hsv = rgb_to_hsv(RgbColor { r: 255, g: 0, b: 0 });
assert!((hsv.h - 0.0).abs() < 1.0);
assert!((hsv.s - 1.0).abs() < 0.01);
assert!((hsv.v - 1.0).abs() < 0.01);
}
#[test]
fn test_rgb_to_hsv_green() {
let hsv = rgb_to_hsv(RgbColor { r: 0, g: 255, b: 0 });
assert!((hsv.h - 120.0).abs() < 1.0);
assert!((hsv.s - 1.0).abs() < 0.01);
assert!((hsv.v - 1.0).abs() < 0.01);
}
#[test]
fn test_rgb_to_hsv_blue() {
let hsv = rgb_to_hsv(RgbColor { r: 0, g: 0, b: 255 });
assert!((hsv.h - 240.0).abs() < 1.0);
assert!((hsv.s - 1.0).abs() < 0.01);
assert!((hsv.v - 1.0).abs() < 0.01);
}
#[test]
fn test_rgb_to_hsv_cyan_complementary_to_red() {
let red_hsv = rgb_to_hsv(RgbColor { r: 255, g: 0, b: 0 });
let cyan_hsv = rgb_to_hsv(RgbColor {
r: 0,
g: 255,
b: 255,
});
let hue_diff = (cyan_hsv.h - red_hsv.h).abs();
assert!(hue_diff > 178.0 && hue_diff < 182.0);
}
#[test]
fn test_hsv_to_rgb_roundtrip() {
let original = RgbColor {
r: 128,
g: 64,
b: 255,
};
let hsv = rgb_to_hsv(original);
let result = hsv_to_rgb(hsv);
assert!((result.r as i16 - original.r as i16).abs() <= 1);
assert!((result.g as i16 - original.g as i16).abs() <= 1);
assert!((result.b as i16 - original.b as i16).abs() <= 1);
}
#[test]
fn test_rotate_hue_180_degrees() {
let hsv = HsvColor {
h: 0.0,
s: 1.0,
v: 1.0,
};
let rotated = rotate_hue(hsv, 180.0);
assert!((rotated.h - 180.0).abs() < 0.01);
}
#[test]
fn test_rotate_hue_wraps_around() {
let hsv = HsvColor {
h: 300.0,
s: 1.0,
v: 1.0,
};
let rotated = rotate_hue(hsv, 100.0);
assert!((rotated.h - 40.0).abs() < 0.01);
}
#[test]
fn test_invert_256_red_to_cyan() {
let red_code = 9;
let inverted = invert_256_color(red_code);
let inverted_rgb = ANSI_256_TO_RGB[inverted as usize];
assert!(inverted_rgb.r < 100 && inverted_rgb.g > 100 && inverted_rgb.b > 100);
}
#[test]
fn test_invert_256_green_to_magenta() {
let green_code = 10;
let inverted = invert_256_color(green_code);
let inverted_rgb = ANSI_256_TO_RGB[inverted as usize];
assert!(inverted_rgb.r > 100 && inverted_rgb.g < 100 && inverted_rgb.b > 100);
}
#[test]
fn test_invert_256_blue_to_yellow() {
let blue_code = 12;
let inverted = invert_256_color(blue_code);
let inverted_rgb = ANSI_256_TO_RGB[inverted as usize];
assert!(inverted_rgb.r > 100 && inverted_rgb.g > 100 && inverted_rgb.b < 100);
}
#[test]
fn test_invert_256_grayscale_unchanged() {
for code in 232..=255 {
let inverted = invert_256_color(code);
assert_eq!(
inverted, code,
"Grayscale color {} should remain unchanged when inverted",
code
);
}
}
#[test]
fn test_rgb_to_256_roundtrip() {
for (code, rgb) in ANSI_256_TO_RGB.iter().enumerate() {
let back = rgb_to_256(*rgb);
let back_rgb = ANSI_256_TO_RGB[back as usize];
let dist_orig = ((rgb.r as i32 - back_rgb.r as i32).pow(2)
+ (rgb.g as i32 - back_rgb.g as i32).pow(2)
+ (rgb.b as i32 - back_rgb.b as i32).pow(2)) as f32;
assert!(
dist_orig < 5000.0,
"Color {} should round-trip close to itself, got dist {}",
code,
dist_orig
);
}
}
#[test]
fn test_new_palettes_exist() {
let _ = Palette::Slime;
let _ = Palette::Mold;
let _ = Palette::Fungus;
let _ = Palette::Swamp;
let _ = Palette::Moss;
}
#[test]
fn test_slime_palette_gradient() {
let min_color = map_brightness(0.0, Palette::Slime, false, false, None);
let max_color = map_brightness(1.0, Palette::Slime, false, false, None);
assert_eq!(min_color, 22);
assert_eq!(max_color, 231);
}
#[test]
fn test_mold_palette_gradient() {
let min_color = map_brightness(0.0, Palette::Mold, false, false, None);
let max_color = map_brightness(1.0, Palette::Mold, false, false, None);
assert_eq!(min_color, 236);
assert_eq!(max_color, 193);
}
#[test]
fn test_fungus_palette_gradient() {
let min_color = map_brightness(0.0, Palette::Fungus, false, false, None);
let max_color = map_brightness(1.0, Palette::Fungus, false, false, None);
assert_eq!(min_color, 232);
assert_eq!(max_color, 223);
}
#[test]
fn test_swamp_palette_gradient() {
let min_color = map_brightness(0.0, Palette::Swamp, false, false, None);
let max_color = map_brightness(1.0, Palette::Swamp, false, false, None);
assert_eq!(min_color, 232);
assert_eq!(max_color, 79);
}
#[test]
fn test_moss_palette_gradient() {
let min_color = map_brightness(0.0, Palette::Moss, false, false, None);
let max_color = map_brightness(1.0, Palette::Moss, false, false, None);
assert_eq!(min_color, 22);
assert_eq!(max_color, 220);
}
#[test]
fn test_slime_palette_rgb_values() {
let color = map_brightness_rgb(0.5, Palette::Slime, false, false, 0.0, None);
assert!(color.g > color.r && color.g > color.b);
}
#[test]
fn test_fungus_palette_has_purple_tones() {
let color = map_brightness_rgb(0.3, Palette::Fungus, false, false, 0.0, None);
assert!(color.r > 50 && color.b > 50);
}
#[test]
fn test_all_new_palettes_in_all_palettes_test() {
let palettes = [
Palette::Slime,
Palette::Mold,
Palette::Fungus,
Palette::Swamp,
Palette::Moss,
];
for _ in palettes {
let _color = map_brightness_rgb(0.5, Palette::Slime, false, false, 0.0, None);
}
}
#[test]
fn test_moss_palette_has_green_tones() {
let color = map_brightness_rgb(0.5, Palette::Moss, false, false, 0.0, None);
assert!(color.g > color.r && color.g > color.b);
}
#[test]
fn test_map_brightness_rgb_hue_shift_with_invert() {
let _color_shifted = map_brightness_rgb(0.5, Palette::Organic, false, true, 90.0, None);
}
#[test]
fn test_map_brightness_rgb_hue_shift_with_reverse() {
let _color_shifted = map_brightness_rgb(0.5, Palette::Organic, true, false, 90.0, None);
}
#[test]
fn test_hex_to_rgb_valid() {
let rgb = hex_to_rgb("ff0000").unwrap();
assert_eq!(rgb.r, 255);
assert_eq!(rgb.g, 0);
assert_eq!(rgb.b, 0);
}
#[test]
fn test_hex_to_rgb_with_hash() {
let rgb = hex_to_rgb("#00ff00").unwrap();
assert_eq!(rgb.r, 0);
assert_eq!(rgb.g, 255);
assert_eq!(rgb.b, 0);
}
#[test]
fn test_hex_to_rgb_invalid() {
assert!(hex_to_rgb("invalid").is_none());
assert!(hex_to_rgb("fff").is_none());
}
#[test]
fn test_map_species_brightness() {
let base_color = RgbColor { r: 255, g: 0, b: 0 };
let dark = map_species_brightness(0.0, base_color, false);
let light = map_species_brightness(1.0, base_color, false);
assert_ne!(dark, light, "Dark and light colors should be different");
}
#[test]
fn test_map_species_brightness_reverse() {
let base_color = RgbColor { r: 0, g: 0, b: 255 };
let _dark = map_species_brightness(0.0, base_color, false);
let _light = map_species_brightness(1.0, base_color, false);
let dark_rev = map_species_brightness(0.0, base_color, true);
let light_rev = map_species_brightness(1.0, base_color, true);
assert_ne!(
dark_rev, light_rev,
"Reversed dark and light should be different"
);
}
#[test]
fn test_map_species_brightness_rgb() {
let base_color = RgbColor {
r: 255,
g: 128,
b: 0,
};
let dark = map_species_brightness_rgb(0.0, base_color, false);
let light = map_species_brightness_rgb(1.0, base_color, false);
assert_ne!(dark.r, light.r, "Red channel should differ");
}
#[test]
fn test_gradient_stop_interpolation() {
let stops = vec![
GradientStop {
position: 0.0,
color: RgbColor { r: 0, g: 0, b: 0 },
},
GradientStop {
position: 1.0,
color: RgbColor {
r: 100,
g: 100,
b: 100,
},
},
];
let color = interpolate_gradient(&stops, 0.5);
assert_eq!(color.r, 50);
assert_eq!(color.g, 50);
assert_eq!(color.b, 50);
}
#[test]
fn test_gradient_stop_interpolation_multiple_stops() {
let stops = vec![
GradientStop {
position: 0.0,
color: RgbColor { r: 0, g: 0, b: 0 },
},
GradientStop {
position: 0.5,
color: RgbColor { r: 100, g: 0, b: 0 },
},
GradientStop {
position: 1.0,
color: RgbColor {
r: 100,
g: 100,
b: 100,
},
},
];
let color = interpolate_gradient(&stops, 0.25);
assert_eq!(color.r, 50);
assert_eq!(color.g, 0);
assert_eq!(color.b, 0);
let color = interpolate_gradient(&stops, 0.75);
assert_eq!(color.r, 100);
assert_eq!(color.g, 50);
assert_eq!(color.b, 50);
}
#[test]
fn test_gradient_stop_edge_cases() {
let stops = vec![
GradientStop {
position: 0.0,
color: RgbColor {
r: 50,
g: 50,
b: 50,
},
},
GradientStop {
position: 1.0,
color: RgbColor {
r: 200,
g: 200,
b: 200,
},
},
];
let color = interpolate_gradient(&stops, 0.0);
assert_eq!(color.r, 50);
let color = interpolate_gradient(&stops, 1.0);
assert_eq!(color.r, 200);
let color = interpolate_gradient(&stops, -0.5);
assert_eq!(color.r, 50);
let color = interpolate_gradient(&stops, 1.5);
assert_eq!(color.r, 200);
}
#[test]
fn test_continuous_interpolation_vs_old_system() {
let color1 = map_brightness_rgb(0.45, Palette::Heat, false, false, 0.0, None);
let color2 = map_brightness_rgb(0.50, Palette::Heat, false, false, 0.0, None);
let color3 = map_brightness_rgb(0.55, Palette::Heat, false, false, 0.0, None);
assert!(
color1.r != color2.r || color1.g != color2.g || color1.b != color2.b,
"Colors at 0.45 and 0.50 should differ"
);
assert!(
color2.r != color3.r || color2.g != color3.g || color2.b != color3.b,
"Colors at 0.50 and 0.55 should differ"
);
}
#[test]
fn test_mapping_function_linear() {
let f = MappingFunction::Linear;
assert!((f.apply(0.0) - 0.0).abs() < f32::EPSILON);
assert!((f.apply(0.5) - 0.5).abs() < f32::EPSILON);
assert!((f.apply(1.0) - 1.0).abs() < f32::EPSILON);
}
#[test]
fn test_mapping_function_logarithmic() {
let f = MappingFunction::Logarithmic { base: 10.0 };
assert!((f.apply(0.0) - 0.0).abs() < 0.001);
assert!((f.apply(1.0) - 1.0).abs() < 0.001);
assert!(f.apply(0.5) > 0.5);
}
#[test]
fn test_mapping_function_exponential() {
let f = MappingFunction::Exponential { base: 10.0 };
assert!((f.apply(0.0) - 0.0).abs() < 0.001);
assert!((f.apply(1.0) - 1.0).abs() < 0.001);
assert!(f.apply(0.5) < 0.5);
}
#[test]
fn test_mapping_function_power() {
let f_dark = MappingFunction::Power { gamma: 0.5 };
let f_bright = MappingFunction::Power { gamma: 2.0 };
assert!((f_dark.apply(0.0) - 0.0).abs() < 0.001);
assert!((f_dark.apply(1.0) - 1.0).abs() < 0.001);
assert!((f_bright.apply(0.0) - 0.0).abs() < 0.001);
assert!((f_bright.apply(1.0) - 1.0).abs() < 0.001);
assert!(f_dark.apply(0.5) > 0.5);
assert!(f_bright.apply(0.5) < 0.5);
}
#[test]
fn test_mapping_function_smoothstep() {
let f = MappingFunction::Smoothstep;
assert!((f.apply(0.0) - 0.0).abs() < 0.001);
assert!((f.apply(1.0) - 1.0).abs() < 0.001);
assert!((f.apply(0.5) - 0.5).abs() < 0.001);
}
#[test]
fn test_mapping_function_quantize() {
let f = MappingFunction::Quantize { levels: 4 };
assert!((f.apply(0.0) - 0.0).abs() < 0.001);
assert!((f.apply(0.33) - 0.333).abs() < 0.1);
assert!((f.apply(1.0) - 1.0).abs() < 0.001);
}
#[test]
fn test_mapping_function_perlin() {
let f = MappingFunction::Perlin {
amplitude: 0.2,
frequency: 5.0,
seed: 42,
};
assert!(f.apply(0.0).abs() < 0.01);
assert!((f.apply(1.0) - 1.0).abs() < 0.01);
let mid = f.apply(0.5);
assert!((0.0..=1.0).contains(&mid));
}
#[test]
fn test_intensity_mapping_linear() {
let mapping = IntensityMapping::linear();
assert!((mapping.apply(0.0) - 0.0).abs() < f32::EPSILON);
assert!((mapping.apply(0.5) - 0.5).abs() < f32::EPSILON);
assert!((mapping.apply(1.0) - 1.0).abs() < f32::EPSILON);
}
#[test]
fn test_intensity_mapping_linear_log_split() {
let mapping = IntensityMapping::linear_log_split(10.0);
assert!((mapping.apply(0.0) - 0.0).abs() < 0.001);
assert!((mapping.apply(1.0) - 1.0).abs() < 0.001);
let split = 6.0 / 11.0;
assert!((mapping.apply(split) - split).abs() < 0.001);
}
#[test]
fn test_intensity_mapping_validation_empty() {
let result = IntensityMapping::new(vec![]);
assert!(matches!(result, Err(MappingError::NoSegments)));
}
#[test]
fn test_intensity_mapping_validation_gap() {
let result = IntensityMapping::new(vec![
MappingSegment {
start: 0.0,
end: 0.4,
function: MappingFunction::Linear,
},
MappingSegment {
start: 0.6, end: 1.0,
function: MappingFunction::Linear,
},
]);
assert!(matches!(result, Err(MappingError::SegmentGap { .. })));
}
#[test]
fn test_intensity_mapping_validation_not_starting_at_zero() {
let result = IntensityMapping::new(vec![MappingSegment {
start: 0.1,
end: 1.0,
function: MappingFunction::Linear,
}]);
assert!(matches!(result, Err(MappingError::DomainNotCovered { .. })));
}
#[test]
fn test_intensity_mapping_validation_not_ending_at_one() {
let result = IntensityMapping::new(vec![MappingSegment {
start: 0.0,
end: 0.9,
function: MappingFunction::Linear,
}]);
assert!(matches!(result, Err(MappingError::DomainNotCovered { .. })));
}
#[test]
fn test_map_brightness_with_logarithmic_mapping() {
let mapping = IntensityMapping::logarithmic(10.0);
let without_mapping = map_brightness(0.5, Palette::Organic, false, false, None);
let with_mapping = map_brightness(0.5, Palette::Organic, false, false, Some(&mapping));
assert_ne!(without_mapping, with_mapping);
}
#[test]
fn test_map_brightness_rgb_with_linear_log_split() {
let mapping = IntensityMapping::linear_log_split(10.0);
let without_mapping = map_brightness_rgb(0.9, Palette::Heat, false, false, 0.0, None);
let with_mapping =
map_brightness_rgb(0.9, Palette::Heat, false, false, 0.0, Some(&mapping));
assert!(
without_mapping.r != with_mapping.r
|| without_mapping.g != with_mapping.g
|| without_mapping.b != with_mapping.b
);
}
#[test]
fn test_oklch_grayscale_returns_nan_hue() {
let gray_colors = [
RgbColor { r: 0, g: 0, b: 0 }, RgbColor {
r: 128,
g: 128,
b: 128,
}, RgbColor {
r: 255,
g: 255,
b: 255,
}, ];
for color in gray_colors {
let oklch = srgb_to_oklch(color);
assert!(
oklch.c < OKLCH_EPSILON || oklch.h.is_nan(),
"Grayscale color {:?} should have chroma < epsilon or NaN hue, got c={}, h={}",
color,
oklch.c,
oklch.h
);
}
}
#[test]
fn test_oklch_color_returns_valid_hue() {
let color = RgbColor { r: 255, g: 0, b: 0 }; let oklch = srgb_to_oklch(color);
assert!(
oklch.c >= OKLCH_EPSILON,
"Red should have significant chroma, got {}",
oklch.c
);
assert!(
oklch.h.is_finite(),
"Red should have finite hue, got {}",
oklch.h
);
assert!(
oklch.h > 20.0 && oklch.h < 40.0,
"Red hue should be around 30° in OKLch, got {}",
oklch.h
);
}
#[test]
fn test_oklch_to_srgb_handles_nan_hue() {
let gray = oklch_to_srgb(0.5, 0.0, f32::NAN);
assert!(
gray.r.abs_diff(gray.g) <= 1 && gray.g.abs_diff(gray.b) <= 1,
"NaN hue with zero chroma should produce grayscale, got R={}, G={}, B={}",
gray.r,
gray.g,
gray.b
);
}
#[test]
fn test_oklch_roundtrip_preserves_color() {
let original = RgbColor {
r: 100,
g: 150,
b: 80,
};
let oklch = srgb_to_oklch(original);
let restored = oklch_to_rgb(oklch);
let tolerance = 2;
assert!(
original.r.abs_diff(restored.r) <= tolerance
&& original.g.abs_diff(restored.g) <= tolerance
&& original.b.abs_diff(restored.b) <= tolerance,
"OKLch roundtrip failed: {:?} -> {:?}",
original,
restored
);
}
#[test]
fn test_oklch_epsilon_boundary() {
let near_gray = RgbColor {
r: 100,
g: 101,
b: 100,
};
let oklch = srgb_to_oklch(near_gray);
if oklch.c < OKLCH_EPSILON {
assert!(oklch.h.is_nan(), "When chroma < epsilon, hue should be NaN");
}
}
#[test]
fn colorize_subpixel_matches_map_brightness_rgb_when_no_temporal() {
let p = Palette::Organic;
let expected = map_brightness_rgb(0.6, p.clone(), false, false, 0.0, None);
let got = colorize_subpixel(
0.6,
p,
false,
false,
0.0,
None,
0.0,
0.0,
TemporalMode::Hue,
PaletteCycle::default(),
None,
);
assert_eq!(got, expected);
}
#[test]
fn palette_cycle_identity_is_noop() {
let id = PaletteCycle::default();
assert!(id.is_identity());
for &t in &[0.0_f32, 0.25, 0.5, 0.75, 1.0] {
assert_eq!(id.map(t), t, "n=1 must be identity at t={t}");
}
let zero = PaletteCycle {
cycles: 0,
mode: PaletteCycleMode::Wrap,
};
assert!(zero.is_identity());
assert_eq!(zero.map(0.4), 0.4);
}
#[test]
fn palette_cycle_wrap_is_sawtooth() {
let c = PaletteCycle {
cycles: 2,
mode: PaletteCycleMode::Wrap,
};
assert!((c.map(0.0) - 0.0).abs() < 1e-6);
assert!((c.map(0.25) - 0.5).abs() < 1e-6);
assert!((c.map(0.5) - 0.0).abs() < 1e-6); assert!((c.map(0.75) - 0.5).abs() < 1e-6);
}
#[test]
fn palette_cycle_mirror_is_triangle() {
let c = PaletteCycle {
cycles: 2,
mode: PaletteCycleMode::Mirror,
};
assert!((c.map(0.0) - 0.0).abs() < 1e-6);
assert!((c.map(0.25) - 1.0).abs() < 1e-6); assert!((c.map(0.5) - 0.0).abs() < 1e-6); assert!((c.map(0.75) - 1.0).abs() < 1e-6);
for i in 0..=100 {
let t = i as f32 / 100.0;
let o = c.map(t);
assert!(
(0.0..=1.0).contains(&o),
"mirror out of range at t={t}: {o}"
);
}
}
#[test]
fn palette_cycle_mode_from_str() {
use std::str::FromStr;
assert_eq!(
PaletteCycleMode::from_str("wrap").unwrap(),
PaletteCycleMode::Wrap
);
assert_eq!(
PaletteCycleMode::from_str("MIRROR").unwrap(),
PaletteCycleMode::Mirror
);
assert!(PaletteCycleMode::from_str("bogus").is_err());
}
#[test]
fn cycled_core_identity_matches_legacy() {
let id = PaletteCycle::default();
for &b in &[0.0_f32, 0.3, 0.6, 1.0] {
assert_eq!(
map_brightness_rgb_cycled(b, Palette::Organic, false, false, 0.0, None, id),
map_brightness_rgb(b, Palette::Organic, false, false, 0.0, None),
);
assert_eq!(
map_brightness_cycled(b, Palette::Organic, false, false, None, id),
map_brightness(b, Palette::Organic, false, false, None),
);
}
}
#[test]
fn cycled_core_changes_color_when_active() {
let active = PaletteCycle {
cycles: 2,
mode: PaletteCycleMode::Mirror,
};
let id = PaletteCycle::default();
let on = map_brightness_rgb_cycled(0.25, Palette::Organic, false, false, 0.0, None, active);
let off = map_brightness_rgb_cycled(0.25, Palette::Organic, false, false, 0.0, None, id);
assert_ne!(on, off, "cycles=2 must remap the gradient index");
}
#[test]
fn colorize_subpixel_identity_cycle_matches_map_brightness_rgb() {
let id = PaletteCycle::default();
let got = colorize_subpixel(
0.6,
Palette::Organic,
false,
false,
0.0,
None,
0.0,
0.0,
TemporalMode::Hue,
id,
None,
);
let want = map_brightness_rgb(0.6, Palette::Organic, false, false, 0.0, None);
assert_eq!(got, want);
}
}