use insmaller_core::{Settings, ThemeColors};
use ratatui::style::Color;
#[derive(Debug, Clone, Copy)]
pub struct Palette {
pub accent: Color,
pub accent2: Color,
pub accent_fg: Color,
pub muted: Color,
pub error: Color,
pub border: Color,
pub border_focus: Color,
pub shadow: Color,
pub success: Color,
}
const fn rgb(hex: u32) -> Color {
Color::Rgb((hex >> 16) as u8, (hex >> 8) as u8, hex as u8)
}
impl Palette {
fn preset(name: &str) -> Palette {
match name {
"mono" => Palette {
accent: Color::Reset,
accent2: Color::Reset,
accent_fg: Color::Reset,
muted: Color::Reset,
error: Color::Reset,
border: Color::Reset,
border_focus: Color::Reset,
shadow: Color::Reset,
success: Color::Reset,
},
"high-contrast" => Palette {
accent: Color::White,
accent2: Color::White,
accent_fg: Color::Black,
muted: Color::Gray,
error: Color::LightRed,
border: Color::Gray,
border_focus: Color::White,
shadow: Color::Black,
success: Color::LightGreen,
},
"default" => Palette {
accent: Color::Cyan,
accent2: Color::Cyan,
accent_fg: Color::Black,
muted: Color::DarkGray,
error: Color::Red,
border: Color::DarkGray,
border_focus: Color::Cyan,
shadow: Color::Black,
success: Color::Green,
},
_ => Palette {
accent: rgb(0x6366f1),
accent2: rgb(0xa855f7),
accent_fg: rgb(0x0b1020),
muted: rgb(0x64748b),
error: rgb(0xfb7185),
border: rgb(0x3b3f5c),
border_focus: rgb(0x818cf8),
shadow: rgb(0x11131c),
success: rgb(0x34d399),
},
}
}
pub fn resolve(settings: &Settings) -> Palette {
let env_theme = std::env::var("INSMALLER_THEME").ok().filter(|s| !s.is_empty());
Self::resolve_with(
std::env::var_os("NO_COLOR").is_some(),
env_theme.as_deref(),
settings,
)
}
fn resolve_with(no_color: bool, env_theme: Option<&str>, settings: &Settings) -> Palette {
if no_color {
return Palette::preset("mono");
}
let name = env_theme.or(settings.theme.as_deref()).unwrap_or_default();
let mut pal = Palette::preset(name);
if let Some(c) = settings.colors.as_ref().filter(|_| pal.colored()) {
pal.apply(c);
}
pal
}
pub fn colored(&self) -> bool {
!matches!(self.accent, Color::Reset)
}
fn apply(&mut self, c: &ThemeColors) {
for (slot, hex) in [
(&mut self.accent, &c.accent),
(&mut self.accent2, &c.accent2),
(&mut self.accent_fg, &c.accent_fg),
(&mut self.muted, &c.muted),
(&mut self.error, &c.error),
(&mut self.border, &c.border),
(&mut self.border_focus, &c.border_focus),
(&mut self.shadow, &c.shadow),
(&mut self.success, &c.success),
] {
if let Some(col) = hex.as_deref().and_then(parse_hex) {
*slot = col;
} else if let Some(bad) = hex.as_deref() {
eprintln!("insmaller: ignoring invalid theme color '{bad}' (want #rrggbb)");
}
}
}
}
fn parse_hex(s: &str) -> Option<Color> {
let h = s.strip_prefix('#')?;
if h.len() != 6 || !h.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
let n = u32::from_str_radix(h, 16).ok()?;
Some(Color::Rgb(
(n >> 16) as u8,
(n >> 8) as u8,
n as u8,
))
}
pub fn gradient(a: Color, b: Color, n: usize) -> Vec<Color> {
let (Color::Rgb(ar, ag, ab), Color::Rgb(br, bg, bb)) = (a, b) else {
return vec![a; n];
};
if n <= 1 {
return vec![a; n];
}
let lerp = |x: u8, y: u8, t: f32| (x as f32 + (y as f32 - x as f32) * t).round() as u8;
(0..n)
.map(|i| {
let t = i as f32 / (n - 1) as f32;
Color::Rgb(lerp(ar, br, t), lerp(ag, bg, t), lerp(ab, bb, t))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn settings(theme: Option<&str>, colors: Option<ThemeColors>) -> Settings {
Settings {
theme: theme.map(String::from),
colors,
..Settings::default()
}
}
#[test]
fn parse_hex_accepts_only_well_formed() {
assert_eq!(parse_hex("#ff8800"), Some(Color::Rgb(0xff, 0x88, 0x00)));
assert_eq!(parse_hex("ff8800"), None); assert_eq!(parse_hex("#fff"), None); assert_eq!(parse_hex("#gg0000"), None); }
#[test]
fn unknown_preset_falls_back_to_modern() {
let p = Palette::preset("nope");
assert_eq!(p.accent, Color::Rgb(0x63, 0x66, 0xf1));
assert_eq!(p.error, Color::Rgb(0xfb, 0x71, 0x85));
let d = Palette::preset("default");
assert_eq!(d.accent, Color::Cyan);
}
#[test]
fn no_color_forces_mono_and_ignores_overrides() {
let s = settings(
Some("high-contrast"),
Some(ThemeColors {
accent: Some("#ff0000".into()),
..Default::default()
}),
);
let p = Palette::resolve_with(true, Some("default"), &s);
assert_eq!(p.accent, Color::Reset);
assert!(!p.colored());
}
#[test]
fn env_theme_beats_config_theme() {
let p = Palette::resolve_with(false, Some("mono"), &settings(Some("high-contrast"), None));
assert_eq!(p.accent, Color::Reset);
}
#[test]
fn config_theme_used_when_no_env() {
let p = Palette::resolve_with(false, None, &settings(Some("high-contrast"), None));
assert_eq!(p.accent, Color::White);
}
#[test]
fn hex_overrides_layer_on_preset() {
let s = settings(
Some("default"),
Some(ThemeColors {
accent: Some("#123456".into()),
accent2: Some("#abcdef".into()),
border_focus: Some("#0f0f0f".into()),
error: Some("bogus".into()), ..Default::default()
}),
);
let p = Palette::resolve_with(false, None, &s);
assert_eq!(p.accent, Color::Rgb(0x12, 0x34, 0x56));
assert_eq!(p.accent2, Color::Rgb(0xab, 0xcd, 0xef));
assert_eq!(p.border_focus, Color::Rgb(0x0f, 0x0f, 0x0f));
assert_eq!(p.error, Color::Red); }
#[test]
fn modern_is_default_when_unset() {
let p = Palette::resolve_with(false, None, &settings(None, None));
assert_eq!(p.accent, Color::Rgb(0x63, 0x66, 0xf1));
assert_eq!(p.accent2, Color::Rgb(0xa8, 0x55, 0xf7));
assert!(p.colored());
}
#[test]
fn mono_zeroes_new_roles() {
let p = Palette::preset("mono");
for c in [p.accent, p.accent2, p.border, p.border_focus, p.shadow, p.success] {
assert_eq!(c, Color::Reset);
}
assert!(!p.colored());
}
#[test]
fn gradient_endpoints_and_flat_for_non_rgb() {
let g = gradient(Color::Rgb(0, 0, 0), Color::Rgb(10, 20, 40), 3);
assert_eq!(g[0], Color::Rgb(0, 0, 0)); assert_eq!(g[2], Color::Rgb(10, 20, 40)); assert_eq!(g[1], Color::Rgb(5, 10, 20)); let flat = gradient(Color::Cyan, Color::Rgb(1, 2, 3), 4);
assert_eq!(flat, vec![Color::Cyan; 4]);
assert_eq!(gradient(Color::Rgb(1, 1, 1), Color::Rgb(2, 2, 2), 0), vec![]);
}
}