use crate::primitives::{Color, FontWeight};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum LineStyle {
Solid,
Dashed,
Dotted,
DashDot,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Marker {
Circle,
Square,
Triangle,
Diamond,
Plus,
Cross,
Star,
Point,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Loc {
Best,
UpperRight,
UpperLeft,
LowerLeft,
LowerRight,
Right,
CenterLeft,
CenterRight,
LowerCenter,
UpperCenter,
Center,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum GridAxis {
X,
Y,
#[default]
Both,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TickDirection {
Outward,
Inward,
}
#[derive(Debug, Clone)]
pub struct Theme {
pub figure_background: Color,
pub axes_background: Color,
pub grid_color: Color,
pub grid_width: f64,
pub show_grid: bool,
pub spine_color: Color,
pub spine_width: f64,
pub show_top_spine: bool,
pub show_right_spine: bool,
pub show_bottom_spine: bool,
pub show_left_spine: bool,
pub tick_color: Color,
pub tick_length: f64,
pub tick_direction: TickDirection,
pub tick_label_size: f64,
pub axis_label_size: f64,
pub title_size: f64,
pub title_weight: FontWeight,
pub text_color: Color,
pub line_width: f64,
pub marker_size: f64,
pub marker_alpha: f64,
pub color_cycle: Vec<Color>,
pub font_family: Option<String>,
}
const TABLEAU_10: [Color; 10] = Color::TABLEAU_10;
impl Default for Theme {
fn default() -> Self {
let spine = Color::rgb(0x33, 0x33, 0x33);
Self {
figure_background: Color::WHITE,
axes_background: Color::WHITE,
grid_color: Color::rgb(0xE6, 0xE6, 0xE6),
grid_width: 1.0,
show_grid: true,
spine_color: spine,
spine_width: 1.0,
show_top_spine: false,
show_right_spine: false,
show_bottom_spine: true,
show_left_spine: true,
tick_color: spine,
tick_length: 4.0,
tick_direction: TickDirection::Outward,
tick_label_size: 9.0,
axis_label_size: 11.0,
title_size: 14.0,
title_weight: FontWeight::Bold,
text_color: spine,
line_width: 1.5,
marker_size: 6.0,
marker_alpha: 0.8,
color_cycle: TABLEAU_10.to_vec(),
font_family: None,
}
}
}
impl Theme {
pub fn dark() -> Self {
let bg = Color::rgb(0x1C, 0x1C, 0x1C);
let text = Color::rgb(0xE0, 0xE0, 0xE0);
let grid = Color::rgb(0x3A, 0x3A, 0x3A);
let spine = Color::rgb(0x55, 0x55, 0x55);
let cycle = vec![
Color::rgb(0x00, 0xD4, 0xFF), Color::rgb(0xFF, 0x6F, 0x61), Color::rgb(0x7B, 0xED, 0x72), Color::rgb(0xFF, 0xA6, 0x00), Color::rgb(0xD1, 0x7D, 0xFF), Color::rgb(0xFF, 0xE1, 0x00), Color::rgb(0x00, 0xFF, 0xAB), Color::rgb(0xFF, 0x4D, 0xA6), Color::rgb(0x48, 0xBF, 0xE3), Color::rgb(0xE8, 0xE8, 0xE8), ];
Self {
figure_background: bg,
axes_background: bg,
grid_color: grid,
grid_width: 1.0,
show_grid: true,
spine_color: spine,
spine_width: 1.0,
show_top_spine: false,
show_right_spine: false,
show_bottom_spine: true,
show_left_spine: true,
tick_color: text,
tick_length: 4.0,
tick_direction: TickDirection::Outward,
tick_label_size: 9.0,
axis_label_size: 11.0,
title_size: 14.0,
title_weight: FontWeight::Bold,
text_color: text,
line_width: 1.5,
marker_size: 6.0,
marker_alpha: 0.9,
color_cycle: cycle,
font_family: None,
}
}
pub fn seaborn() -> Self {
let text = Color::rgb(0x33, 0x33, 0x33);
let axes_bg = Color::rgb(0xEA, 0xEA, 0xF2);
let cycle = vec![
Color::rgb(0x4C, 0x72, 0xB0), Color::rgb(0xDD, 0x85, 0x52), Color::rgb(0x55, 0xA8, 0x68), Color::rgb(0xC4, 0x4E, 0x52), Color::rgb(0x81, 0x72, 0xB3), Color::rgb(0x93, 0x7A, 0x60), Color::rgb(0xDA, 0x8B, 0xC3), Color::rgb(0x8C, 0x8C, 0x8C), Color::rgb(0xCC, 0xB9, 0x74), Color::rgb(0x64, 0xB5, 0xCD), ];
Self {
figure_background: Color::WHITE,
axes_background: axes_bg,
grid_color: Color::WHITE,
grid_width: 1.5,
show_grid: true,
spine_color: Color::rgb(0xCC, 0xCC, 0xCC),
spine_width: 1.0,
show_top_spine: false,
show_right_spine: false,
show_bottom_spine: true,
show_left_spine: true,
tick_color: text,
tick_length: 0.0, tick_direction: TickDirection::Outward,
tick_label_size: 9.0,
axis_label_size: 11.0,
title_size: 14.0,
title_weight: FontWeight::Bold,
text_color: text,
line_width: 1.5,
marker_size: 6.0,
marker_alpha: 0.8,
color_cycle: cycle,
font_family: Some("sans-serif".to_string()),
}
}
pub fn ggplot() -> Self {
let panel = Color::rgb(0xE5, 0xE5, 0xE5);
let text = Color::rgb(0x30, 0x30, 0x30);
let border = Color::rgb(0x80, 0x80, 0x80);
let cycle = vec![
Color::rgb(0xF8, 0x76, 0x6D), Color::rgb(0xA3, 0xA5, 0x00), Color::rgb(0x00, 0xBA, 0x38), Color::rgb(0x00, 0xBF, 0xC4), Color::rgb(0x61, 0x9C, 0xFF), Color::rgb(0xF5, 0x64, 0xE3), Color::rgb(0xFF, 0x64, 0xB0), Color::rgb(0xB7, 0x9F, 0x00), ];
Self {
figure_background: Color::WHITE,
axes_background: panel,
grid_color: Color::WHITE,
grid_width: 1.0,
show_grid: true,
spine_color: border,
spine_width: 0.5,
show_top_spine: true,
show_right_spine: true,
show_bottom_spine: true,
show_left_spine: true,
tick_color: text,
tick_length: 0.0, tick_direction: TickDirection::Outward,
tick_label_size: 9.0,
axis_label_size: 11.0,
title_size: 14.0,
title_weight: FontWeight::Bold,
text_color: text,
line_width: 1.0,
marker_size: 5.0,
marker_alpha: 1.0,
color_cycle: cycle,
font_family: None,
}
}
pub fn publication() -> Self {
let ink = Color::rgb(0x1A, 0x1A, 0x1A);
Self {
figure_background: Color::WHITE,
axes_background: Color::WHITE,
grid_color: Color::rgb(0xD0, 0xD0, 0xD0),
grid_width: 0.5,
show_grid: false,
spine_color: ink,
spine_width: 0.5,
show_top_spine: true,
show_right_spine: true,
show_bottom_spine: true,
show_left_spine: true,
tick_color: ink,
tick_length: 3.0,
tick_direction: TickDirection::Inward,
tick_label_size: 8.0,
axis_label_size: 12.0,
title_size: 13.0,
title_weight: FontWeight::Bold,
text_color: ink,
line_width: 1.0,
marker_size: 4.0,
marker_alpha: 1.0,
color_cycle: TABLEAU_10.to_vec(),
font_family: Some("serif".to_string()),
}
}
pub fn nature() -> Self {
let ink = Color::rgb(0x1A, 0x1A, 0x1A);
let cycle = vec![
Color::rgb(0xE6, 0x4B, 0x35), Color::rgb(0x4D, 0xBB, 0xD5), Color::rgb(0x00, 0xA0, 0x87), Color::rgb(0x30, 0x66, 0xBE), Color::rgb(0xF3, 0x9B, 0x7F), Color::rgb(0x87, 0x5F, 0x9A), Color::rgb(0xFE, 0xBE, 0x10), Color::rgb(0x00, 0x72, 0xB2), ];
Self {
figure_background: Color::WHITE,
axes_background: Color::WHITE,
grid_color: Color::rgb(0xDD, 0xDD, 0xDD),
grid_width: 0.5,
show_grid: false,
spine_color: ink,
spine_width: 0.75,
show_top_spine: false,
show_right_spine: false,
show_bottom_spine: true,
show_left_spine: true,
tick_color: ink,
tick_length: 3.0,
tick_direction: TickDirection::Outward,
tick_label_size: 7.0,
axis_label_size: 8.0,
title_size: 10.0,
title_weight: FontWeight::Bold,
text_color: ink,
line_width: 1.0,
marker_size: 4.0,
marker_alpha: 1.0,
color_cycle: cycle,
font_family: Some("sans-serif".to_string()),
}
}
pub fn solarized() -> Self {
let base03 = Color::rgb(0x00, 0x2B, 0x36); let base02 = Color::rgb(0x07, 0x36, 0x42); let base01 = Color::rgb(0x58, 0x6E, 0x75); let base0 = Color::rgb(0x83, 0x94, 0x96); let base1 = Color::rgb(0x93, 0xA1, 0xA1);
let cycle = vec![
Color::rgb(0x26, 0x8B, 0xD2), Color::rgb(0xDC, 0x32, 0x2F), Color::rgb(0x85, 0x99, 0x00), Color::rgb(0xB5, 0x89, 0x00), Color::rgb(0x2A, 0xA1, 0x98), Color::rgb(0xD3, 0x36, 0x82), Color::rgb(0xCB, 0x4B, 0x16), Color::rgb(0x6C, 0x71, 0xC4), ];
Self {
figure_background: base03,
axes_background: base03,
grid_color: base02,
grid_width: 1.0,
show_grid: true,
spine_color: base01,
spine_width: 1.0,
show_top_spine: false,
show_right_spine: false,
show_bottom_spine: true,
show_left_spine: true,
tick_color: base0,
tick_length: 4.0,
tick_direction: TickDirection::Outward,
tick_label_size: 9.0,
axis_label_size: 11.0,
title_size: 14.0,
title_weight: FontWeight::Bold,
text_color: base1,
line_width: 1.5,
marker_size: 6.0,
marker_alpha: 0.9,
color_cycle: cycle,
font_family: Some("sans-serif".to_string()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_theme_background_is_white() {
let t = Theme::default();
assert_eq!(t.figure_background, Color::WHITE);
assert_eq!(t.axes_background, Color::WHITE);
}
#[test]
fn default_theme_despine_look() {
let t = Theme::default();
assert!(!t.show_top_spine);
assert!(!t.show_right_spine);
assert!(t.show_bottom_spine);
assert!(t.show_left_spine);
}
#[test]
fn default_theme_grid() {
let t = Theme::default();
assert_eq!(t.grid_color, Color::rgb(0xE6, 0xE6, 0xE6));
assert!((t.grid_width - 1.0).abs() < f64::EPSILON);
assert!(t.show_grid);
}
#[test]
fn default_theme_spines() {
let t = Theme::default();
let expected = Color::rgb(0x33, 0x33, 0x33);
assert_eq!(t.spine_color, expected);
assert!((t.spine_width - 1.0).abs() < f64::EPSILON);
}
#[test]
fn default_theme_ticks() {
let t = Theme::default();
assert_eq!(t.tick_color, Color::rgb(0x33, 0x33, 0x33));
assert!((t.tick_length - 4.0).abs() < f64::EPSILON);
assert_eq!(t.tick_direction, TickDirection::Outward);
}
#[test]
fn default_theme_font_sizes() {
let t = Theme::default();
assert!((t.tick_label_size - 9.0).abs() < f64::EPSILON);
assert!((t.axis_label_size - 11.0).abs() < f64::EPSILON);
assert!((t.title_size - 14.0).abs() < f64::EPSILON);
assert_eq!(t.title_weight, FontWeight::Bold);
}
#[test]
fn default_theme_text_color() {
let t = Theme::default();
assert_eq!(t.text_color, Color::rgb(0x33, 0x33, 0x33));
}
#[test]
fn default_theme_data_defaults() {
let t = Theme::default();
assert!((t.line_width - 1.5).abs() < f64::EPSILON);
assert!((t.marker_size - 6.0).abs() < f64::EPSILON);
assert!((t.marker_alpha - 0.8).abs() < f64::EPSILON);
}
#[test]
fn default_theme_tableau_10_cycle() {
let t = Theme::default();
assert_eq!(t.color_cycle.len(), 10);
assert_eq!(t.color_cycle[0], Color::TAB_BLUE);
assert_eq!(t.color_cycle[9], Color::TAB_CYAN);
}
#[test]
fn dark_theme_has_dark_background() {
let t = Theme::dark();
assert_eq!(t.figure_background, Color::rgb(0x1C, 0x1C, 0x1C));
assert_eq!(t.axes_background, Color::rgb(0x1C, 0x1C, 0x1C));
}
#[test]
fn dark_theme_light_text() {
let t = Theme::dark();
assert_eq!(t.text_color, Color::rgb(0xE0, 0xE0, 0xE0));
}
#[test]
fn dark_theme_neon_cycle() {
let t = Theme::dark();
assert_eq!(t.color_cycle.len(), 10);
assert_eq!(t.color_cycle[0], Color::rgb(0x00, 0xD4, 0xFF));
}
#[test]
fn seaborn_theme_tinted_face() {
let t = Theme::seaborn();
assert_eq!(t.axes_background, Color::rgb(0xEA, 0xEA, 0xF2));
}
#[test]
fn seaborn_theme_white_grid_thicker() {
let t = Theme::seaborn();
assert_eq!(t.grid_color, Color::WHITE);
assert!(t.show_grid);
assert!((t.grid_width - 1.5).abs() < f64::EPSILON);
}
#[test]
fn ggplot_theme_grey_panel() {
let t = Theme::ggplot();
assert_eq!(t.axes_background, Color::rgb(0xE5, 0xE5, 0xE5));
}
#[test]
fn ggplot_theme_white_grid() {
let t = Theme::ggplot();
assert_eq!(t.grid_color, Color::WHITE);
assert!(t.show_grid);
}
#[test]
fn ggplot_theme_panel_border() {
let t = Theme::ggplot();
assert!(t.show_top_spine);
assert!(t.show_right_spine);
assert!(t.show_bottom_spine);
assert!(t.show_left_spine);
assert!((t.spine_width - 0.5).abs() < f64::EPSILON);
}
#[test]
fn ggplot_theme_palette() {
let t = Theme::ggplot();
assert_eq!(t.color_cycle.len(), 8);
assert_eq!(t.color_cycle[0], Color::rgb(0xF8, 0x76, 0x6D));
}
#[test]
fn publication_theme_all_spines_visible() {
let t = Theme::publication();
assert!(t.show_top_spine);
assert!(t.show_right_spine);
assert!(t.show_bottom_spine);
assert!(t.show_left_spine);
}
#[test]
fn publication_theme_no_grid() {
let t = Theme::publication();
assert!(!t.show_grid);
}
#[test]
fn publication_theme_thin_spines() {
let t = Theme::publication();
assert!((t.spine_width - 0.5).abs() < f64::EPSILON);
}
#[test]
fn publication_theme_inward_ticks() {
let t = Theme::publication();
assert_eq!(t.tick_direction, TickDirection::Inward);
}
#[test]
fn publication_theme_serif_font() {
let t = Theme::publication();
assert_eq!(t.font_family, Some("serif".to_string()));
}
#[test]
fn publication_theme_white_background() {
let t = Theme::publication();
assert_eq!(t.figure_background, Color::WHITE);
assert_eq!(t.axes_background, Color::WHITE);
}
#[test]
fn grid_axis_default_is_both() {
assert_eq!(GridAxis::default(), GridAxis::Both);
}
#[test]
fn seaborn_theme_muted_palette() {
let t = Theme::seaborn();
assert_eq!(t.color_cycle.len(), 10);
assert_eq!(t.color_cycle[0], Color::rgb(0x4C, 0x72, 0xB0));
}
#[test]
fn seaborn_theme_no_top_right_spines() {
let t = Theme::seaborn();
assert!(!t.show_top_spine);
assert!(!t.show_right_spine);
assert!(t.show_bottom_spine);
assert!(t.show_left_spine);
}
#[test]
fn seaborn_theme_sans_serif_font() {
let t = Theme::seaborn();
assert_eq!(t.font_family, Some("sans-serif".to_string()));
}
#[test]
fn publication_theme_larger_axis_labels() {
let t = Theme::publication();
assert!((t.axis_label_size - 12.0).abs() < f64::EPSILON);
}
#[test]
fn nature_theme_constructs_without_panic() {
let _t = Theme::nature();
}
#[test]
fn nature_theme_white_background() {
let t = Theme::nature();
assert_eq!(t.figure_background, Color::WHITE);
assert_eq!(t.axes_background, Color::WHITE);
}
#[test]
fn nature_theme_no_grid() {
let t = Theme::nature();
assert!(!t.show_grid);
}
#[test]
fn nature_theme_thin_spines() {
let t = Theme::nature();
assert!((t.spine_width - 0.75).abs() < f64::EPSILON);
assert!(!t.show_top_spine);
assert!(!t.show_right_spine);
assert!(t.show_bottom_spine);
assert!(t.show_left_spine);
}
#[test]
fn nature_theme_compact_font_sizes() {
let t = Theme::nature();
assert!((t.tick_label_size - 7.0).abs() < f64::EPSILON);
assert!((t.axis_label_size - 8.0).abs() < f64::EPSILON);
assert!((t.title_size - 10.0).abs() < f64::EPSILON);
}
#[test]
fn nature_theme_bold_labels() {
let t = Theme::nature();
assert_eq!(t.title_weight, FontWeight::Bold);
}
#[test]
fn nature_theme_sans_serif_font() {
let t = Theme::nature();
assert_eq!(t.font_family, Some("sans-serif".to_string()));
}
#[test]
fn nature_theme_palette() {
let t = Theme::nature();
assert_eq!(t.color_cycle.len(), 8);
assert_eq!(t.color_cycle[0], Color::rgb(0xE6, 0x4B, 0x35));
}
#[test]
fn nature_theme_small_markers() {
let t = Theme::nature();
assert!((t.marker_size - 4.0).abs() < f64::EPSILON);
assert!((t.marker_alpha - 1.0).abs() < f64::EPSILON);
}
#[test]
fn solarized_theme_constructs_without_panic() {
let _t = Theme::solarized();
}
#[test]
fn solarized_theme_dark_background() {
let t = Theme::solarized();
assert_eq!(t.figure_background, Color::rgb(0x00, 0x2B, 0x36));
assert_eq!(t.axes_background, Color::rgb(0x00, 0x2B, 0x36));
}
#[test]
fn solarized_theme_content_text_color() {
let t = Theme::solarized();
assert_eq!(t.text_color, Color::rgb(0x93, 0xA1, 0xA1));
}
#[test]
fn solarized_theme_accent_palette() {
let t = Theme::solarized();
assert_eq!(t.color_cycle.len(), 8);
assert_eq!(t.color_cycle[0], Color::rgb(0x26, 0x8B, 0xD2));
assert_eq!(t.color_cycle[7], Color::rgb(0x6C, 0x71, 0xC4)); }
#[test]
fn solarized_theme_grid_uses_base02() {
let t = Theme::solarized();
assert_eq!(t.grid_color, Color::rgb(0x07, 0x36, 0x42));
assert!(t.show_grid);
}
#[test]
fn solarized_theme_sans_serif_font() {
let t = Theme::solarized();
assert_eq!(t.font_family, Some("sans-serif".to_string()));
}
#[test]
fn solarized_theme_despine_look() {
let t = Theme::solarized();
assert!(!t.show_top_spine);
assert!(!t.show_right_spine);
assert!(t.show_bottom_spine);
assert!(t.show_left_spine);
}
#[test]
fn all_themes_have_distinct_backgrounds() {
let themes: Vec<(&str, Theme)> = vec![
("default", Theme::default()),
("dark", Theme::dark()),
("seaborn", Theme::seaborn()),
("ggplot", Theme::ggplot()),
("publication", Theme::publication()),
("nature", Theme::nature()),
("solarized", Theme::solarized()),
];
let mut backgrounds: Vec<(Color, Color)> = themes
.iter()
.map(|(_, t)| (t.figure_background, t.axes_background))
.collect();
backgrounds.sort_by_key(|(f, a)| (f.r, f.g, f.b, a.r, a.g, a.b));
backgrounds.dedup();
assert!(
backgrounds.len() >= 4,
"Expected at least 4 distinct background combos, got {}",
backgrounds.len()
);
}
#[test]
fn all_themes_have_reasonable_spine_widths() {
let themes = [
Theme::default(),
Theme::dark(),
Theme::seaborn(),
Theme::ggplot(),
Theme::publication(),
Theme::nature(),
Theme::solarized(),
];
for t in &themes {
assert!(
t.spine_width >= 0.0 && t.spine_width <= 3.0,
"spine_width {} out of reasonable range",
t.spine_width
);
}
}
#[test]
fn all_themes_have_reasonable_tick_sizes() {
let themes = [
Theme::default(),
Theme::dark(),
Theme::seaborn(),
Theme::ggplot(),
Theme::publication(),
Theme::nature(),
Theme::solarized(),
];
for t in &themes {
assert!(
t.tick_length >= 0.0 && t.tick_length <= 10.0,
"tick_length {} out of reasonable range",
t.tick_length
);
assert!(
t.tick_label_size >= 5.0 && t.tick_label_size <= 16.0,
"tick_label_size {} out of reasonable range",
t.tick_label_size
);
}
}
#[test]
fn each_theme_has_nonempty_color_cycle() {
let themes = [
Theme::default(),
Theme::dark(),
Theme::seaborn(),
Theme::ggplot(),
Theme::publication(),
Theme::nature(),
Theme::solarized(),
];
for t in &themes {
assert!(
!t.color_cycle.is_empty(),
"color_cycle must not be empty"
);
}
}
#[test]
fn nature_and_publication_are_distinct() {
let n = Theme::nature();
let p = Theme::publication();
assert_ne!(n.font_family, p.font_family);
assert_ne!(n.show_top_spine, p.show_top_spine);
assert!((n.axis_label_size - p.axis_label_size).abs() > f64::EPSILON);
}
#[test]
fn solarized_and_dark_are_distinct() {
let s = Theme::solarized();
let d = Theme::dark();
assert_ne!(s.figure_background, d.figure_background);
assert_ne!(s.color_cycle[0], d.color_cycle[0]);
assert_ne!(s.text_color, d.text_color);
}
}