use ratatui::style::Color;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Theme {
pub name: String,
#[serde(with = "color_serde")]
pub bg: Color,
#[serde(with = "color_serde")]
pub bg_selected: Color,
#[serde(with = "color_serde")]
pub fg: Color,
#[serde(with = "color_serde")]
pub fg_secondary: Color,
#[serde(with = "color_serde")]
pub fg_muted: Color,
#[serde(with = "color_serde")]
pub accent: Color,
#[serde(with = "color_serde")]
pub accent_secondary: Color,
#[serde(with = "color_serde")]
pub success: Color,
#[serde(with = "color_serde")]
pub error: Color,
#[serde(with = "color_serde")]
pub warning: Color,
#[serde(with = "color_serde")]
pub sender_self: Color,
#[serde(with = "color_array_serde")]
pub sender_palette: [Color; 8],
#[serde(with = "color_serde")]
pub link: Color,
#[serde(with = "color_serde")]
pub mention: Color,
#[serde(with = "color_serde")]
pub quote: Color,
#[serde(with = "color_serde")]
pub system_msg: Color,
#[serde(with = "color_serde")]
pub msg_selected_bg: Color,
#[serde(with = "color_serde")]
pub input_insert: Color,
#[serde(with = "color_serde")]
pub input_normal: Color,
#[serde(with = "color_serde")]
pub statusbar_bg: Color,
#[serde(with = "color_serde")]
pub statusbar_fg: Color,
#[serde(with = "color_serde")]
pub receipt_failed: Color,
#[serde(with = "color_serde")]
pub receipt_sending: Color,
#[serde(with = "color_serde")]
pub receipt_sent: Color,
#[serde(with = "color_serde")]
pub receipt_delivered: Color,
#[serde(with = "color_serde")]
pub receipt_read: Color,
#[serde(with = "color_serde")]
pub receipt_viewed: Color,
}
pub fn default_theme() -> Theme {
Theme {
name: "Default".into(),
bg: Color::Black,
bg_selected: Color::DarkGray,
fg: Color::White,
fg_secondary: Color::Gray,
fg_muted: Color::DarkGray,
accent: Color::Cyan,
accent_secondary: Color::Yellow,
success: Color::Green,
error: Color::Red,
warning: Color::Yellow,
sender_self: Color::Green,
sender_palette: [
Color::Cyan,
Color::Magenta,
Color::Yellow,
Color::Blue,
Color::LightRed,
Color::LightGreen,
Color::LightCyan,
Color::LightMagenta,
],
link: Color::Blue,
mention: Color::Cyan,
quote: Color::DarkGray,
system_msg: Color::DarkGray,
msg_selected_bg: Color::Indexed(236),
input_insert: Color::Cyan,
input_normal: Color::Yellow,
statusbar_bg: Color::DarkGray,
statusbar_fg: Color::White,
receipt_failed: Color::Red,
receipt_sending: Color::DarkGray,
receipt_sent: Color::DarkGray,
receipt_delivered: Color::White,
receipt_read: Color::Green,
receipt_viewed: Color::Cyan,
}
}
fn catppuccin_mocha() -> Theme {
Theme {
name: "Catppuccin Mocha".into(),
bg: Color::Rgb(30, 30, 46), bg_selected: Color::Rgb(69, 71, 90), fg: Color::Rgb(205, 214, 244), fg_secondary: Color::Rgb(166, 173, 200), fg_muted: Color::Rgb(108, 112, 134), accent: Color::Rgb(203, 166, 247), accent_secondary: Color::Rgb(249, 226, 175), success: Color::Rgb(166, 227, 161), error: Color::Rgb(243, 139, 168), warning: Color::Rgb(249, 226, 175), sender_self: Color::Rgb(166, 227, 161), sender_palette: [
Color::Rgb(137, 180, 250), Color::Rgb(245, 194, 231), Color::Rgb(249, 226, 175), Color::Rgb(116, 199, 236), Color::Rgb(243, 139, 168), Color::Rgb(166, 227, 161), Color::Rgb(148, 226, 213), Color::Rgb(203, 166, 247), ],
link: Color::Rgb(137, 180, 250), mention: Color::Rgb(203, 166, 247), quote: Color::Rgb(108, 112, 134), system_msg: Color::Rgb(108, 112, 134), msg_selected_bg: Color::Rgb(49, 50, 68), input_insert: Color::Rgb(203, 166, 247), input_normal: Color::Rgb(249, 226, 175), statusbar_bg: Color::Rgb(24, 24, 37), statusbar_fg: Color::Rgb(205, 214, 244), receipt_failed: Color::Rgb(243, 139, 168),
receipt_sending: Color::Rgb(108, 112, 134),
receipt_sent: Color::Rgb(108, 112, 134),
receipt_delivered: Color::Rgb(205, 214, 244),
receipt_read: Color::Rgb(166, 227, 161),
receipt_viewed: Color::Rgb(137, 180, 250),
}
}
fn catppuccin_latte() -> Theme {
Theme {
name: "Catppuccin Latte".into(),
bg: Color::Rgb(239, 241, 245), bg_selected: Color::Rgb(188, 192, 204), fg: Color::Rgb(76, 79, 105), fg_secondary: Color::Rgb(108, 111, 133), fg_muted: Color::Rgb(140, 143, 161), accent: Color::Rgb(136, 57, 239), accent_secondary: Color::Rgb(223, 142, 29), success: Color::Rgb(64, 160, 43), error: Color::Rgb(210, 15, 57), warning: Color::Rgb(223, 142, 29), sender_self: Color::Rgb(64, 160, 43), sender_palette: [
Color::Rgb(30, 102, 245), Color::Rgb(234, 118, 203), Color::Rgb(223, 142, 29), Color::Rgb(32, 159, 181), Color::Rgb(210, 15, 57), Color::Rgb(64, 160, 43), Color::Rgb(23, 146, 153), Color::Rgb(136, 57, 239), ],
link: Color::Rgb(30, 102, 245), mention: Color::Rgb(136, 57, 239), quote: Color::Rgb(140, 143, 161), system_msg: Color::Rgb(140, 143, 161), msg_selected_bg: Color::Rgb(204, 208, 218), input_insert: Color::Rgb(136, 57, 239), input_normal: Color::Rgb(223, 142, 29), statusbar_bg: Color::Rgb(230, 233, 239), statusbar_fg: Color::Rgb(76, 79, 105), receipt_failed: Color::Rgb(210, 15, 57),
receipt_sending: Color::Rgb(140, 143, 161),
receipt_sent: Color::Rgb(140, 143, 161),
receipt_delivered: Color::Rgb(76, 79, 105),
receipt_read: Color::Rgb(64, 160, 43),
receipt_viewed: Color::Rgb(30, 102, 245),
}
}
fn dracula() -> Theme {
Theme {
name: "Dracula".into(),
bg: Color::Rgb(40, 42, 54), bg_selected: Color::Rgb(68, 71, 90), fg: Color::Rgb(248, 248, 242), fg_secondary: Color::Rgb(189, 147, 249), fg_muted: Color::Rgb(98, 114, 164), accent: Color::Rgb(189, 147, 249), accent_secondary: Color::Rgb(241, 250, 140), success: Color::Rgb(80, 250, 123), error: Color::Rgb(255, 85, 85), warning: Color::Rgb(241, 250, 140), sender_self: Color::Rgb(80, 250, 123), sender_palette: [
Color::Rgb(139, 233, 253), Color::Rgb(255, 121, 198), Color::Rgb(241, 250, 140), Color::Rgb(189, 147, 249), Color::Rgb(255, 85, 85), Color::Rgb(80, 250, 123), Color::Rgb(255, 184, 108), Color::Rgb(139, 233, 253), ],
link: Color::Rgb(139, 233, 253), mention: Color::Rgb(255, 121, 198), quote: Color::Rgb(98, 114, 164), system_msg: Color::Rgb(98, 114, 164), msg_selected_bg: Color::Rgb(55, 57, 69),
input_insert: Color::Rgb(189, 147, 249), input_normal: Color::Rgb(241, 250, 140), statusbar_bg: Color::Rgb(33, 34, 44),
statusbar_fg: Color::Rgb(248, 248, 242),
receipt_failed: Color::Rgb(255, 85, 85),
receipt_sending: Color::Rgb(98, 114, 164),
receipt_sent: Color::Rgb(98, 114, 164),
receipt_delivered: Color::Rgb(248, 248, 242),
receipt_read: Color::Rgb(80, 250, 123),
receipt_viewed: Color::Rgb(139, 233, 253),
}
}
fn nord() -> Theme {
Theme {
name: "Nord".into(),
bg: Color::Rgb(46, 52, 64), bg_selected: Color::Rgb(67, 76, 94), fg: Color::Rgb(236, 239, 244), fg_secondary: Color::Rgb(216, 222, 233), fg_muted: Color::Rgb(76, 86, 106), accent: Color::Rgb(136, 192, 208), accent_secondary: Color::Rgb(235, 203, 139), success: Color::Rgb(163, 190, 140), error: Color::Rgb(191, 97, 106), warning: Color::Rgb(235, 203, 139), sender_self: Color::Rgb(163, 190, 140), sender_palette: [
Color::Rgb(136, 192, 208), Color::Rgb(180, 142, 173), Color::Rgb(235, 203, 139), Color::Rgb(129, 161, 193), Color::Rgb(191, 97, 106), Color::Rgb(163, 190, 140), Color::Rgb(143, 188, 187), Color::Rgb(208, 135, 112), ],
link: Color::Rgb(129, 161, 193), mention: Color::Rgb(180, 142, 173), quote: Color::Rgb(76, 86, 106), system_msg: Color::Rgb(76, 86, 106), msg_selected_bg: Color::Rgb(59, 66, 82), input_insert: Color::Rgb(136, 192, 208), input_normal: Color::Rgb(235, 203, 139), statusbar_bg: Color::Rgb(59, 66, 82), statusbar_fg: Color::Rgb(236, 239, 244), receipt_failed: Color::Rgb(191, 97, 106),
receipt_sending: Color::Rgb(76, 86, 106),
receipt_sent: Color::Rgb(76, 86, 106),
receipt_delivered: Color::Rgb(236, 239, 244),
receipt_read: Color::Rgb(163, 190, 140),
receipt_viewed: Color::Rgb(136, 192, 208),
}
}
fn gruvbox_dark() -> Theme {
Theme {
name: "Gruvbox Dark".into(),
bg: Color::Rgb(40, 40, 40), bg_selected: Color::Rgb(80, 73, 69), fg: Color::Rgb(235, 219, 178), fg_secondary: Color::Rgb(189, 174, 147), fg_muted: Color::Rgb(124, 111, 100), accent: Color::Rgb(254, 128, 25), accent_secondary: Color::Rgb(250, 189, 47), success: Color::Rgb(184, 187, 38), error: Color::Rgb(251, 73, 52), warning: Color::Rgb(250, 189, 47), sender_self: Color::Rgb(184, 187, 38), sender_palette: [
Color::Rgb(131, 165, 152), Color::Rgb(211, 134, 155), Color::Rgb(250, 189, 47), Color::Rgb(69, 133, 136), Color::Rgb(251, 73, 52), Color::Rgb(184, 187, 38), Color::Rgb(254, 128, 25), Color::Rgb(142, 192, 124), ],
link: Color::Rgb(131, 165, 152), mention: Color::Rgb(211, 134, 155), quote: Color::Rgb(124, 111, 100), system_msg: Color::Rgb(124, 111, 100), msg_selected_bg: Color::Rgb(60, 56, 54), input_insert: Color::Rgb(254, 128, 25), input_normal: Color::Rgb(250, 189, 47), statusbar_bg: Color::Rgb(50, 48, 47), statusbar_fg: Color::Rgb(235, 219, 178), receipt_failed: Color::Rgb(251, 73, 52),
receipt_sending: Color::Rgb(124, 111, 100),
receipt_sent: Color::Rgb(124, 111, 100),
receipt_delivered: Color::Rgb(235, 219, 178),
receipt_read: Color::Rgb(184, 187, 38),
receipt_viewed: Color::Rgb(131, 165, 152),
}
}
fn mirc_dark() -> Theme {
Theme {
name: "mIRC Dark".into(),
bg: Color::Black,
bg_selected: Color::DarkGray,
fg: Color::White,
fg_secondary: Color::Gray,
fg_muted: Color::DarkGray,
accent: Color::LightGreen,
accent_secondary: Color::LightYellow,
success: Color::LightGreen,
error: Color::LightRed,
warning: Color::LightYellow,
sender_self: Color::LightGreen,
sender_palette: [
Color::LightCyan,
Color::LightMagenta,
Color::LightYellow,
Color::LightBlue,
Color::LightRed,
Color::LightGreen,
Color::Cyan,
Color::Magenta,
],
link: Color::LightBlue,
mention: Color::LightCyan,
quote: Color::DarkGray,
system_msg: Color::DarkGray,
msg_selected_bg: Color::Indexed(236),
input_insert: Color::LightGreen,
input_normal: Color::LightYellow,
statusbar_bg: Color::DarkGray,
statusbar_fg: Color::White,
receipt_failed: Color::LightRed,
receipt_sending: Color::DarkGray,
receipt_sent: Color::DarkGray,
receipt_delivered: Color::White,
receipt_read: Color::LightGreen,
receipt_viewed: Color::LightCyan,
}
}
fn mirc_light() -> Theme {
Theme {
name: "mIRC Light".into(),
bg: Color::White,
bg_selected: Color::Gray,
fg: Color::Black,
fg_secondary: Color::DarkGray,
fg_muted: Color::Gray,
accent: Color::Green,
accent_secondary: Color::Yellow,
success: Color::Green,
error: Color::Red,
warning: Color::Yellow,
sender_self: Color::Green,
sender_palette: [
Color::Cyan,
Color::Magenta,
Color::Blue,
Color::Red,
Color::Green,
Color::Yellow,
Color::DarkGray,
Color::Blue,
],
link: Color::Blue,
mention: Color::Magenta,
quote: Color::Gray,
system_msg: Color::Gray,
msg_selected_bg: Color::Indexed(254),
input_insert: Color::Green,
input_normal: Color::Yellow,
statusbar_bg: Color::Gray,
statusbar_fg: Color::Black,
receipt_failed: Color::Red,
receipt_sending: Color::Gray,
receipt_sent: Color::Gray,
receipt_delivered: Color::Black,
receipt_read: Color::Green,
receipt_viewed: Color::Cyan,
}
}
fn builtin_themes() -> Vec<Theme> {
vec![
default_theme(),
catppuccin_mocha(),
catppuccin_latte(),
dracula(),
nord(),
gruvbox_dark(),
mirc_dark(),
mirc_light(),
]
}
pub fn load_custom_themes() -> Vec<Theme> {
let dir = match dirs::config_dir() {
Some(d) => d.join("siggy").join("themes"),
None => return Vec::new(),
};
if !dir.is_dir() {
return Vec::new();
}
let mut themes = Vec::new();
let entries = match std::fs::read_dir(&dir) {
Ok(e) => e,
Err(e) => {
crate::debug_log::logf(format_args!("custom themes dir read error: {e}"));
return Vec::new();
}
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("toml") {
continue;
}
match std::fs::read_to_string(&path) {
Ok(contents) => match toml::from_str::<Theme>(&contents) {
Ok(theme) => themes.push(theme),
Err(e) => {
crate::debug_log::logf(format_args!(
"custom theme parse error {}: {e}",
path.display()
));
}
},
Err(e) => {
crate::debug_log::logf(format_args!(
"custom theme read error {}: {e}",
path.display()
));
}
}
}
themes
}
pub fn all_themes() -> Vec<Theme> {
let mut themes = builtin_themes();
themes.extend(load_custom_themes());
themes
}
pub fn find_theme(name: &str) -> Theme {
all_themes()
.into_iter()
.find(|t| t.name == name)
.unwrap_or_else(default_theme)
}
mod color_serde {
use ratatui::style::Color;
use serde::{self, Deserialize, Deserializer, Serializer};
pub fn serialize<S>(color: &Color, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&super::color_to_string(color))
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Color, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
super::string_to_color(&s).map_err(serde::de::Error::custom)
}
}
mod color_array_serde {
use ratatui::style::Color;
use serde::{self, Deserialize, Deserializer, Serializer};
pub fn serialize<S>(colors: &[Color; 8], serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
use serde::ser::SerializeSeq;
let mut seq = serializer.serialize_seq(Some(8))?;
for c in colors {
seq.serialize_element(&super::color_to_string(c))?;
}
seq.end()
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<[Color; 8], D::Error>
where
D: Deserializer<'de>,
{
let strings: Vec<String> = Vec::deserialize(deserializer)?;
if strings.len() != 8 {
return Err(serde::de::Error::custom(format!(
"expected 8 colors, got {}",
strings.len()
)));
}
let mut colors = [Color::Reset; 8];
for (i, s) in strings.iter().enumerate() {
colors[i] = super::string_to_color(s).map_err(serde::de::Error::custom)?;
}
Ok(colors)
}
}
fn color_to_string(color: &Color) -> String {
match color {
Color::Rgb(r, g, b) => format!("#{r:02x}{g:02x}{b:02x}"),
Color::Indexed(i) => format!("indexed({i})"),
Color::Reset => "reset".into(),
Color::Black => "black".into(),
Color::Red => "red".into(),
Color::Green => "green".into(),
Color::Yellow => "yellow".into(),
Color::Blue => "blue".into(),
Color::Magenta => "magenta".into(),
Color::Cyan => "cyan".into(),
Color::Gray => "gray".into(),
Color::DarkGray => "dark_gray".into(),
Color::LightRed => "light_red".into(),
Color::LightGreen => "light_green".into(),
Color::LightYellow => "light_yellow".into(),
Color::LightBlue => "light_blue".into(),
Color::LightMagenta => "light_magenta".into(),
Color::LightCyan => "light_cyan".into(),
Color::White => "white".into(),
}
}
fn string_to_color(s: &str) -> Result<Color, String> {
let s = s.trim();
if let Some(hex) = s.strip_prefix('#') {
if hex.len() == 6 {
let r = u8::from_str_radix(&hex[0..2], 16).map_err(|e| format!("bad hex red: {e}"))?;
let g =
u8::from_str_radix(&hex[2..4], 16).map_err(|e| format!("bad hex green: {e}"))?;
let b = u8::from_str_radix(&hex[4..6], 16).map_err(|e| format!("bad hex blue: {e}"))?;
return Ok(Color::Rgb(r, g, b));
}
return Err(format!("hex color must be 6 digits: {s}"));
}
if let Some(inner) = s.strip_prefix("indexed(").and_then(|s| s.strip_suffix(')')) {
let i: u8 = inner.parse().map_err(|e| format!("bad index: {e}"))?;
return Ok(Color::Indexed(i));
}
match s.to_lowercase().as_str() {
"reset" => Ok(Color::Reset),
"black" => Ok(Color::Black),
"red" => Ok(Color::Red),
"green" => Ok(Color::Green),
"yellow" => Ok(Color::Yellow),
"blue" => Ok(Color::Blue),
"magenta" => Ok(Color::Magenta),
"cyan" => Ok(Color::Cyan),
"gray" | "grey" => Ok(Color::Gray),
"dark_gray" | "dark_grey" | "darkgray" | "darkgrey" => Ok(Color::DarkGray),
"light_red" | "lightred" => Ok(Color::LightRed),
"light_green" | "lightgreen" => Ok(Color::LightGreen),
"light_yellow" | "lightyellow" => Ok(Color::LightYellow),
"light_blue" | "lightblue" => Ok(Color::LightBlue),
"light_magenta" | "lightmagenta" => Ok(Color::LightMagenta),
"light_cyan" | "lightcyan" => Ok(Color::LightCyan),
"white" => Ok(Color::White),
_ => Err(format!("unknown color: {s}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
#[test]
fn default_theme_has_correct_name() {
assert_eq!(default_theme().name, "Default");
}
#[test]
fn all_builtin_themes_have_unique_names() {
let themes = all_themes();
let mut names: Vec<&str> = themes.iter().map(|t| t.name.as_str()).collect();
let len = names.len();
names.sort();
names.dedup();
assert_eq!(names.len(), len, "duplicate theme names found");
}
#[test]
fn find_theme_returns_default_for_unknown() {
let t = find_theme("nonexistent");
assert_eq!(t.name, "Default");
}
#[rstest]
#[case(Color::Rgb(205, 214, 244), "#cdd6f4")]
#[case(Color::Cyan, "cyan")]
#[case(Color::Indexed(236), "indexed(236)")]
fn color_serde_roundtrip(#[case] color: Color, #[case] expected_str: &str) {
let s = color_to_string(&color);
assert_eq!(s, expected_str);
let c = string_to_color(&s).unwrap();
assert_eq!(c, color);
}
#[test]
fn theme_toml_roundtrip() {
let theme = default_theme();
let toml_str = toml::to_string_pretty(&theme).unwrap();
let parsed: Theme = toml::from_str(&toml_str).unwrap();
assert_eq!(parsed.name, theme.name);
assert_eq!(parsed.bg, theme.bg);
assert_eq!(parsed.sender_palette, theme.sender_palette);
assert_eq!(parsed.receipt_viewed, theme.receipt_viewed);
}
}