#[derive(Debug, Clone, PartialEq)]
pub enum GridStyle {
Both,
HorizontalOnly,
VerticalOnly,
None,
}
#[derive(Debug, Clone, PartialEq)]
pub enum TextTransform {
None,
Uppercase,
Lowercase,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ZeroLineSpec {
pub color: String,
pub width: f32,
}
#[derive(Debug, Clone, PartialEq)]
pub enum BarCornerRadius {
Uniform(f32),
Top(f32),
}
impl Default for BarCornerRadius {
fn default() -> Self {
Self::Uniform(0.0)
}
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct Theme {
pub text: String,
pub text_secondary: String,
pub text_strong: String,
pub axis_line: String,
pub tick: String,
pub grid: String,
pub bg: String,
pub title_font_family: String,
pub title_font_size: f32,
pub title_font_weight: u16,
pub title_font_style: String,
pub label_font_family: String,
pub label_font_size: f32,
pub label_font_weight: u16,
pub label_letter_spacing: f32,
pub label_text_transform: TextTransform,
pub numeric_font_family: String,
pub numeric_font_size: f32,
pub legend_font_family: String,
pub legend_font_size: f32,
pub legend_font_weight: u16,
pub axis_line_weight: f32,
pub grid_line_weight: f32,
pub series_line_weight: f32,
pub annotation_line_weight: f32,
pub bar_corner_radius: BarCornerRadius,
pub dot_radius: f32,
pub dot_halo_color: Option<String>,
pub dot_halo_width: f32,
pub grid_style: GridStyle,
pub zero_line: Option<ZeroLineSpec>,
pub table_header_bg: String,
pub table_header_text: String,
pub table_row_bg: String,
pub table_row_bg_alt: String,
pub table_border: String,
pub table_text: String,
pub table_cell_padding: String,
pub table_font_size: String,
}
impl Default for Theme {
fn default() -> Self {
Self {
text: "#374151".into(),
text_secondary: "#6b7280".into(),
text_strong: "#1f2937".into(),
axis_line: "#374151".into(),
tick: "#374151".into(),
grid: "#e0e0e0".into(),
bg: "#ffffff".into(),
title_font_family: "system-ui, sans-serif".into(),
title_font_size: 14.0,
title_font_weight: 700,
title_font_style: "normal".into(),
label_font_family: "system-ui, sans-serif".into(),
label_font_size: 12.0,
label_font_weight: 400,
label_letter_spacing: 0.0,
label_text_transform: TextTransform::None,
numeric_font_family: "system-ui, sans-serif".into(),
numeric_font_size: 12.0,
legend_font_family: "system-ui, sans-serif".into(),
legend_font_size: 12.0,
legend_font_weight: 400,
axis_line_weight: 1.0,
grid_line_weight: 1.0,
series_line_weight: 2.0,
annotation_line_weight: 1.0,
bar_corner_radius: BarCornerRadius::Uniform(0.0),
dot_radius: 5.0,
dot_halo_color: None,
dot_halo_width: 0.0,
grid_style: GridStyle::Both,
zero_line: None,
table_header_bg: "#f9fafb".into(),
table_header_text: "#1f2937".into(),
table_row_bg: "#ffffff".into(),
table_row_bg_alt: "#f9fafb".into(),
table_border: "#e5e7eb".into(),
table_text: "#374151".into(),
table_cell_padding: "8px 12px".into(),
table_font_size: "13px".into(),
}
}
}
impl Theme {
pub fn dark() -> Self {
Self {
text: "#e5e7eb".into(),
text_secondary: "#9ca3af".into(),
text_strong: "#f3f4f6".into(),
axis_line: "#9ca3af".into(),
tick: "#9ca3af".into(),
grid: "#374151".into(),
bg: "#1f2937".into(),
table_header_bg: "#111827".into(),
table_header_text: "#f3f4f6".into(),
table_row_bg: "#1f2937".into(),
table_row_bg_alt: "#111827".into(),
table_border: "#374151".into(),
table_text: "#e5e7eb".into(),
..Theme::default()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_theme_has_no_css_var_values() {
let theme = Theme::default();
let all_values = [
&theme.text, &theme.text_secondary, &theme.text_strong,
&theme.axis_line, &theme.tick, &theme.grid, &theme.bg,
];
for value in all_values {
assert!(
!value.contains("var("),
"Theme value should be plain hex, not CSS var(): {value}"
);
}
}
#[test]
fn dark_theme_has_no_css_var_values() {
let theme = Theme::dark();
let all_values = [
&theme.text, &theme.text_secondary, &theme.text_strong,
&theme.axis_line, &theme.tick, &theme.grid, &theme.bg,
];
for value in all_values {
assert!(
!value.contains("var("),
"Dark theme value should be plain hex, not CSS var(): {value}"
);
}
}
#[test]
fn theme_fields_are_customizable() {
let custom = Theme {
axis_line: "#ff0000".into(),
..Theme::dark()
};
assert_eq!(custom.axis_line, "#ff0000");
assert_eq!(custom.grid, "#374151"); }
#[test]
fn default_title_typography() {
let t = Theme::default();
assert_eq!(t.title_font_family, "system-ui, sans-serif");
assert_eq!(t.title_font_size, 14.0);
assert_eq!(t.title_font_weight, 700);
assert_eq!(t.title_font_style, "normal");
}
#[test]
fn default_label_typography() {
let t = Theme::default();
assert_eq!(t.label_font_family, "system-ui, sans-serif");
assert_eq!(t.label_font_size, 12.0);
assert_eq!(t.label_font_weight, 400);
assert_eq!(t.label_letter_spacing, 0.0);
assert_eq!(t.label_text_transform, TextTransform::None);
}
#[test]
fn default_numeric_typography() {
let t = Theme::default();
assert_eq!(t.numeric_font_family, "system-ui, sans-serif");
assert_eq!(t.numeric_font_size, 12.0);
}
#[test]
fn default_legend_typography() {
let t = Theme::default();
assert_eq!(t.legend_font_family, "system-ui, sans-serif");
assert_eq!(t.legend_font_size, 12.0);
assert_eq!(t.legend_font_weight, 400);
}
#[test]
fn default_stroke_weights_match_audit() {
let t = Theme::default();
assert_eq!(t.axis_line_weight, 1.0);
assert_eq!(t.grid_line_weight, 1.0);
assert_eq!(t.series_line_weight, 2.0);
assert_eq!(t.annotation_line_weight, 1.0);
}
#[test]
fn default_shape_fields() {
let t = Theme::default();
assert_eq!(t.bar_corner_radius, BarCornerRadius::Uniform(0.0));
assert_eq!(t.dot_radius, 5.0);
assert!(t.dot_halo_color.is_none());
assert_eq!(t.dot_halo_width, 0.0);
}
#[test]
fn default_grid_style_is_both() {
assert_eq!(Theme::default().grid_style, GridStyle::Both);
}
#[test]
fn default_zero_line_is_none() {
assert!(Theme::default().zero_line.is_none());
}
#[test]
fn dark_theme_inherits_typography_and_shape_from_default() {
let d = Theme::default();
let k = Theme::dark();
assert_eq!(d.title_font_size, k.title_font_size);
assert_eq!(d.label_font_weight, k.label_font_weight);
assert_eq!(d.numeric_font_family, k.numeric_font_family);
assert_eq!(d.legend_font_family, k.legend_font_family);
assert_eq!(d.axis_line_weight, k.axis_line_weight);
assert_eq!(d.grid_line_weight, k.grid_line_weight);
assert_eq!(d.series_line_weight, k.series_line_weight);
assert_eq!(d.dot_radius, k.dot_radius);
assert_eq!(d.bar_corner_radius, k.bar_corner_radius);
assert_eq!(d.grid_style, k.grid_style);
assert_eq!(d.zero_line, k.zero_line);
}
#[test]
fn custom_theme_can_override_new_fields_individually() {
let custom = Theme {
series_line_weight: 3.5,
bar_corner_radius: BarCornerRadius::Uniform(4.0),
dot_halo_color: Some("#ffffff".into()),
dot_halo_width: 2.0,
grid_style: GridStyle::HorizontalOnly,
zero_line: Some(ZeroLineSpec { color: "#000000".into(), width: 1.5 }),
label_text_transform: TextTransform::Uppercase,
..Theme::default()
};
assert_eq!(custom.series_line_weight, 3.5);
assert_eq!(custom.bar_corner_radius, BarCornerRadius::Uniform(4.0));
assert_eq!(custom.dot_halo_color.as_deref(), Some("#ffffff"));
assert_eq!(custom.dot_halo_width, 2.0);
assert_eq!(custom.grid_style, GridStyle::HorizontalOnly);
assert_eq!(
custom.zero_line,
Some(ZeroLineSpec { color: "#000000".into(), width: 1.5 })
);
assert_eq!(custom.label_text_transform, TextTransform::Uppercase);
assert_eq!(custom.axis_line_weight, 1.0);
assert_eq!(custom.dot_radius, 5.0);
assert_eq!(custom.title_font_size, 14.0);
assert_eq!(custom.axis_line, "#374151");
}
}