Skip to main content

chartml_core/
theme.rs

1//! Chart theme — colors, typography, and shape defaults for chart chrome.
2//!
3//! The `Theme` struct provides all chrome properties used by chart renderers:
4//! colors (axes, grid, text, background), typography (fonts and sizes for
5//! titles, labels, and numeric tick labels), and shape defaults (stroke
6//! weights, dot radii, corner radii, grid styling).
7//!
8//! Plain values are written directly into SVG attributes, ensuring
9//! compatibility with every SVG renderer (browsers, resvg, Inkscape, etc.).
10//!
11//! ## Browser theming
12//!
13//! When charts are rendered in a browser, a `<style>` block inside the SVG
14//! maps element classes to CSS custom properties:
15//!
16//! ```css
17//! .axis-line { stroke: var(--chartml-axis-line) }
18//! .grid-line { stroke: var(--chartml-grid) }
19//! ```
20//!
21//! CSS specificity means these override the inline attribute defaults, so
22//! consuming apps can set `--chartml-axis-line: #9ca3af` on a parent element
23//! and charts respond instantly — no re-render needed.
24//!
25//! ## Server-side rendering
26//!
27//! For server-side rendering (e.g. `render_to_png()`), pass a `Theme` that
28//! matches your application's current appearance. The same `Theme` used
29//! server-side should match the CSS custom properties set browser-side,
30//! ensuring visual parity between both rendering paths.
31//!
32//! ## Example
33//!
34//! ```rust
35//! use chartml_core::theme::Theme;
36//!
37//! // Light mode (default)
38//! let light = Theme::default();
39//!
40//! // Dark mode
41//! let dark = Theme::dark();
42//!
43//! // Custom theme — `Theme` is `#[non_exhaustive]`, so consumers must
44//! // start from `Theme::default()` or `Theme::dark()` and mutate fields.
45//! // This makes adding new theme fields non-breaking forever.
46//! let mut custom = Theme::dark();
47//! custom.axis_line = "#9ca3af".into();
48//! custom.grid = "#374151".into();
49//! ```
50
51/// Grid line style — controls which gridlines are drawn.
52#[derive(Debug, Clone, PartialEq)]
53pub enum GridStyle {
54    /// Draw both horizontal and vertical gridlines (current default behavior).
55    Both,
56    /// Draw only horizontal gridlines.
57    HorizontalOnly,
58    /// Draw only vertical gridlines.
59    VerticalOnly,
60    /// Do not draw gridlines.
61    None,
62}
63
64/// Text transform applied to label text (tick labels, axis labels, legend).
65#[derive(Debug, Clone, PartialEq)]
66pub enum TextTransform {
67    /// No transform — render text as-is.
68    None,
69    /// Transform to uppercase.
70    Uppercase,
71    /// Transform to lowercase.
72    Lowercase,
73}
74
75/// Specification for the zero-line (baseline) overlay on value axes.
76#[derive(Debug, Clone, PartialEq)]
77pub struct ZeroLineSpec {
78    /// Stroke color for the zero line.
79    pub color: String,
80    /// Stroke width in pixels.
81    pub width: f32,
82}
83
84/// Which corners of a bar rect are rounded.
85///
86/// Default is `Uniform(0.0)` — no rounding — preserving the legacy square-bar
87/// behavior. `Uniform(r)` rounds all four corners equally; `Top(r)` rounds
88/// only the two corners at the maximum-value end of the bar (the top of a
89/// vertical bar, the right end of a horizontal bar, and — for bars with
90/// negative values — the end that points away from the zero baseline).
91#[derive(Debug, Clone, PartialEq)]
92pub enum BarCornerRadius {
93    /// Round all four corners uniformly with the given radius.
94    Uniform(f32),
95    /// Round only the "top" corners — the two corners at the maximum-value
96    /// end of the bar. For a vertical bar this is the top of the rect; for a
97    /// horizontal bar this is the right side (the end pointing away from the
98    /// category axis). For bars with negative values, the rounded corners
99    /// flip to the opposite end (the side pointing away from zero).
100    Top(f32),
101}
102
103impl Default for BarCornerRadius {
104    fn default() -> Self {
105        Self::Uniform(0.0)
106    }
107}
108
109/// Chart theme — colors, typography, and shape defaults.
110///
111/// Color fields are CSS color strings (typically hex like `"#374151"`) that
112/// are written directly into SVG `stroke` and `fill` attributes.
113///
114/// ## Line weight audit (Phase 2)
115///
116/// Defaults for the stroke-weight fields were chosen by auditing every
117/// hardcoded `stroke_width: Some(X.0)` across the renderer crates and
118/// categorizing each by role. The audit found:
119///
120/// - **axis_line_weight = 1.0** — universal across
121///   `chartml-chart-cartesian/src/helpers.rs` (lines 419, 684, 756, 866, 957)
122///   and `chartml-chart-scatter/src/lib.rs` (lines 237, 242). All axis lines
123///   currently use 1.0. (Tick marks also use 1.0 today and reuse this field.)
124/// - **grid_line_weight = 1.0** — universal across
125///   `chartml-chart-cartesian/src/helpers.rs` (lines 434, 780, 982) and
126///   `chartml-chart-scatter/src/lib.rs` (lines 168, 211).
127/// - **series_line_weight = 2.0** — majority value in
128///   `chartml-chart-cartesian/src/{line.rs:454, 563, 635}`, `area.rs:{212, 343, 481}`,
129///   and `bar.rs:1258` (combo line).
130///   Outlier: legend line symbols use 2.5 in `bar.rs:1352` and
131///   `chartml-core/src/layout/legend.rs:220` — this is a legend-specific glyph
132///   weight, not a series weight, and is intentionally not folded in.
133///   NOT included: `chartml-chart-pie/src/lib.rs:75` (pie slice border). The
134///   pie slice border uses `theme.bg` as its color — it is a background-colored
135///   separator gap between slices, not a series mark, and must NOT be wired to
136///   `series_line_weight` in later phases.
137/// - **annotation_line_weight = 1.0** — annotations currently read their
138///   stroke width from the spec (`ann.stroke_width` in `helpers.rs:1348, 1382`);
139///   no hardcoded default exists. 1.0 is chosen as the natural fallback for
140///   a future "annotation default" path (reference lines, brackets, etc.).
141/// - **bar_corner_radius = BarCornerRadius::Uniform(0.0)** — default is no
142///   rounding (byte-identical to pre-3.1 behavior). `Uniform(r)` rounds all
143///   four corners; `Top(r)` rounds only the two corners at the max-value end
144///   of the bar (see `BarCornerRadius` docs).
145/// - **dot_radius = 5.0** — matches `chartml-chart-scatter/src/lib.rs:106`
146///   (default when no size field) and line endpoint markers in
147///   `chartml-chart-cartesian/src/line.rs:{466, 577, 649}` and
148///   `bar.rs:1268` (combo line dots).
149///
150/// ## Construction contract
151///
152/// `Theme` is `#[non_exhaustive]`: external crates cannot construct it with
153/// a struct literal *or* the functional-update spread syntax. They must start
154/// from `Theme::default()` (or `Theme::dark()`) and mutate fields:
155///
156/// ```ignore
157/// let mut theme = Theme::default();
158/// theme.axis_line = "#9ca3af".into();
159/// theme.grid = "#374151".into();
160/// ```
161///
162/// This makes adding new fields genuinely non-breaking forever — every
163/// downstream consumer that follows the mutate-after-default pattern keeps
164/// compiling regardless of how many fields land in `Theme` later.
165#[derive(Debug, Clone, PartialEq)]
166#[non_exhaustive]
167pub struct Theme {
168    // ----- Chrome colors -----
169    /// Primary text color (metric values, param controls).
170    pub text: String,
171    /// Secondary text color (tick labels, axis labels, legend labels).
172    pub text_secondary: String,
173    /// Strong/emphasized text color (chart titles).
174    pub text_strong: String,
175    /// Axis line strokes (the main horizontal/vertical axis lines).
176    pub axis_line: String,
177    /// Tick mark strokes (small marks at each tick position).
178    pub tick: String,
179    /// Grid line strokes.
180    pub grid: String,
181    /// Background-aware stroke for element separators
182    /// (pie slice borders, dot outlines). Should match the chart background.
183    pub bg: String,
184
185    // ----- Typography: title -----
186    /// Font family for chart titles.
187    pub title_font_family: String,
188    /// Font size (px) for chart titles.
189    pub title_font_size: f32,
190    /// Font weight for chart titles.
191    pub title_font_weight: u16,
192    /// Font style (`"normal"` / `"italic"`) for chart titles.
193    pub title_font_style: String,
194
195    // ----- Typography: labels (tick labels, axis labels, data labels) -----
196    /// Font family for tick and axis labels.
197    pub label_font_family: String,
198    /// Font size (px) for tick and axis labels.
199    pub label_font_size: f32,
200    /// Font weight for tick and axis labels.
201    pub label_font_weight: u16,
202    /// Extra letter spacing (px) applied to labels.
203    pub label_letter_spacing: f32,
204    /// Text transform applied to labels.
205    pub label_text_transform: TextTransform,
206
207    // ----- Typography: numeric tick labels -----
208    /// Font family for numeric tick labels (e.g. a tabular/monospaced face).
209    pub numeric_font_family: String,
210    /// Font size (px) for numeric tick labels.
211    pub numeric_font_size: f32,
212
213    // ----- Typography: legend -----
214    /// Font family for legend labels.
215    pub legend_font_family: String,
216    /// Font size (px) for legend labels.
217    pub legend_font_size: f32,
218    /// Font weight for legend labels.
219    pub legend_font_weight: u16,
220
221    // ----- Shape / stroke -----
222    /// Stroke width for axis lines. See audit in struct-level doc.
223    pub axis_line_weight: f32,
224    /// Stroke width for grid lines. See audit in struct-level doc.
225    pub grid_line_weight: f32,
226    /// Stroke width for series marks (line paths, area outlines, combo line).
227    /// Pie slice borders are NOT series marks — they use `theme.bg` as a
228    /// background-colored separator. See audit in struct-level doc.
229    pub series_line_weight: f32,
230    /// Stroke width for annotation lines (reference lines, brackets).
231    /// See audit in struct-level doc.
232    pub annotation_line_weight: f32,
233    /// Corner radius for bar rects. Supports uniform rounding (all four
234    /// corners) and top-only rounding (the two corners at the max-value end
235    /// of the bar). When the enclosed radius is `0.0`, renderers MUST NOT
236    /// emit any `rx`/`ry` attribute and MUST emit a plain `<rect>` (to
237    /// preserve byte-identical output for un-themed charts).
238    pub bar_corner_radius: BarCornerRadius,
239    /// Default radius (px) for scatter points and line endpoint markers.
240    pub dot_radius: f32,
241    /// Optional halo/outline color for dots. When `None`, no halo is drawn.
242    pub dot_halo_color: Option<String>,
243    /// Halo stroke width (px). Only used when `dot_halo_color` is `Some`.
244    pub dot_halo_width: f32,
245
246    // ----- Grid + baseline -----
247    /// Which gridlines to draw (both, horizontal-only, vertical-only, none).
248    pub grid_style: GridStyle,
249    /// Optional emphasized zero line on the value axis. `None` = no zero line.
250    pub zero_line: Option<ZeroLineSpec>,
251
252    // ----- Table chart tokens -----
253    /// Background color for the table header row.
254    pub table_header_bg: String,
255    /// Text color for the table header row.
256    pub table_header_text: String,
257    /// Background color for a regular (non-striped) body row.
258    pub table_row_bg: String,
259    /// Background color for the alternating zebra-striped body row.
260    pub table_row_bg_alt: String,
261    /// Border color between cells and between header/body.
262    pub table_border: String,
263    /// Text color for body cells.
264    pub table_text: String,
265    /// CSS shorthand padding for body cells (e.g. `"8px 12px"`).
266    pub table_cell_padding: String,
267    /// CSS font size for table text (e.g. `"13px"`).
268    pub table_font_size: String,
269}
270
271impl Default for Theme {
272    /// Light mode theme — matches the `chartml.css` light-mode custom properties
273    /// and all currently hardcoded renderer defaults.
274    fn default() -> Self {
275        Self {
276            // Chrome colors
277            text: "#374151".into(),
278            text_secondary: "#6b7280".into(),
279            text_strong: "#1f2937".into(),
280            axis_line: "#374151".into(),
281            tick: "#374151".into(),
282            grid: "#e0e0e0".into(),
283            bg: "#ffffff".into(),
284
285            // Title typography
286            title_font_family: "system-ui, sans-serif".into(),
287            title_font_size: 14.0,
288            title_font_weight: 700,
289            title_font_style: "normal".into(),
290
291            // Label typography
292            label_font_family: "system-ui, sans-serif".into(),
293            label_font_size: 12.0,
294            label_font_weight: 400,
295            label_letter_spacing: 0.0,
296            label_text_transform: TextTransform::None,
297
298            // Numeric typography
299            numeric_font_family: "system-ui, sans-serif".into(),
300            numeric_font_size: 12.0,
301
302            // Legend typography
303            legend_font_family: "system-ui, sans-serif".into(),
304            legend_font_size: 12.0,
305            legend_font_weight: 400,
306
307            // Shape / stroke — see struct-level audit
308            axis_line_weight: 1.0,
309            grid_line_weight: 1.0,
310            series_line_weight: 2.0,
311            annotation_line_weight: 1.0,
312            bar_corner_radius: BarCornerRadius::Uniform(0.0),
313            dot_radius: 5.0,
314            dot_halo_color: None,
315            dot_halo_width: 0.0,
316
317            // Grid + baseline
318            grid_style: GridStyle::Both,
319            zero_line: None,
320
321            // Table tokens — light mode defaults
322            table_header_bg: "#f9fafb".into(),
323            table_header_text: "#1f2937".into(),
324            table_row_bg: "#ffffff".into(),
325            table_row_bg_alt: "#f9fafb".into(),
326            table_border: "#e5e7eb".into(),
327            table_text: "#374151".into(),
328            table_cell_padding: "8px 12px".into(),
329            table_font_size: "13px".into(),
330        }
331    }
332}
333
334impl Theme {
335    /// Dark mode theme — matches the `chartml.css` dark-mode custom properties.
336    ///
337    /// Typography and shape defaults are identical to `Theme::default()`;
338    /// chrome color fields and table tokens are overridden to dark values.
339    pub fn dark() -> Self {
340        Self {
341            text: "#e5e7eb".into(),
342            text_secondary: "#9ca3af".into(),
343            text_strong: "#f3f4f6".into(),
344            axis_line: "#9ca3af".into(),
345            tick: "#9ca3af".into(),
346            grid: "#374151".into(),
347            bg: "#1f2937".into(),
348            table_header_bg: "#111827".into(),
349            table_header_text: "#f3f4f6".into(),
350            table_row_bg: "#1f2937".into(),
351            table_row_bg_alt: "#111827".into(),
352            table_border: "#374151".into(),
353            table_text: "#e5e7eb".into(),
354            ..Theme::default()
355        }
356    }
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362
363    #[test]
364    fn default_theme_has_no_css_var_values() {
365        let theme = Theme::default();
366        let all_values = [
367            &theme.text, &theme.text_secondary, &theme.text_strong,
368            &theme.axis_line, &theme.tick, &theme.grid, &theme.bg,
369        ];
370        for value in all_values {
371            assert!(
372                !value.contains("var("),
373                "Theme value should be plain hex, not CSS var(): {value}"
374            );
375        }
376    }
377
378    #[test]
379    fn dark_theme_has_no_css_var_values() {
380        let theme = Theme::dark();
381        let all_values = [
382            &theme.text, &theme.text_secondary, &theme.text_strong,
383            &theme.axis_line, &theme.tick, &theme.grid, &theme.bg,
384        ];
385        for value in all_values {
386            assert!(
387                !value.contains("var("),
388                "Dark theme value should be plain hex, not CSS var(): {value}"
389            );
390        }
391    }
392
393    #[test]
394    fn theme_fields_are_customizable() {
395        let custom = Theme {
396            axis_line: "#ff0000".into(),
397            ..Theme::dark()
398        };
399        assert_eq!(custom.axis_line, "#ff0000");
400        assert_eq!(custom.grid, "#374151"); // rest from dark
401    }
402
403    // ---- Phase 2: new field default tests ----
404
405    #[test]
406    fn default_title_typography() {
407        let t = Theme::default();
408        assert_eq!(t.title_font_family, "system-ui, sans-serif");
409        assert_eq!(t.title_font_size, 14.0);
410        assert_eq!(t.title_font_weight, 700);
411        assert_eq!(t.title_font_style, "normal");
412    }
413
414    #[test]
415    fn default_label_typography() {
416        let t = Theme::default();
417        assert_eq!(t.label_font_family, "system-ui, sans-serif");
418        assert_eq!(t.label_font_size, 12.0);
419        assert_eq!(t.label_font_weight, 400);
420        assert_eq!(t.label_letter_spacing, 0.0);
421        assert_eq!(t.label_text_transform, TextTransform::None);
422    }
423
424    #[test]
425    fn default_numeric_typography() {
426        let t = Theme::default();
427        assert_eq!(t.numeric_font_family, "system-ui, sans-serif");
428        assert_eq!(t.numeric_font_size, 12.0);
429    }
430
431    #[test]
432    fn default_legend_typography() {
433        let t = Theme::default();
434        assert_eq!(t.legend_font_family, "system-ui, sans-serif");
435        assert_eq!(t.legend_font_size, 12.0);
436        assert_eq!(t.legend_font_weight, 400);
437    }
438
439    #[test]
440    fn default_stroke_weights_match_audit() {
441        let t = Theme::default();
442        assert_eq!(t.axis_line_weight, 1.0);
443        assert_eq!(t.grid_line_weight, 1.0);
444        assert_eq!(t.series_line_weight, 2.0);
445        assert_eq!(t.annotation_line_weight, 1.0);
446    }
447
448    #[test]
449    fn default_shape_fields() {
450        let t = Theme::default();
451        assert_eq!(t.bar_corner_radius, BarCornerRadius::Uniform(0.0));
452        assert_eq!(t.dot_radius, 5.0);
453        assert!(t.dot_halo_color.is_none());
454        assert_eq!(t.dot_halo_width, 0.0);
455    }
456
457    #[test]
458    fn default_grid_style_is_both() {
459        assert_eq!(Theme::default().grid_style, GridStyle::Both);
460    }
461
462    #[test]
463    fn default_zero_line_is_none() {
464        assert!(Theme::default().zero_line.is_none());
465    }
466
467    #[test]
468    fn dark_theme_inherits_typography_and_shape_from_default() {
469        let d = Theme::default();
470        let k = Theme::dark();
471        // Typography
472        assert_eq!(d.title_font_size, k.title_font_size);
473        assert_eq!(d.label_font_weight, k.label_font_weight);
474        assert_eq!(d.numeric_font_family, k.numeric_font_family);
475        assert_eq!(d.legend_font_family, k.legend_font_family);
476        // Shape
477        assert_eq!(d.axis_line_weight, k.axis_line_weight);
478        assert_eq!(d.grid_line_weight, k.grid_line_weight);
479        assert_eq!(d.series_line_weight, k.series_line_weight);
480        assert_eq!(d.dot_radius, k.dot_radius);
481        assert_eq!(d.bar_corner_radius, k.bar_corner_radius);
482        // Grid + baseline
483        assert_eq!(d.grid_style, k.grid_style);
484        assert_eq!(d.zero_line, k.zero_line);
485    }
486
487    #[test]
488    fn custom_theme_can_override_new_fields_individually() {
489        let custom = Theme {
490            series_line_weight: 3.5,
491            bar_corner_radius: BarCornerRadius::Uniform(4.0),
492            dot_halo_color: Some("#ffffff".into()),
493            dot_halo_width: 2.0,
494            grid_style: GridStyle::HorizontalOnly,
495            zero_line: Some(ZeroLineSpec { color: "#000000".into(), width: 1.5 }),
496            label_text_transform: TextTransform::Uppercase,
497            ..Theme::default()
498        };
499        // Overridden
500        assert_eq!(custom.series_line_weight, 3.5);
501        assert_eq!(custom.bar_corner_radius, BarCornerRadius::Uniform(4.0));
502        assert_eq!(custom.dot_halo_color.as_deref(), Some("#ffffff"));
503        assert_eq!(custom.dot_halo_width, 2.0);
504        assert_eq!(custom.grid_style, GridStyle::HorizontalOnly);
505        assert_eq!(
506            custom.zero_line,
507            Some(ZeroLineSpec { color: "#000000".into(), width: 1.5 })
508        );
509        assert_eq!(custom.label_text_transform, TextTransform::Uppercase);
510        // Not overridden — should match Default
511        assert_eq!(custom.axis_line_weight, 1.0);
512        assert_eq!(custom.dot_radius, 5.0);
513        assert_eq!(custom.title_font_size, 14.0);
514        assert_eq!(custom.axis_line, "#374151");
515    }
516}