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    /// CSS font-weight for header cells (e.g. `"600"`, `"500"`).
258    pub table_header_font_weight: String,
259    /// CSS letter-spacing for header cells (e.g. `"normal"`, `"0.08em"`).
260    pub table_header_letter_spacing: String,
261    /// CSS text-transform for header cells (e.g. `"none"`, `"uppercase"`).
262    pub table_header_text_transform: String,
263    /// Border color for header cell bottom edge. Empty string falls back
264    /// to `table_border`.
265    pub table_header_border: String,
266    /// Background color for a regular (non-striped) body row.
267    pub table_row_bg: String,
268    /// Background color for the alternating zebra-striped body row.
269    pub table_row_bg_alt: String,
270    /// Border color between cells and between header/body.
271    pub table_border: String,
272    /// Text color for body cells.
273    pub table_text: String,
274    /// CSS shorthand padding for body cells (e.g. `"8px 12px"`).
275    pub table_cell_padding: String,
276    /// CSS font size for table text (e.g. `"13px"`).
277    pub table_font_size: String,
278    /// CSS border-radius for the table root container (e.g. `"4px"`, `"0"`).
279    pub table_border_radius: String,
280}
281
282impl Default for Theme {
283    /// Light mode theme — matches the `chartml.css` light-mode custom properties
284    /// and all currently hardcoded renderer defaults.
285    fn default() -> Self {
286        Self {
287            // Chrome colors
288            text: "#374151".into(),
289            text_secondary: "#6b7280".into(),
290            text_strong: "#1f2937".into(),
291            axis_line: "#374151".into(),
292            tick: "#374151".into(),
293            grid: "#e0e0e0".into(),
294            bg: "#ffffff".into(),
295
296            // Title typography
297            title_font_family: "system-ui, sans-serif".into(),
298            title_font_size: 14.0,
299            title_font_weight: 700,
300            title_font_style: "normal".into(),
301
302            // Label typography
303            label_font_family: "system-ui, sans-serif".into(),
304            label_font_size: 12.0,
305            label_font_weight: 400,
306            label_letter_spacing: 0.0,
307            label_text_transform: TextTransform::None,
308
309            // Numeric typography
310            numeric_font_family: "system-ui, sans-serif".into(),
311            numeric_font_size: 12.0,
312
313            // Legend typography
314            legend_font_family: "system-ui, sans-serif".into(),
315            legend_font_size: 12.0,
316            legend_font_weight: 400,
317
318            // Shape / stroke — see struct-level audit
319            axis_line_weight: 1.0,
320            grid_line_weight: 1.0,
321            series_line_weight: 2.0,
322            annotation_line_weight: 1.0,
323            bar_corner_radius: BarCornerRadius::Uniform(0.0),
324            dot_radius: 5.0,
325            dot_halo_color: None,
326            dot_halo_width: 0.0,
327
328            // Grid + baseline
329            grid_style: GridStyle::Both,
330            zero_line: None,
331
332            // Table tokens — light mode defaults
333            table_header_bg: "#f9fafb".into(),
334            table_header_text: "#1f2937".into(),
335            table_header_font_weight: "600".into(),
336            table_header_letter_spacing: "normal".into(),
337            table_header_text_transform: "none".into(),
338            table_header_border: String::new(),
339            table_row_bg: "#ffffff".into(),
340            table_row_bg_alt: "#f9fafb".into(),
341            table_border: "#e5e7eb".into(),
342            table_text: "#374151".into(),
343            table_cell_padding: "8px 12px".into(),
344            table_font_size: "13px".into(),
345            table_border_radius: "4px".into(),
346        }
347    }
348}
349
350impl Theme {
351    /// Dark mode theme — matches the `chartml.css` dark-mode custom properties.
352    ///
353    /// Typography and shape defaults are identical to `Theme::default()`;
354    /// chrome color fields and table tokens are overridden to dark values.
355    pub fn dark() -> Self {
356        Self {
357            text: "#e5e7eb".into(),
358            text_secondary: "#9ca3af".into(),
359            text_strong: "#f3f4f6".into(),
360            axis_line: "#9ca3af".into(),
361            tick: "#9ca3af".into(),
362            grid: "#374151".into(),
363            bg: "#1f2937".into(),
364            table_header_bg: "#111827".into(),
365            table_header_text: "#f3f4f6".into(),
366            table_row_bg: "#1f2937".into(),
367            table_row_bg_alt: "#111827".into(),
368            table_border: "#374151".into(),
369            table_text: "#e5e7eb".into(),
370            ..Theme::default()
371        }
372    }
373}
374
375#[cfg(test)]
376mod tests {
377    #![allow(clippy::unwrap_used)]
378    use super::*;
379
380    #[test]
381    fn default_theme_has_no_css_var_values() {
382        let theme = Theme::default();
383        let all_values = [
384            &theme.text, &theme.text_secondary, &theme.text_strong,
385            &theme.axis_line, &theme.tick, &theme.grid, &theme.bg,
386        ];
387        for value in all_values {
388            assert!(
389                !value.contains("var("),
390                "Theme value should be plain hex, not CSS var(): {value}"
391            );
392        }
393    }
394
395    #[test]
396    fn dark_theme_has_no_css_var_values() {
397        let theme = Theme::dark();
398        let all_values = [
399            &theme.text, &theme.text_secondary, &theme.text_strong,
400            &theme.axis_line, &theme.tick, &theme.grid, &theme.bg,
401        ];
402        for value in all_values {
403            assert!(
404                !value.contains("var("),
405                "Dark theme value should be plain hex, not CSS var(): {value}"
406            );
407        }
408    }
409
410    #[test]
411    fn theme_fields_are_customizable() {
412        let custom = Theme {
413            axis_line: "#ff0000".into(),
414            ..Theme::dark()
415        };
416        assert_eq!(custom.axis_line, "#ff0000");
417        assert_eq!(custom.grid, "#374151"); // rest from dark
418    }
419
420    // ---- Phase 2: new field default tests ----
421
422    #[test]
423    fn default_title_typography() {
424        let t = Theme::default();
425        assert_eq!(t.title_font_family, "system-ui, sans-serif");
426        assert_eq!(t.title_font_size, 14.0);
427        assert_eq!(t.title_font_weight, 700);
428        assert_eq!(t.title_font_style, "normal");
429    }
430
431    #[test]
432    fn default_label_typography() {
433        let t = Theme::default();
434        assert_eq!(t.label_font_family, "system-ui, sans-serif");
435        assert_eq!(t.label_font_size, 12.0);
436        assert_eq!(t.label_font_weight, 400);
437        assert_eq!(t.label_letter_spacing, 0.0);
438        assert_eq!(t.label_text_transform, TextTransform::None);
439    }
440
441    #[test]
442    fn default_numeric_typography() {
443        let t = Theme::default();
444        assert_eq!(t.numeric_font_family, "system-ui, sans-serif");
445        assert_eq!(t.numeric_font_size, 12.0);
446    }
447
448    #[test]
449    fn default_legend_typography() {
450        let t = Theme::default();
451        assert_eq!(t.legend_font_family, "system-ui, sans-serif");
452        assert_eq!(t.legend_font_size, 12.0);
453        assert_eq!(t.legend_font_weight, 400);
454    }
455
456    #[test]
457    fn default_stroke_weights_match_audit() {
458        let t = Theme::default();
459        assert_eq!(t.axis_line_weight, 1.0);
460        assert_eq!(t.grid_line_weight, 1.0);
461        assert_eq!(t.series_line_weight, 2.0);
462        assert_eq!(t.annotation_line_weight, 1.0);
463    }
464
465    #[test]
466    fn default_shape_fields() {
467        let t = Theme::default();
468        assert_eq!(t.bar_corner_radius, BarCornerRadius::Uniform(0.0));
469        assert_eq!(t.dot_radius, 5.0);
470        assert!(t.dot_halo_color.is_none());
471        assert_eq!(t.dot_halo_width, 0.0);
472    }
473
474    #[test]
475    fn default_grid_style_is_both() {
476        assert_eq!(Theme::default().grid_style, GridStyle::Both);
477    }
478
479    #[test]
480    fn default_zero_line_is_none() {
481        assert!(Theme::default().zero_line.is_none());
482    }
483
484    #[test]
485    fn dark_theme_inherits_typography_and_shape_from_default() {
486        let d = Theme::default();
487        let k = Theme::dark();
488        // Typography
489        assert_eq!(d.title_font_size, k.title_font_size);
490        assert_eq!(d.label_font_weight, k.label_font_weight);
491        assert_eq!(d.numeric_font_family, k.numeric_font_family);
492        assert_eq!(d.legend_font_family, k.legend_font_family);
493        // Shape
494        assert_eq!(d.axis_line_weight, k.axis_line_weight);
495        assert_eq!(d.grid_line_weight, k.grid_line_weight);
496        assert_eq!(d.series_line_weight, k.series_line_weight);
497        assert_eq!(d.dot_radius, k.dot_radius);
498        assert_eq!(d.bar_corner_radius, k.bar_corner_radius);
499        // Grid + baseline
500        assert_eq!(d.grid_style, k.grid_style);
501        assert_eq!(d.zero_line, k.zero_line);
502    }
503
504    #[test]
505    fn custom_theme_can_override_new_fields_individually() {
506        let custom = Theme {
507            series_line_weight: 3.5,
508            bar_corner_radius: BarCornerRadius::Uniform(4.0),
509            dot_halo_color: Some("#ffffff".into()),
510            dot_halo_width: 2.0,
511            grid_style: GridStyle::HorizontalOnly,
512            zero_line: Some(ZeroLineSpec { color: "#000000".into(), width: 1.5 }),
513            label_text_transform: TextTransform::Uppercase,
514            ..Theme::default()
515        };
516        // Overridden
517        assert_eq!(custom.series_line_weight, 3.5);
518        assert_eq!(custom.bar_corner_radius, BarCornerRadius::Uniform(4.0));
519        assert_eq!(custom.dot_halo_color.as_deref(), Some("#ffffff"));
520        assert_eq!(custom.dot_halo_width, 2.0);
521        assert_eq!(custom.grid_style, GridStyle::HorizontalOnly);
522        assert_eq!(
523            custom.zero_line,
524            Some(ZeroLineSpec { color: "#000000".into(), width: 1.5 })
525        );
526        assert_eq!(custom.label_text_transform, TextTransform::Uppercase);
527        // Not overridden — should match Default
528        assert_eq!(custom.axis_line_weight, 1.0);
529        assert_eq!(custom.dot_radius, 5.0);
530        assert_eq!(custom.title_font_size, 14.0);
531        assert_eq!(custom.axis_line, "#374151");
532    }
533}