use ratatui::style::Color;
use serde::Deserialize;
pub mod presets;
#[derive(Debug, Clone, Copy)]
pub struct ColorKey {
pub name: &'static str,
pub description: &'static str,
}
pub const BG: ColorKey = ColorKey {
name: "bg",
description: "Viewport background. Painted across the entire splash area before widgets draw. The default is a deep-ocean navy; set `bg = \"reset\"` to inherit the terminal's own background instead (recommended on light terminals).",
};
pub const BG_SUBTLE: ColorKey = ColorKey {
name: "bg_subtle",
description: "Secondary surface painted when a `[[row]]` or `[[row.child]]` opts in with `bg = \"subtle\"`. One step off `bg` — for headers, callouts, or any slot that should visually separate from the main content band.",
};
pub const TEXT: ColorKey = ColorKey {
name: "text",
description: "Primary body text colour for plain renderers (text_plain, text_ascii, list_plain, animated_typewriter). The top step of the chrome ladder — brighter than `text_secondary`, reads as diffuse daylight on the ocean surface. Set `text = \"reset\"` to inherit the terminal's own foreground instead.",
};
pub const PANEL_BORDER: ColorKey = ColorKey {
name: "panel_border",
description: "Panel border colour. Defaults to a muted ocean teal-gray that sits between `bg` and `text_secondary` — visible as structure without competing with the coral `panel_title`. Set `panel_border = \"reset\"` to inherit the terminal's own foreground instead.",
};
pub const PANEL_TITLE: ColorKey = ColorKey {
name: "panel_title",
description: "Panel title colour. The coral hero of the Splash palette; set `panel_title = \"reset\"` to inherit the terminal's own foreground.",
};
pub const STATUS_OK: ColorKey = ColorKey {
name: "status_ok",
description: "Healthy / passing status (badges, table rows, timeline titles).",
};
pub const STATUS_WARN: ColorKey = ColorKey {
name: "status_warn",
description: "Degraded / warning status — visually distinct from both ok and error.",
};
pub const STATUS_ERROR: ColorKey = ColorKey {
name: "status_error",
description: "Failing / error status — reserved for attention-grabbing failures.",
};
pub const TEXT_DIM: ColorKey = ColorKey {
name: "text_dim",
description: "Barely-visible chrome text: timeline date prefixes, empty-state placeholder, clip hint. Replaces the old `Modifier::DIM` usage — set an explicit colour so rendering stays consistent across terminals (some render DIM as invisible, some as a no-op).",
};
pub const TEXT_SECONDARY: ColorKey = ColorKey {
name: "text_secondary",
description: "Slightly-less-dim secondary text, e.g. the detail line under a timeline entry.",
};
pub const ACCENT_TODAY: ColorKey = ColorKey {
name: "accent_today",
description: "Calendar \"today\" marker.",
};
pub const ACCENT_EVENT: ColorKey = ColorKey {
name: "accent_event",
description: "Calendar event marker and default scatter-plot dot colour.",
};
pub const PALETTE_SERIES: ColorKey = ColorKey {
name: "palette_series",
description: "Palette cycled per-slice / per-series by chart_pie / chart_line / chart_scatter. TOML takes an array of colours.",
};
pub const PALETTE_HEATMAP: ColorKey = ColorKey {
name: "palette_heatmap",
description: "5-step intensity gradient for grid_heatmap (level 0 → level 4). TOML array.",
};
#[derive(Debug, Clone)]
pub struct Theme {
pub bg: Color,
pub bg_subtle: Color,
pub text: Color,
pub panel_border: Color,
pub panel_title: Color,
pub status_ok: Color,
pub status_warn: Color,
pub status_error: Color,
pub text_dim: Color,
pub text_secondary: Color,
pub accent_today: Color,
pub accent_event: Color,
pub palette_series: Vec<Color>,
pub palette_heatmap: Vec<Color>,
}
impl Default for Theme {
fn default() -> Self {
Self {
bg: Color::Rgb(0x0e, 0x17, 0x2a), bg_subtle: Color::Rgb(0x17, 0x24, 0x3b), text: Color::Rgb(0xc5, 0xd2, 0xdc), panel_border: Color::Rgb(0x4a, 0x6b, 0x75), panel_title: Color::Rgb(0xff, 0x8c, 0x7a), status_ok: Color::Rgb(0x7d, 0xe0, 0xb5), status_warn: Color::Rgb(0xff, 0xc6, 0x6b), status_error: Color::Rgb(0xff, 0x5e, 0x7a), text_dim: Color::Rgb(0x2d, 0x47, 0x57), text_secondary: Color::Rgb(0x7e, 0xa0, 0xb5), accent_today: Color::Rgb(0xff, 0xc6, 0x6b), accent_event: Color::Rgb(0x5e, 0xd8, 0xe0), palette_series: default_series(),
palette_heatmap: default_heatmap_ramp(),
}
}
}
pub fn token_color(theme: &Theme, name: &str) -> Option<Color> {
Some(match name {
"bg" => theme.bg,
"bg_subtle" => theme.bg_subtle,
"text" => theme.text,
"text_dim" => theme.text_dim,
"text_secondary" => theme.text_secondary,
"panel_border" => theme.panel_border,
"panel_title" => theme.panel_title,
"status_ok" => theme.status_ok,
"status_warn" => theme.status_warn,
"status_error" => theme.status_error,
"accent_today" => theme.accent_today,
"accent_event" => theme.accent_event,
_ => return None,
})
}
impl Theme {
pub fn series_color(&self, i: usize) -> Color {
if self.palette_series.is_empty() {
return Color::Cyan;
}
self.palette_series[i % self.palette_series.len()]
}
pub fn heatmap_level(&self, level: usize) -> Color {
let ramp = if self.palette_heatmap.is_empty() {
return default_heatmap_ramp()[level.min(4)];
} else {
&self.palette_heatmap
};
ramp[level.min(ramp.len() - 1)]
}
pub fn from_config(cfg: &ThemeConfig) -> Self {
let mut t = match cfg.preset.as_deref() {
None => Self::default(),
Some(name) => match presets::by_name(name) {
Some(theme) => theme,
None => {
eprintln!(
"splashboard: unknown theme preset {name:?}; known: {:?}",
presets::KNOWN
);
Self::default()
}
},
};
if let Some(c) = cfg.bg {
t.bg = c;
}
if let Some(c) = cfg.bg_subtle {
t.bg_subtle = c;
}
if let Some(c) = cfg.text {
t.text = c;
}
if let Some(c) = cfg.panel_border {
t.panel_border = c;
}
if let Some(c) = cfg.panel_title {
t.panel_title = c;
}
if let Some(c) = cfg.status_ok {
t.status_ok = c;
}
if let Some(c) = cfg.status_warn {
t.status_warn = c;
}
if let Some(c) = cfg.status_error {
t.status_error = c;
}
if let Some(c) = cfg.text_dim {
t.text_dim = c;
}
if let Some(c) = cfg.text_secondary {
t.text_secondary = c;
}
if let Some(c) = cfg.accent_today {
t.accent_today = c;
}
if let Some(c) = cfg.accent_event {
t.accent_event = c;
}
if let Some(series) = &cfg.palette_series
&& !series.is_empty()
{
t.palette_series = series.clone();
}
if let Some(ramp) = &cfg.palette_heatmap
&& !ramp.is_empty()
{
t.palette_heatmap = ramp.clone();
}
t
}
}
fn default_series() -> Vec<Color> {
vec![
Color::Rgb(0xff, 0x8c, 0x7a), Color::Rgb(0x5e, 0xd8, 0xe0), Color::Rgb(0xff, 0xc6, 0x6b), Color::Rgb(0x7e, 0xc8, 0xd8), Color::Rgb(0xff, 0x5e, 0x7a), Color::Rgb(0x7d, 0xe0, 0xb5), Color::Rgb(0xf0, 0xa4, 0x88), Color::Rgb(0x3c, 0xa5, 0xa8), Color::Rgb(0xff, 0xb8, 0x9e), Color::Rgb(0x8a, 0x9a, 0xc4), ]
}
fn default_heatmap_ramp() -> Vec<Color> {
vec![
Color::Rgb(0x0a, 0x14, 0x24), Color::Rgb(0x0d, 0x3f, 0x4a), Color::Rgb(0x2a, 0x8c, 0x8f), Color::Rgb(0xff, 0x8c, 0x7a), Color::Rgb(0xff, 0xc6, 0x6b), ]
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ThemeConfig {
pub preset: Option<String>,
pub bg: Option<Color>,
pub bg_subtle: Option<Color>,
pub text: Option<Color>,
pub panel_border: Option<Color>,
pub panel_title: Option<Color>,
pub status_ok: Option<Color>,
pub status_warn: Option<Color>,
pub status_error: Option<Color>,
pub text_dim: Option<Color>,
pub text_secondary: Option<Color>,
pub accent_today: Option<Color>,
pub accent_event: Option<Color>,
pub palette_series: Option<Vec<Color>>,
pub palette_heatmap: Option<Vec<Color>>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_theme_locks_in_signature_palette() {
let t = Theme::default();
assert_eq!(t.bg, Color::Rgb(0x0e, 0x17, 0x2a), "deep-ocean navy");
assert_eq!(t.bg_subtle, Color::Rgb(0x17, 0x24, 0x3b));
assert_eq!(
t.panel_border,
Color::Rgb(0x4a, 0x6b, 0x75),
"ocean teal-gray"
);
assert_eq!(t.text, Color::Rgb(0xc5, 0xd2, 0xdc), "diffuse daylight");
assert_eq!(
t.panel_title,
Color::Rgb(0xff, 0x8c, 0x7a),
"coral — rising sun"
);
assert_eq!(
t.accent_event,
Color::Rgb(0x5e, 0xd8, 0xe0),
"cyan-teal — the sea"
);
assert_eq!(t.text_dim, Color::Rgb(0x2d, 0x47, 0x57), "teal-dark chrome");
assert_eq!(t.text_secondary, Color::Rgb(0x7e, 0xa0, 0xb5));
assert_eq!(t.status_ok, Color::Rgb(0x7d, 0xe0, 0xb5));
assert_eq!(t.status_warn, Color::Rgb(0xff, 0xc6, 0x6b));
assert_eq!(t.status_error, Color::Rgb(0xff, 0x5e, 0x7a));
assert_eq!(t.palette_series.len(), 10);
assert_eq!(t.palette_series[0], Color::Rgb(0xff, 0x8c, 0x7a));
assert_eq!(t.palette_series[1], Color::Rgb(0x5e, 0xd8, 0xe0));
assert_eq!(t.palette_heatmap.len(), 5);
assert_eq!(t.palette_heatmap[0], Color::Rgb(0x0a, 0x14, 0x24));
assert_eq!(
t.palette_heatmap[4],
Color::Rgb(0xff, 0xc6, 0x6b),
"amber dawn"
);
}
#[test]
fn no_chrome_token_is_reset_by_default() {
let t = Theme::default();
assert_ne!(t.bg, Color::Reset);
assert_ne!(t.bg_subtle, Color::Reset);
assert_ne!(t.text, Color::Reset);
assert_ne!(t.panel_border, Color::Reset);
assert_ne!(t.panel_title, Color::Reset);
}
#[test]
fn from_config_overrides_only_provided_fields() {
let cfg = ThemeConfig {
status_ok: Some(Color::Blue),
..Default::default()
};
let t = Theme::from_config(&cfg);
assert_eq!(t.status_ok, Color::Blue);
assert_eq!(t.status_error, Theme::default().status_error);
}
#[test]
fn theme_config_parses_named_and_hex() {
let toml = r##"
status_ok = "cyan"
status_error = "#ff8800"
palette_series = ["red", "#00ff00", "blue"]
"##;
let cfg: ThemeConfig = toml::from_str(toml).unwrap();
let t = Theme::from_config(&cfg);
assert_eq!(t.status_ok, Color::Cyan);
assert_eq!(t.status_error, Color::Rgb(0xff, 0x88, 0x00));
assert_eq!(t.palette_series.len(), 3);
}
#[test]
fn series_color_wraps_around() {
let t = Theme::default();
assert_eq!(t.series_color(0), t.series_color(t.palette_series.len()));
}
#[test]
fn series_color_falls_back_on_empty_palette() {
let t = Theme {
palette_series: vec![],
..Theme::default()
};
assert_eq!(t.series_color(0), Color::Cyan);
}
#[test]
fn heatmap_level_saturates_at_top() {
let t = Theme::default();
assert_eq!(t.heatmap_level(99), t.heatmap_level(4));
}
#[test]
fn heatmap_level_falls_back_on_empty_palette() {
let t = Theme {
palette_heatmap: vec![],
..Theme::default()
};
assert_eq!(t.heatmap_level(0), Color::Rgb(0x0a, 0x14, 0x24));
assert_eq!(t.heatmap_level(99), Color::Rgb(0xff, 0xc6, 0x6b));
}
#[test]
fn preset_with_overlay_layers_correctly() {
let cfg = ThemeConfig {
preset: Some("nord".into()),
status_ok: Some(Color::Magenta),
..Default::default()
};
let t = Theme::from_config(&cfg);
assert_eq!(t.status_ok, Color::Magenta, "override must beat preset");
assert_ne!(t.status_error, Theme::default().status_error);
}
#[test]
fn unknown_preset_falls_back_to_default() {
let cfg = ThemeConfig {
preset: Some("bogus".into()),
..Default::default()
};
let t = Theme::from_config(&cfg);
assert_eq!(t.status_ok, Theme::default().status_ok);
}
#[test]
fn unknown_preset_falls_back_then_applies_scalar_overrides() {
let cfg = ThemeConfig {
preset: Some("bogus".into()),
bg: Some(Color::Rgb(0x01, 0x02, 0x03)),
..Default::default()
};
let t = Theme::from_config(&cfg);
assert_eq!(t.bg, Color::Rgb(0x01, 0x02, 0x03));
assert_eq!(t.status_ok, Theme::default().status_ok);
}
#[test]
fn empty_series_override_keeps_default() {
let cfg = ThemeConfig {
palette_series: Some(vec![]),
..Default::default()
};
let t = Theme::from_config(&cfg);
assert_eq!(t.palette_series.len(), 10);
}
#[test]
fn from_config_overrides_remaining_scalars_and_heatmap_palette() {
let cfg = ThemeConfig {
bg: Some(Color::Rgb(0x01, 0x11, 0x21)),
bg_subtle: Some(Color::Rgb(0x02, 0x12, 0x22)),
text: Some(Color::Rgb(0x03, 0x13, 0x23)),
panel_border: Some(Color::Rgb(0x04, 0x14, 0x24)),
panel_title: Some(Color::Rgb(0x05, 0x15, 0x25)),
status_warn: Some(Color::Rgb(0x06, 0x16, 0x26)),
text_dim: Some(Color::Rgb(0x07, 0x17, 0x27)),
text_secondary: Some(Color::Rgb(0x08, 0x18, 0x28)),
accent_today: Some(Color::Rgb(0x09, 0x19, 0x29)),
accent_event: Some(Color::Rgb(0x0a, 0x1a, 0x2a)),
palette_heatmap: Some(vec![
Color::Rgb(0xaa, 0x00, 0x00),
Color::Rgb(0x00, 0xaa, 0x00),
]),
..Default::default()
};
let t = Theme::from_config(&cfg);
assert_eq!(t.bg, Color::Rgb(0x01, 0x11, 0x21));
assert_eq!(t.bg_subtle, Color::Rgb(0x02, 0x12, 0x22));
assert_eq!(t.text, Color::Rgb(0x03, 0x13, 0x23));
assert_eq!(t.panel_border, Color::Rgb(0x04, 0x14, 0x24));
assert_eq!(t.panel_title, Color::Rgb(0x05, 0x15, 0x25));
assert_eq!(t.status_warn, Color::Rgb(0x06, 0x16, 0x26));
assert_eq!(t.text_dim, Color::Rgb(0x07, 0x17, 0x27));
assert_eq!(t.text_secondary, Color::Rgb(0x08, 0x18, 0x28));
assert_eq!(t.accent_today, Color::Rgb(0x09, 0x19, 0x29));
assert_eq!(t.accent_event, Color::Rgb(0x0a, 0x1a, 0x2a));
assert_eq!(
t.palette_heatmap,
vec![Color::Rgb(0xaa, 0x00, 0x00), Color::Rgb(0x00, 0xaa, 0x00)]
);
}
}