use gtk::gdk;
use serde::Deserialize;
use vte::prelude::*;
pub const THEME_NAMES: &[&str] = &[
"default",
"green-on-black",
"white-on-black",
"dark-pastels",
"cyberpunk",
"matrix",
"ocean",
"ember",
"solarized-dark",
"bronco",
"black-on-white",
];
pub const BRONCO_ACCENT: &str = "#0ea5e9";
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct CustomThemeDefinition {
pub name: String,
pub base: Option<String>,
pub foreground: Option<String>,
pub background: Option<String>,
pub cursor_background: Option<String>,
pub cursor_foreground: Option<String>,
pub palette: Option<Vec<String>>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ThemeCatalog {
custom: Vec<(String, TerminalTheme)>,
}
impl ThemeCatalog {
pub fn new(definitions: Vec<CustomThemeDefinition>) -> Result<Self, String> {
let mut custom: Vec<(String, TerminalTheme)> = Vec::with_capacity(definitions.len());
for definition in definitions {
validate_custom_name(&definition.name)?;
if TerminalTheme::canonical_name(&definition.name).is_ok() {
return Err(format!(
"custom theme name '{}' collides with a built-in theme or alias",
definition.name
));
}
if custom
.iter()
.any(|(name, _)| name.eq_ignore_ascii_case(&definition.name))
{
return Err(format!("duplicate custom theme name '{}'", definition.name));
}
let base = definition.base.as_deref().unwrap_or("default");
let canonical_base = TerminalTheme::canonical_name(base).map_err(|_| {
format!(
"custom theme '{}' has unknown built-in base '{base}'",
definition.name
)
})?;
let mut theme = TerminalTheme::named(canonical_base)?;
theme
.apply_overrides(
definition.foreground.as_deref(),
definition.background.as_deref(),
definition.cursor_background.as_deref(),
definition.cursor_foreground.as_deref(),
definition.palette.as_deref(),
)
.map_err(|message| format!("custom theme '{}': {message}", definition.name))?;
custom.push((definition.name, theme));
}
Ok(Self { custom })
}
pub fn resolve(&self, name: &str) -> Result<(String, TerminalTheme), String> {
let normalized = name.trim().to_ascii_lowercase();
if let Some((name, theme)) = self.custom.iter().find(|(name, _)| name == &normalized) {
return Ok((name.clone(), theme.clone()));
}
if let Ok(canonical) = TerminalTheme::canonical_name(name) {
return Ok((canonical.to_string(), TerminalTheme::named(canonical)?));
}
Err(format!(
"unknown theme '{name}'. Available themes: {}",
self.selectable_names().join(", ")
))
}
pub fn selectable_names(&self) -> Vec<String> {
THEME_NAMES
.iter()
.map(|name| (*name).to_string())
.chain(self.custom.iter().map(|(name, _)| name.clone()))
.collect()
}
}
fn validate_custom_name(name: &str) -> Result<(), String> {
let valid = !name.is_empty()
&& name == name.trim()
&& name
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
&& !name.starts_with('-')
&& !name.ends_with('-')
&& !name.contains("--");
if valid {
Ok(())
} else {
Err(format!(
"invalid custom theme name '{name}'; use a lowercase slug such as 'midnight-blue'"
))
}
}
const XFCE_PALETTE: [&str; 16] = [
"#000000", "#aa0000", "#00aa00", "#aa5500", "#0000aa", "#aa00aa", "#00aaaa", "#aaaaaa",
"#555555", "#ff5555", "#55ff55", "#ffff55", "#5555ff", "#ff55ff", "#55ffff", "#ffffff",
];
pub const XFCE_CURSOR_BACKGROUND: &str = "#e6192e";
pub const XFCE_CURSOR_FOREGROUND: &str = "#a51d2d";
const XTERM_PALETTE: [&str; 16] = [
"#000000", "#cd0000", "#00cd00", "#cdcd00", "#0000cd", "#cd00cd", "#00cdcd", "#e5e5e5",
"#7f7f7f", "#ff0000", "#00ff00", "#ffff00", "#5c5cff", "#ff00ff", "#00ffff", "#ffffff",
];
const SOLARIZED_PALETTE: [&str; 16] = [
"#073642", "#dc322f", "#859900", "#b58900", "#268bd2", "#d33682", "#2aa198", "#eee8d5",
"#002b36", "#cb4b16", "#586e75", "#657b83", "#839496", "#6c71c4", "#93a1a1", "#fdf6e3",
];
const DARK_PASTELS_PALETTE: [&str; 16] = [
"#3f3f3f", "#705050", "#60b48a", "#dfaf8f", "#9ab8d7", "#dc8cc3", "#8cd0d3", "#dcdcdc",
"#709080", "#dca3a3", "#72d5a3", "#f0dfaf", "#94bff3", "#ec93d3", "#93e0e3", "#ffffff",
];
const DRACULA_PALETTE: [&str; 16] = [
"#21222c", "#ff5555", "#50fa7b", "#f1fa8c", "#bd93f9", "#ff79c6", "#8be9fd", "#f8f8f2",
"#6272a4", "#ff6e6e", "#69ff94", "#ffffa5", "#d6acff", "#ff92df", "#a4ffff", "#ffffff",
];
const CYBERPUNK_PALETTE: [&str; 16] = [
"#0d0221", "#ff3864", "#00ff9f", "#fcee09", "#2de2e6", "#ff2a6d", "#05d9e8", "#d1f7ff",
"#241734", "#ff5c8a", "#72f1b8", "#fff36d", "#00b8ff", "#f706cf", "#7df9ff", "#ffffff",
];
const MATRIX_PALETTE: [&str; 16] = [
"#000500", "#0f6f2f", "#00ff41", "#aaff00", "#00b7ff", "#44ff99", "#00ffcc", "#c8ffc8",
"#003b12", "#22aa55", "#5dff87", "#d8ff72", "#55dfff", "#8cffb8", "#72ffe6", "#f0fff0",
];
const OCEAN_PALETTE: [&str; 16] = [
"#02111d", "#ef476f", "#2ec4b6", "#ffd166", "#00b4d8", "#bde0fe", "#48cae4", "#caf0f8",
"#073b4c", "#ff6b8a", "#64dfdf", "#ffe08a", "#48cae4", "#d6f3ff", "#90e0ef", "#ffffff",
];
const EMBER_PALETTE: [&str; 16] = [
"#160b08", "#ff5f40", "#f7c59f", "#ffbf69", "#8ecae6", "#ff6b35", "#ffa260", "#fff1db",
"#34170f", "#ff7a59", "#ffd6a5", "#ffca7a", "#a8dcf4", "#ff8a50", "#fcbf7f", "#ffffff",
];
#[derive(Debug, Clone, PartialEq)]
pub struct TerminalTheme {
foreground: gdk::RGBA,
background: gdk::RGBA,
cursor_background: Option<gdk::RGBA>,
cursor_foreground: Option<gdk::RGBA>,
palette: Vec<gdk::RGBA>,
}
impl TerminalTheme {
pub fn canonical_name(name: &str) -> Result<&'static str, String> {
let normalized = name.trim().to_ascii_lowercase();
match normalized.as_str() {
"default" | "xfce" | "xterm" => Ok("default"),
"solarized-dark" | "solarized_dark" => Ok("solarized-dark"),
"bronco" | "solarized-light" | "solarized_light" => Ok("bronco"),
"dark-pastels" | "dark_pastels" => Ok("dark-pastels"),
"dracula" | "purple-dracula" | "purple_dracula" => Ok("dracula"),
"cyberpunk" | "cyberpunk-neon" | "cyberpunk_neon" | "neon" => Ok("cyberpunk"),
"matrix" | "matrix-green" | "matrix_green" => Ok("matrix"),
"ocean" | "tide" | "tide-oracle" | "tide_oracle" | "marine" => Ok("ocean"),
"ember" | "signal-kitchen" | "signal_kitchen" | "ember-kitchen" | "ember_kitchen" => {
Ok("ember")
}
"green-on-black" | "green_on_black" => Ok("green-on-black"),
"black-on-white" | "black_on_white" => Ok("black-on-white"),
"white-on-black" | "white_on_black" => Ok("white-on-black"),
_ => Err(format!(
"unknown theme '{name}'. Available themes: {}",
THEME_NAMES.join(", ")
)),
}
}
pub fn named(name: &str) -> Result<Self, String> {
match Self::canonical_name(name)? {
"default" => Ok(Self::scheme(
"#ffffff",
"#000000",
None,
None,
&XFCE_PALETTE,
)),
"solarized-dark" => Ok(Self::scheme(
"#839496",
"#002b36",
Some("#93a1a1"),
None,
&SOLARIZED_PALETTE,
)),
"bronco" => Ok(Self::scheme(
"#fff1db",
"#160b08",
Some(BRONCO_ACCENT),
Some("#160b08"),
&EMBER_PALETTE,
)),
"dark-pastels" => Ok(Self::scheme(
"#dcdcdc",
"#2c2c2c",
Some("#dcdcdc"),
None,
&DARK_PASTELS_PALETTE,
)),
"dracula" => Ok(Self::scheme(
"#f8f8f2",
"#282a36",
Some("#bd93f9"),
Some("#282a36"),
&DRACULA_PALETTE,
)),
"cyberpunk" => Ok(Self::scheme(
"#f7f7ff",
"#080014",
Some("#ff2a6d"),
Some("#080014"),
&CYBERPUNK_PALETTE,
)),
"matrix" => Ok(Self::scheme(
"#00ff41",
"#000500",
Some("#00ff41"),
Some("#000500"),
&MATRIX_PALETTE,
)),
"ocean" => Ok(Self::scheme(
"#caf0f8",
"#061421",
Some("#00b4d8"),
Some("#061421"),
&OCEAN_PALETTE,
)),
"ember" => Ok(Self::scheme(
"#fff1db",
"#160b08",
Some("#ff9f1c"),
Some("#160b08"),
&EMBER_PALETTE,
)),
"green-on-black" => Ok(Self::scheme(
"#17f018",
"#000000",
None,
None,
&XFCE_PALETTE,
)),
"black-on-white" => Ok(Self::scheme(
"#000000",
"#ffffff",
None,
None,
&XTERM_PALETTE,
)),
"white-on-black" => Ok(Self::scheme(
"#ffffff",
"#000000",
None,
None,
&XFCE_PALETTE,
)),
_ => unreachable!("canonical theme names are exhaustive"),
}
}
pub fn parse_color(value: &str) -> Result<gdk::RGBA, String> {
gdk::RGBA::parse(value).map_err(|_| format!("invalid color '{value}'"))
}
pub fn apply_overrides(
&mut self,
foreground: Option<&str>,
background: Option<&str>,
cursor_background: Option<&str>,
cursor_foreground: Option<&str>,
palette: Option<&[String]>,
) -> Result<(), String> {
if let Some(foreground) = foreground {
self.foreground = Self::parse_color(foreground)?;
}
if let Some(background) = background {
self.background = Self::parse_color(background)?;
}
apply_optional_color(&mut self.cursor_background, cursor_background)?;
apply_optional_color(&mut self.cursor_foreground, cursor_foreground)?;
if let Some(palette) = palette {
if palette.len() != 16 {
return Err("theme.palette must contain exactly 16 colors".to_string());
}
self.palette = palette
.iter()
.map(|color| Self::parse_color(color))
.collect::<Result<Vec<_>, _>>()?;
}
Ok(())
}
pub fn with_background_alpha(&self, alpha: f64) -> Self {
let mut theme = self.clone();
theme.background.set_alpha(alpha as f32);
theme
}
pub fn background_color(&self) -> gdk::RGBA {
let mut color = self.background;
color.set_alpha(1.0);
color
}
pub fn cursor_background_color(&self) -> Option<gdk::RGBA> {
self.cursor_background
}
pub fn cursor_foreground_color(&self) -> Option<gdk::RGBA> {
self.cursor_foreground
}
fn scheme(
foreground: &str,
background: &str,
cursor_background: Option<&str>,
cursor_foreground: Option<&str>,
palette: &[&str],
) -> Self {
Self {
foreground: rgba(foreground),
background: rgba(background),
cursor_background: cursor_background.map(rgba),
cursor_foreground: cursor_foreground.map(rgba),
palette: palette.iter().map(|color| rgba(color)).collect(),
}
}
pub fn apply_to(&self, terminal: &vte::Terminal) {
let palette: Vec<&gdk::RGBA> = self.palette.iter().collect();
terminal.set_colors(Some(&self.foreground), Some(&self.background), &palette);
terminal.set_color_cursor(self.cursor_background.as_ref());
terminal.set_color_cursor_foreground(self.cursor_foreground.as_ref());
}
}
fn apply_optional_color(target: &mut Option<gdk::RGBA>, value: Option<&str>) -> Result<(), String> {
if let Some(value) = value {
*target = if value.trim().is_empty() {
None
} else {
Some(TerminalTheme::parse_color(value)?)
};
}
Ok(())
}
fn rgba(value: &str) -> gdk::RGBA {
gdk::RGBA::parse(value).unwrap_or_else(|_| panic!("invalid color literal: {value}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_theme_uses_normal_cursor() {
let theme = TerminalTheme::named("default").unwrap();
assert!(theme.cursor_background_color().is_none());
assert!(theme.cursor_foreground_color().is_none());
}
#[test]
fn cursor_foreground_override_can_be_set_and_cleared() {
let mut theme = TerminalTheme::named("default").unwrap();
theme
.apply_overrides(None, None, Some("#ff0000"), Some(""), None)
.unwrap();
assert_eq!(hex(&theme.cursor_background_color().unwrap()), "#ff0000");
assert!(theme.cursor_foreground_color().is_none());
}
#[test]
fn legacy_dracula_theme_still_loads() {
let theme = TerminalTheme::named("dracula").unwrap();
assert_eq!(hex(&theme.background_color()), "#282a36");
assert_eq!(hex(&theme.cursor_background_color().unwrap()), "#bd93f9");
assert_eq!(hex(&theme.cursor_foreground_color().unwrap()), "#282a36");
assert!(!THEME_NAMES.contains(&"dracula"));
}
#[test]
fn cyberpunk_theme_uses_neon_cursor() {
let theme = TerminalTheme::named("cyberpunk").unwrap();
assert_eq!(hex(&theme.background_color()), "#080014");
assert_eq!(hex(&theme.cursor_background_color().unwrap()), "#ff2a6d");
assert_eq!(hex(&theme.cursor_foreground_color().unwrap()), "#080014");
}
#[test]
fn matrix_theme_uses_green_cursor() {
let theme = TerminalTheme::named("matrix-green").unwrap();
assert_eq!(hex(&theme.background_color()), "#000500");
assert_eq!(hex(&theme.cursor_background_color().unwrap()), "#00ff41");
assert_eq!(hex(&theme.cursor_foreground_color().unwrap()), "#000500");
}
#[test]
fn ocean_theme_uses_tide_oracle_palette() {
let theme = TerminalTheme::named("tide-oracle").unwrap();
assert_eq!(hex(&theme.background_color()), "#061421");
assert_eq!(hex(&theme.cursor_background_color().unwrap()), "#00b4d8");
assert_eq!(hex(&theme.cursor_foreground_color().unwrap()), "#061421");
}
#[test]
fn ember_theme_uses_signal_kitchen_palette() {
let theme = TerminalTheme::named("signal-kitchen").unwrap();
assert_eq!(hex(&theme.background_color()), "#160b08");
assert_eq!(hex(&theme.cursor_background_color().unwrap()), "#ff9f1c");
assert_eq!(hex(&theme.cursor_foreground_color().unwrap()), "#160b08");
}
#[test]
fn bronco_replaces_solarized_light_with_ember_and_cyan() {
let theme = TerminalTheme::named("bronco").unwrap();
let legacy = TerminalTheme::named("solarized-light").unwrap();
assert_eq!(
TerminalTheme::canonical_name("solarized_light").unwrap(),
"bronco"
);
assert_eq!(theme, legacy);
assert_eq!(hex(&theme.background_color()), "#160b08");
assert_eq!(
hex(&theme.cursor_background_color().unwrap()),
BRONCO_ACCENT
);
assert_eq!(hex(&theme.cursor_foreground_color().unwrap()), "#160b08");
}
#[test]
fn custom_catalog_resolves_case_insensitively_with_builtin_base() {
let catalog = ThemeCatalog::new(vec![CustomThemeDefinition {
name: "my-night".to_string(),
base: Some("solarized_dark".to_string()),
background: Some("#101010".to_string()),
cursor_background: Some(String::new()),
..CustomThemeDefinition::default()
}])
.unwrap();
let (name, theme) = catalog.resolve("MY-NIGHT").unwrap();
assert_eq!(name, "my-night");
assert_eq!(hex(&theme.background_color()), "#101010");
assert!(theme.cursor_background_color().is_none());
assert_eq!(catalog.selectable_names().last().unwrap(), "my-night");
}
#[test]
fn custom_catalog_rejects_invalid_names_duplicates_and_collisions() {
for name in ["My-Night", "my night", "-night", "night-", "my--night"] {
assert!(
ThemeCatalog::new(vec![CustomThemeDefinition {
name: name.to_string(),
..CustomThemeDefinition::default()
}])
.is_err()
);
}
for name in ["default", "xfce", "xterm", "dracula", "tide-oracle"] {
assert!(
ThemeCatalog::new(vec![CustomThemeDefinition {
name: name.to_string(),
..CustomThemeDefinition::default()
}])
.is_err()
);
}
assert!(
ThemeCatalog::new(vec![
CustomThemeDefinition {
name: "night".to_string(),
..CustomThemeDefinition::default()
},
CustomThemeDefinition {
name: "night".to_string(),
..CustomThemeDefinition::default()
},
])
.is_err()
);
}
#[test]
fn legacy_xfce_and_xterm_names_resolve_to_default() {
assert_eq!(TerminalTheme::canonical_name("xfce").unwrap(), "default");
assert_eq!(TerminalTheme::canonical_name("xterm").unwrap(), "default");
assert_eq!(
TerminalTheme::named("xfce").unwrap(),
TerminalTheme::named("default").unwrap()
);
assert_eq!(
TerminalTheme::named("xterm").unwrap(),
TerminalTheme::named("default").unwrap()
);
assert!(!THEME_NAMES.contains(&"xfce"));
assert!(!THEME_NAMES.contains(&"xterm"));
}
#[test]
fn custom_catalog_rejects_custom_inheritance_invalid_colors_and_palette_length() {
let custom_base = ThemeCatalog::new(vec![
CustomThemeDefinition {
name: "first".to_string(),
..CustomThemeDefinition::default()
},
CustomThemeDefinition {
name: "second".to_string(),
base: Some("first".to_string()),
..CustomThemeDefinition::default()
},
]);
assert!(custom_base.is_err());
assert!(
ThemeCatalog::new(vec![CustomThemeDefinition {
name: "bad-color".to_string(),
foreground: Some("not-a-color".to_string()),
..CustomThemeDefinition::default()
}])
.is_err()
);
assert!(
ThemeCatalog::new(vec![CustomThemeDefinition {
name: "bad-palette".to_string(),
palette: Some(vec!["#000000".to_string(); 15]),
..CustomThemeDefinition::default()
}])
.is_err()
);
}
fn hex(color: &gdk::RGBA) -> String {
let red = (color.red().clamp(0.0, 1.0) * 255.0).round() as u8;
let green = (color.green().clamp(0.0, 1.0) * 255.0).round() as u8;
let blue = (color.blue().clamp(0.0, 1.0) * 255.0).round() as u8;
format!("#{red:02x}{green:02x}{blue:02x}")
}
}