Skip to main content

plotkit_core/
theme.rs

1//! Theme system controlling all visual defaults.
2//!
3//! A plot rendered with zero custom styling must look professional. The
4//! [`Theme::default()`] configuration follows the Visual Design Brief:
5//!
6//! - White background, despined axes (top + right hidden), light grid behind data.
7//! - Tableau-10 categorical palette, viridis continuous colormap.
8//! - Outward ticks, readable font sizes (title 14 pt bold, labels 11 pt, ticks 9 pt).
9//!
10//! Additional built-in themes are available via [`Theme::dark()`],
11//! [`Theme::seaborn()`], [`Theme::ggplot()`], [`Theme::publication()`],
12//! [`Theme::nature()`], and [`Theme::solarized()`].
13
14use crate::primitives::{Color, FontWeight};
15
16// ---------------------------------------------------------------------------
17// LineStyle
18// ---------------------------------------------------------------------------
19
20/// Line drawing style.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22#[non_exhaustive]
23pub enum LineStyle {
24    /// A continuous solid line.
25    Solid,
26    /// A dashed line (e.g. `[6, 4]`).
27    Dashed,
28    /// A dotted line (e.g. `[2, 2]`).
29    Dotted,
30    /// Alternating long dash and dot (e.g. `[6, 3, 2, 3]`).
31    DashDot,
32}
33
34// ---------------------------------------------------------------------------
35// Marker
36// ---------------------------------------------------------------------------
37
38/// Scatter plot marker shape.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40#[non_exhaustive]
41pub enum Marker {
42    /// Filled circle.
43    Circle,
44    /// Filled square.
45    Square,
46    /// Filled upward-pointing triangle.
47    Triangle,
48    /// Filled diamond (rotated square).
49    Diamond,
50    /// Axis-aligned plus sign (stroked, not filled).
51    Plus,
52    /// Diagonal cross / X (stroked, not filled).
53    Cross,
54    /// Five-pointed star.
55    Star,
56    /// A single pixel-sized point (smallest possible marker).
57    Point,
58}
59
60// ---------------------------------------------------------------------------
61// Loc (legend location)
62// ---------------------------------------------------------------------------
63
64/// Legend location relative to the axes area.
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66#[non_exhaustive]
67pub enum Loc {
68    /// Automatically choose the location that overlaps the fewest data points.
69    Best,
70    /// Upper-right corner.
71    UpperRight,
72    /// Upper-left corner.
73    UpperLeft,
74    /// Lower-left corner.
75    LowerLeft,
76    /// Lower-right corner.
77    LowerRight,
78    /// Centered on the right edge.
79    Right,
80    /// Centered on the left edge.
81    CenterLeft,
82    /// Centered on the right edge (alias kept for symmetry).
83    CenterRight,
84    /// Centered on the bottom edge.
85    LowerCenter,
86    /// Centered on the top edge.
87    UpperCenter,
88    /// Dead center of the axes area.
89    Center,
90}
91
92// ---------------------------------------------------------------------------
93// GridAxis
94// ---------------------------------------------------------------------------
95
96/// Which axes should display grid lines.
97#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
98pub enum GridAxis {
99    /// Grid lines for the x-axis only (vertical lines at each x-tick).
100    X,
101    /// Grid lines for the y-axis only (horizontal lines at each y-tick).
102    Y,
103    /// Grid lines for both axes (the default).
104    #[default]
105    Both,
106}
107
108// ---------------------------------------------------------------------------
109// TickDirection
110// ---------------------------------------------------------------------------
111
112/// Direction in which axis tick marks extend from the spine.
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub enum TickDirection {
115    /// Ticks extend outward, away from the data area.
116    Outward,
117    /// Ticks extend inward, into the data area.
118    Inward,
119}
120
121// ---------------------------------------------------------------------------
122// Theme
123// ---------------------------------------------------------------------------
124
125/// Visual theme controlling all rendering defaults.
126///
127/// Every visual parameter that a renderer or layout engine might need lives
128/// here. Chart builders read from the active theme to fill in any value the
129/// user did not override explicitly.
130#[derive(Debug, Clone)]
131pub struct Theme {
132    /// Background color for the entire figure (outside the axes area).
133    pub figure_background: Color,
134    /// Background color for the axes face (the data-drawing region).
135    pub axes_background: Color,
136
137    // -- Grid ---------------------------------------------------------------
138    /// Color of major grid lines.
139    pub grid_color: Color,
140    /// Width (in px) of major grid lines.
141    pub grid_width: f64,
142    /// Whether the grid is shown by default (line/scatter: true; bar/hist: false).
143    pub show_grid: bool,
144
145    // -- Spines -------------------------------------------------------------
146    /// Color of visible axis spines.
147    pub spine_color: Color,
148    /// Width (in px) of axis spines.
149    pub spine_width: f64,
150    /// Whether the top spine is drawn.
151    pub show_top_spine: bool,
152    /// Whether the right spine is drawn.
153    pub show_right_spine: bool,
154    /// Whether the bottom spine is drawn.
155    pub show_bottom_spine: bool,
156    /// Whether the left spine is drawn.
157    pub show_left_spine: bool,
158
159    // -- Ticks --------------------------------------------------------------
160    /// Color of tick marks and tick labels.
161    pub tick_color: Color,
162    /// Length (in px) of major tick marks.
163    pub tick_length: f64,
164    /// Direction ticks extend from the spine.
165    pub tick_direction: TickDirection,
166    /// Font size (in pt) for tick labels.
167    pub tick_label_size: f64,
168
169    // -- Labels & Title -----------------------------------------------------
170    /// Font size (in pt) for axis labels.
171    pub axis_label_size: f64,
172    /// Font size (in pt) for the plot title.
173    pub title_size: f64,
174    /// Font weight for the plot title.
175    pub title_weight: FontWeight,
176    /// Color used for all text (titles, labels, tick labels).
177    pub text_color: Color,
178
179    // -- Data elements ------------------------------------------------------
180    /// Default line width (in px) for line plots.
181    pub line_width: f64,
182    /// Default marker diameter (in px) for scatter plots.
183    pub marker_size: f64,
184    /// Default marker opacity (0.0 = fully transparent, 1.0 = fully opaque).
185    pub marker_alpha: f64,
186
187    // -- Palette ------------------------------------------------------------
188    /// Categorical color cycle used when the user does not specify colors.
189    pub color_cycle: Vec<Color>,
190
191    // -- Font ---------------------------------------------------------------
192    /// Optional font family override. `None` means the renderer picks its
193    /// built-in default (typically a clean sans-serif such as Helvetica).
194    pub font_family: Option<String>,
195}
196
197// ---------------------------------------------------------------------------
198// Tableau-10 palette (convenience constant)
199// ---------------------------------------------------------------------------
200
201/// The Tableau-10 categorical palette as a fixed-size array.
202const TABLEAU_10: [Color; 10] = Color::TABLEAU_10;
203
204// ---------------------------------------------------------------------------
205// Default theme
206// ---------------------------------------------------------------------------
207
208impl Default for Theme {
209    /// Returns the canonical default theme matching the Visual Design Brief.
210    ///
211    /// - Background: `#FFFFFF`, axes face: `#FFFFFF`
212    /// - Grid: `#E6E6E6`, 1 px, shown by default
213    /// - Spines: `#333333`, 1 px, top + right hidden (despine look)
214    /// - Ticks: outward, 4 px, `#333333`
215    /// - Font sizes: title 14 pt bold, axis labels 11 pt, tick labels 9 pt
216    /// - Text color: `#333333`
217    /// - Line width 1.5 px, marker 6 px diameter, marker alpha 0.8
218    /// - Tableau-10 color cycle
219    fn default() -> Self {
220        let spine = Color::rgb(0x33, 0x33, 0x33);
221
222        Self {
223            figure_background: Color::WHITE,
224            axes_background: Color::WHITE,
225
226            grid_color: Color::rgb(0xE6, 0xE6, 0xE6),
227            grid_width: 1.0,
228            show_grid: true,
229
230            spine_color: spine,
231            spine_width: 1.0,
232            show_top_spine: false,
233            show_right_spine: false,
234            show_bottom_spine: true,
235            show_left_spine: true,
236
237            tick_color: spine,
238            tick_length: 4.0,
239            tick_direction: TickDirection::Outward,
240            tick_label_size: 9.0,
241
242            axis_label_size: 11.0,
243            title_size: 14.0,
244            title_weight: FontWeight::Bold,
245            text_color: spine,
246
247            line_width: 1.5,
248            marker_size: 6.0,
249            marker_alpha: 0.8,
250
251            color_cycle: TABLEAU_10.to_vec(),
252
253            font_family: None,
254        }
255    }
256}
257
258// ---------------------------------------------------------------------------
259// Named themes
260// ---------------------------------------------------------------------------
261
262impl Theme {
263    /// Dark theme with a near-black background and bright, neon-ish data colors.
264    ///
265    /// Suited for dashboards and presentations on dark backgrounds.
266    pub fn dark() -> Self {
267        let bg = Color::rgb(0x1C, 0x1C, 0x1C);
268        let text = Color::rgb(0xE0, 0xE0, 0xE0);
269        let grid = Color::rgb(0x3A, 0x3A, 0x3A);
270        let spine = Color::rgb(0x55, 0x55, 0x55);
271
272        // Bright / neon-ish palette optimised for dark backgrounds.
273        let cycle = vec![
274            Color::rgb(0x00, 0xD4, 0xFF), // cyan
275            Color::rgb(0xFF, 0x6F, 0x61), // coral-red
276            Color::rgb(0x7B, 0xED, 0x72), // lime-green
277            Color::rgb(0xFF, 0xA6, 0x00), // amber
278            Color::rgb(0xD1, 0x7D, 0xFF), // violet
279            Color::rgb(0xFF, 0xE1, 0x00), // yellow
280            Color::rgb(0x00, 0xFF, 0xAB), // mint
281            Color::rgb(0xFF, 0x4D, 0xA6), // hot-pink
282            Color::rgb(0x48, 0xBF, 0xE3), // sky-blue
283            Color::rgb(0xE8, 0xE8, 0xE8), // light-grey
284        ];
285
286        Self {
287            figure_background: bg,
288            axes_background: bg,
289
290            grid_color: grid,
291            grid_width: 1.0,
292            show_grid: true,
293
294            spine_color: spine,
295            spine_width: 1.0,
296            show_top_spine: false,
297            show_right_spine: false,
298            show_bottom_spine: true,
299            show_left_spine: true,
300
301            tick_color: text,
302            tick_length: 4.0,
303            tick_direction: TickDirection::Outward,
304            tick_label_size: 9.0,
305
306            axis_label_size: 11.0,
307            title_size: 14.0,
308            title_weight: FontWeight::Bold,
309            text_color: text,
310
311            line_width: 1.5,
312            marker_size: 6.0,
313            marker_alpha: 0.9,
314
315            color_cycle: cycle,
316
317            font_family: None,
318        }
319    }
320
321    /// Seaborn-inspired theme with a tinted axes background and white grid.
322    ///
323    /// Mimics the popular seaborn `"whitegrid"` aesthetic: a pale blue-grey
324    /// axes face (`#EAEAF2`) with white grid lines over it. Top and right
325    /// spines are hidden for the characteristic despined look. Grid lines
326    /// are slightly thicker than default for visual weight against the
327    /// tinted background. Uses a muted color palette via Tableau-10 and a
328    /// sans-serif font family.
329    pub fn seaborn() -> Self {
330        let text = Color::rgb(0x33, 0x33, 0x33);
331        let axes_bg = Color::rgb(0xEA, 0xEA, 0xF2);
332
333        // Seaborn muted palette (desaturated version of standard colors).
334        let cycle = vec![
335            Color::rgb(0x4C, 0x72, 0xB0), // muted blue
336            Color::rgb(0xDD, 0x85, 0x52), // muted orange
337            Color::rgb(0x55, 0xA8, 0x68), // muted green
338            Color::rgb(0xC4, 0x4E, 0x52), // muted red
339            Color::rgb(0x81, 0x72, 0xB3), // muted purple
340            Color::rgb(0x93, 0x7A, 0x60), // muted brown
341            Color::rgb(0xDA, 0x8B, 0xC3), // muted pink
342            Color::rgb(0x8C, 0x8C, 0x8C), // muted grey
343            Color::rgb(0xCC, 0xB9, 0x74), // muted olive
344            Color::rgb(0x64, 0xB5, 0xCD), // muted cyan
345        ];
346
347        Self {
348            figure_background: Color::WHITE,
349            axes_background: axes_bg,
350
351            grid_color: Color::WHITE,
352            grid_width: 1.5,
353            show_grid: true,
354
355            spine_color: Color::rgb(0xCC, 0xCC, 0xCC),
356            spine_width: 1.0,
357            show_top_spine: false,
358            show_right_spine: false,
359            show_bottom_spine: true,
360            show_left_spine: true,
361
362            tick_color: text,
363            tick_length: 0.0, // seaborn typically hides tick marks
364            tick_direction: TickDirection::Outward,
365            tick_label_size: 9.0,
366
367            axis_label_size: 11.0,
368            title_size: 14.0,
369            title_weight: FontWeight::Bold,
370            text_color: text,
371
372            line_width: 1.5,
373            marker_size: 6.0,
374            marker_alpha: 0.8,
375
376            color_cycle: cycle,
377
378            font_family: Some("sans-serif".to_string()),
379        }
380    }
381
382    /// ggplot2-inspired theme with a grey panel and white grid.
383    ///
384    /// Reproduces the characteristic look of R's ggplot2: a medium-grey panel
385    /// (`#E5E5E5`), white major grid lines, a thin panel border around all
386    /// four sides, and the ggplot2 default qualitative palette. The title is
387    /// rendered bold in the classic ggplot2 aesthetic.
388    pub fn ggplot() -> Self {
389        let panel = Color::rgb(0xE5, 0xE5, 0xE5);
390        let text = Color::rgb(0x30, 0x30, 0x30);
391        let border = Color::rgb(0x80, 0x80, 0x80);
392
393        // Classic ggplot2 qualitative palette (first 8 hues at C=100, L=65).
394        let cycle = vec![
395            Color::rgb(0xF8, 0x76, 0x6D), // red
396            Color::rgb(0xA3, 0xA5, 0x00), // olive-yellow
397            Color::rgb(0x00, 0xBA, 0x38), // green
398            Color::rgb(0x00, 0xBF, 0xC4), // teal
399            Color::rgb(0x61, 0x9C, 0xFF), // blue
400            Color::rgb(0xF5, 0x64, 0xE3), // magenta
401            Color::rgb(0xFF, 0x64, 0xB0), // pink
402            Color::rgb(0xB7, 0x9F, 0x00), // gold
403        ];
404
405        Self {
406            figure_background: Color::WHITE,
407            axes_background: panel,
408
409            grid_color: Color::WHITE,
410            grid_width: 1.0,
411            show_grid: true,
412
413            // Panel border around all four sides.
414            spine_color: border,
415            spine_width: 0.5,
416            show_top_spine: true,
417            show_right_spine: true,
418            show_bottom_spine: true,
419            show_left_spine: true,
420
421            tick_color: text,
422            tick_length: 0.0, // no visible ticks in ggplot2 default
423            tick_direction: TickDirection::Outward,
424            tick_label_size: 9.0,
425
426            axis_label_size: 11.0,
427            title_size: 14.0,
428            title_weight: FontWeight::Bold,
429            text_color: text,
430
431            line_width: 1.0,
432            marker_size: 5.0,
433            marker_alpha: 1.0,
434
435            color_cycle: cycle,
436
437            font_family: None,
438        }
439    }
440
441    /// Publication-ready theme: crisp, minimal, and suitable for print.
442    ///
443    /// Designed for journal submissions and academic papers at 300+ DPI:
444    ///
445    /// - Pure white background, no grid by default.
446    /// - All four thin black spines (0.5 px) for a complete panel frame.
447    /// - Inward ticks for a compact footprint that does not intrude on margins.
448    /// - Larger axis labels (12 pt) for readability at reduced figure sizes.
449    /// - Serif font family for traditional academic aesthetics.
450    pub fn publication() -> Self {
451        let ink = Color::rgb(0x1A, 0x1A, 0x1A);
452
453        Self {
454            figure_background: Color::WHITE,
455            axes_background: Color::WHITE,
456
457            grid_color: Color::rgb(0xD0, 0xD0, 0xD0),
458            grid_width: 0.5,
459            show_grid: false,
460
461            spine_color: ink,
462            spine_width: 0.5,
463            show_top_spine: true,
464            show_right_spine: true,
465            show_bottom_spine: true,
466            show_left_spine: true,
467
468            tick_color: ink,
469            tick_length: 3.0,
470            tick_direction: TickDirection::Inward,
471            tick_label_size: 8.0,
472
473            axis_label_size: 12.0,
474            title_size: 13.0,
475            title_weight: FontWeight::Bold,
476            text_color: ink,
477
478            line_width: 1.0,
479            marker_size: 4.0,
480            marker_alpha: 1.0,
481
482            color_cycle: TABLEAU_10.to_vec(),
483
484            font_family: Some("serif".to_string()),
485        }
486    }
487
488    /// Nature/Science journal theme: ultra-clean and compact.
489    ///
490    /// Inspired by the house style of top scientific journals such as
491    /// *Nature* and *Science*:
492    ///
493    /// - White background with no unnecessary decoration.
494    /// - Bold axis labels for immediate readability in multi-panel figures.
495    /// - Thin spines (0.75 px) on bottom and left only; top and right hidden.
496    /// - Compact font sizes suited for narrow column widths.
497    /// - Sans-serif font family (Helvetica/Arial style) per journal guidelines.
498    pub fn nature() -> Self {
499        let ink = Color::rgb(0x1A, 0x1A, 0x1A);
500
501        // Nature-style palette: high-contrast, print-safe colors.
502        let cycle = vec![
503            Color::rgb(0xE6, 0x4B, 0x35), // red
504            Color::rgb(0x4D, 0xBB, 0xD5), // teal
505            Color::rgb(0x00, 0xA0, 0x87), // green
506            Color::rgb(0x30, 0x66, 0xBE), // blue
507            Color::rgb(0xF3, 0x9B, 0x7F), // salmon
508            Color::rgb(0x87, 0x5F, 0x9A), // purple
509            Color::rgb(0xFE, 0xBE, 0x10), // gold
510            Color::rgb(0x00, 0x72, 0xB2), // dark blue
511        ];
512
513        Self {
514            figure_background: Color::WHITE,
515            axes_background: Color::WHITE,
516
517            grid_color: Color::rgb(0xDD, 0xDD, 0xDD),
518            grid_width: 0.5,
519            show_grid: false,
520
521            spine_color: ink,
522            spine_width: 0.75,
523            show_top_spine: false,
524            show_right_spine: false,
525            show_bottom_spine: true,
526            show_left_spine: true,
527
528            tick_color: ink,
529            tick_length: 3.0,
530            tick_direction: TickDirection::Outward,
531            tick_label_size: 7.0,
532
533            axis_label_size: 8.0,
534            title_size: 10.0,
535            title_weight: FontWeight::Bold,
536            text_color: ink,
537
538            line_width: 1.0,
539            marker_size: 4.0,
540            marker_alpha: 1.0,
541
542            color_cycle: cycle,
543
544            font_family: Some("sans-serif".to_string()),
545        }
546    }
547
548    /// Solarized dark theme based on the Solarized color scheme by Ethan
549    /// Schoonover.
550    ///
551    /// Uses the base03 background (`#002B36`) with Solarized content tones
552    /// for text (`#839496`) and accent colors for data series. The result is
553    /// a low-contrast, eye-friendly palette designed for extended viewing.
554    ///
555    /// This is the dark variant. A light variant could be built by swapping
556    /// base03/base0 roles.
557    pub fn solarized() -> Self {
558        let base03 = Color::rgb(0x00, 0x2B, 0x36);  // dark background
559        let base02 = Color::rgb(0x07, 0x36, 0x42);  // highlight background
560        let base01 = Color::rgb(0x58, 0x6E, 0x75);  // secondary content
561        let base0 = Color::rgb(0x83, 0x94, 0x96);   // primary content
562        let base1 = Color::rgb(0x93, 0xA1, 0xA1);   // emphasized content
563
564        // Solarized accent colors.
565        let cycle = vec![
566            Color::rgb(0x26, 0x8B, 0xD2), // blue
567            Color::rgb(0xDC, 0x32, 0x2F), // red
568            Color::rgb(0x85, 0x99, 0x00), // green
569            Color::rgb(0xB5, 0x89, 0x00), // yellow
570            Color::rgb(0x2A, 0xA1, 0x98), // cyan
571            Color::rgb(0xD3, 0x36, 0x82), // magenta
572            Color::rgb(0xCB, 0x4B, 0x16), // orange
573            Color::rgb(0x6C, 0x71, 0xC4), // violet
574        ];
575
576        Self {
577            figure_background: base03,
578            axes_background: base03,
579
580            grid_color: base02,
581            grid_width: 1.0,
582            show_grid: true,
583
584            spine_color: base01,
585            spine_width: 1.0,
586            show_top_spine: false,
587            show_right_spine: false,
588            show_bottom_spine: true,
589            show_left_spine: true,
590
591            tick_color: base0,
592            tick_length: 4.0,
593            tick_direction: TickDirection::Outward,
594            tick_label_size: 9.0,
595
596            axis_label_size: 11.0,
597            title_size: 14.0,
598            title_weight: FontWeight::Bold,
599            text_color: base1,
600
601            line_width: 1.5,
602            marker_size: 6.0,
603            marker_alpha: 0.9,
604
605            color_cycle: cycle,
606
607            font_family: Some("sans-serif".to_string()),
608        }
609    }
610}
611
612// ---------------------------------------------------------------------------
613// Tests
614// ---------------------------------------------------------------------------
615
616#[cfg(test)]
617mod tests {
618    use super::*;
619
620    #[test]
621    fn default_theme_background_is_white() {
622        let t = Theme::default();
623        assert_eq!(t.figure_background, Color::WHITE);
624        assert_eq!(t.axes_background, Color::WHITE);
625    }
626
627    #[test]
628    fn default_theme_despine_look() {
629        let t = Theme::default();
630        assert!(!t.show_top_spine);
631        assert!(!t.show_right_spine);
632        assert!(t.show_bottom_spine);
633        assert!(t.show_left_spine);
634    }
635
636    #[test]
637    fn default_theme_grid() {
638        let t = Theme::default();
639        assert_eq!(t.grid_color, Color::rgb(0xE6, 0xE6, 0xE6));
640        assert!((t.grid_width - 1.0).abs() < f64::EPSILON);
641        assert!(t.show_grid);
642    }
643
644    #[test]
645    fn default_theme_spines() {
646        let t = Theme::default();
647        let expected = Color::rgb(0x33, 0x33, 0x33);
648        assert_eq!(t.spine_color, expected);
649        assert!((t.spine_width - 1.0).abs() < f64::EPSILON);
650    }
651
652    #[test]
653    fn default_theme_ticks() {
654        let t = Theme::default();
655        assert_eq!(t.tick_color, Color::rgb(0x33, 0x33, 0x33));
656        assert!((t.tick_length - 4.0).abs() < f64::EPSILON);
657        assert_eq!(t.tick_direction, TickDirection::Outward);
658    }
659
660    #[test]
661    fn default_theme_font_sizes() {
662        let t = Theme::default();
663        assert!((t.tick_label_size - 9.0).abs() < f64::EPSILON);
664        assert!((t.axis_label_size - 11.0).abs() < f64::EPSILON);
665        assert!((t.title_size - 14.0).abs() < f64::EPSILON);
666        assert_eq!(t.title_weight, FontWeight::Bold);
667    }
668
669    #[test]
670    fn default_theme_text_color() {
671        let t = Theme::default();
672        assert_eq!(t.text_color, Color::rgb(0x33, 0x33, 0x33));
673    }
674
675    #[test]
676    fn default_theme_data_defaults() {
677        let t = Theme::default();
678        assert!((t.line_width - 1.5).abs() < f64::EPSILON);
679        assert!((t.marker_size - 6.0).abs() < f64::EPSILON);
680        assert!((t.marker_alpha - 0.8).abs() < f64::EPSILON);
681    }
682
683    #[test]
684    fn default_theme_tableau_10_cycle() {
685        let t = Theme::default();
686        assert_eq!(t.color_cycle.len(), 10);
687        assert_eq!(t.color_cycle[0], Color::TAB_BLUE);
688        assert_eq!(t.color_cycle[9], Color::TAB_CYAN);
689    }
690
691    #[test]
692    fn dark_theme_has_dark_background() {
693        let t = Theme::dark();
694        assert_eq!(t.figure_background, Color::rgb(0x1C, 0x1C, 0x1C));
695        assert_eq!(t.axes_background, Color::rgb(0x1C, 0x1C, 0x1C));
696    }
697
698    #[test]
699    fn dark_theme_light_text() {
700        let t = Theme::dark();
701        assert_eq!(t.text_color, Color::rgb(0xE0, 0xE0, 0xE0));
702    }
703
704    #[test]
705    fn dark_theme_neon_cycle() {
706        let t = Theme::dark();
707        assert_eq!(t.color_cycle.len(), 10);
708        // First color is a bright cyan.
709        assert_eq!(t.color_cycle[0], Color::rgb(0x00, 0xD4, 0xFF));
710    }
711
712    #[test]
713    fn seaborn_theme_tinted_face() {
714        let t = Theme::seaborn();
715        assert_eq!(t.axes_background, Color::rgb(0xEA, 0xEA, 0xF2));
716    }
717
718    #[test]
719    fn seaborn_theme_white_grid_thicker() {
720        let t = Theme::seaborn();
721        assert_eq!(t.grid_color, Color::WHITE);
722        assert!(t.show_grid);
723        assert!((t.grid_width - 1.5).abs() < f64::EPSILON);
724    }
725
726    #[test]
727    fn ggplot_theme_grey_panel() {
728        let t = Theme::ggplot();
729        assert_eq!(t.axes_background, Color::rgb(0xE5, 0xE5, 0xE5));
730    }
731
732    #[test]
733    fn ggplot_theme_white_grid() {
734        let t = Theme::ggplot();
735        assert_eq!(t.grid_color, Color::WHITE);
736        assert!(t.show_grid);
737    }
738
739    #[test]
740    fn ggplot_theme_panel_border() {
741        let t = Theme::ggplot();
742        assert!(t.show_top_spine);
743        assert!(t.show_right_spine);
744        assert!(t.show_bottom_spine);
745        assert!(t.show_left_spine);
746        assert!((t.spine_width - 0.5).abs() < f64::EPSILON);
747    }
748
749    #[test]
750    fn ggplot_theme_palette() {
751        let t = Theme::ggplot();
752        assert_eq!(t.color_cycle.len(), 8);
753        assert_eq!(t.color_cycle[0], Color::rgb(0xF8, 0x76, 0x6D));
754    }
755
756    #[test]
757    fn publication_theme_all_spines_visible() {
758        let t = Theme::publication();
759        assert!(t.show_top_spine);
760        assert!(t.show_right_spine);
761        assert!(t.show_bottom_spine);
762        assert!(t.show_left_spine);
763    }
764
765    #[test]
766    fn publication_theme_no_grid() {
767        let t = Theme::publication();
768        assert!(!t.show_grid);
769    }
770
771    #[test]
772    fn publication_theme_thin_spines() {
773        let t = Theme::publication();
774        assert!((t.spine_width - 0.5).abs() < f64::EPSILON);
775    }
776
777    #[test]
778    fn publication_theme_inward_ticks() {
779        let t = Theme::publication();
780        assert_eq!(t.tick_direction, TickDirection::Inward);
781    }
782
783    #[test]
784    fn publication_theme_serif_font() {
785        let t = Theme::publication();
786        assert_eq!(t.font_family, Some("serif".to_string()));
787    }
788
789    #[test]
790    fn publication_theme_white_background() {
791        let t = Theme::publication();
792        assert_eq!(t.figure_background, Color::WHITE);
793        assert_eq!(t.axes_background, Color::WHITE);
794    }
795
796    #[test]
797    fn grid_axis_default_is_both() {
798        assert_eq!(GridAxis::default(), GridAxis::Both);
799    }
800
801    // -- Seaborn additional tests -------------------------------------------
802
803    #[test]
804    fn seaborn_theme_muted_palette() {
805        let t = Theme::seaborn();
806        assert_eq!(t.color_cycle.len(), 10);
807        // First color is the muted blue.
808        assert_eq!(t.color_cycle[0], Color::rgb(0x4C, 0x72, 0xB0));
809    }
810
811    #[test]
812    fn seaborn_theme_no_top_right_spines() {
813        let t = Theme::seaborn();
814        assert!(!t.show_top_spine);
815        assert!(!t.show_right_spine);
816        assert!(t.show_bottom_spine);
817        assert!(t.show_left_spine);
818    }
819
820    #[test]
821    fn seaborn_theme_sans_serif_font() {
822        let t = Theme::seaborn();
823        assert_eq!(t.font_family, Some("sans-serif".to_string()));
824    }
825
826    // -- Publication additional tests ---------------------------------------
827
828    #[test]
829    fn publication_theme_larger_axis_labels() {
830        let t = Theme::publication();
831        assert!((t.axis_label_size - 12.0).abs() < f64::EPSILON);
832    }
833
834    // -- Nature theme tests -------------------------------------------------
835
836    #[test]
837    fn nature_theme_constructs_without_panic() {
838        let _t = Theme::nature();
839    }
840
841    #[test]
842    fn nature_theme_white_background() {
843        let t = Theme::nature();
844        assert_eq!(t.figure_background, Color::WHITE);
845        assert_eq!(t.axes_background, Color::WHITE);
846    }
847
848    #[test]
849    fn nature_theme_no_grid() {
850        let t = Theme::nature();
851        assert!(!t.show_grid);
852    }
853
854    #[test]
855    fn nature_theme_thin_spines() {
856        let t = Theme::nature();
857        assert!((t.spine_width - 0.75).abs() < f64::EPSILON);
858        assert!(!t.show_top_spine);
859        assert!(!t.show_right_spine);
860        assert!(t.show_bottom_spine);
861        assert!(t.show_left_spine);
862    }
863
864    #[test]
865    fn nature_theme_compact_font_sizes() {
866        let t = Theme::nature();
867        assert!((t.tick_label_size - 7.0).abs() < f64::EPSILON);
868        assert!((t.axis_label_size - 8.0).abs() < f64::EPSILON);
869        assert!((t.title_size - 10.0).abs() < f64::EPSILON);
870    }
871
872    #[test]
873    fn nature_theme_bold_labels() {
874        let t = Theme::nature();
875        assert_eq!(t.title_weight, FontWeight::Bold);
876    }
877
878    #[test]
879    fn nature_theme_sans_serif_font() {
880        let t = Theme::nature();
881        assert_eq!(t.font_family, Some("sans-serif".to_string()));
882    }
883
884    #[test]
885    fn nature_theme_palette() {
886        let t = Theme::nature();
887        assert_eq!(t.color_cycle.len(), 8);
888        // First color is the signature Nature red.
889        assert_eq!(t.color_cycle[0], Color::rgb(0xE6, 0x4B, 0x35));
890    }
891
892    #[test]
893    fn nature_theme_small_markers() {
894        let t = Theme::nature();
895        assert!((t.marker_size - 4.0).abs() < f64::EPSILON);
896        assert!((t.marker_alpha - 1.0).abs() < f64::EPSILON);
897    }
898
899    // -- Solarized theme tests ----------------------------------------------
900
901    #[test]
902    fn solarized_theme_constructs_without_panic() {
903        let _t = Theme::solarized();
904    }
905
906    #[test]
907    fn solarized_theme_dark_background() {
908        let t = Theme::solarized();
909        // Solarized base03
910        assert_eq!(t.figure_background, Color::rgb(0x00, 0x2B, 0x36));
911        assert_eq!(t.axes_background, Color::rgb(0x00, 0x2B, 0x36));
912    }
913
914    #[test]
915    fn solarized_theme_content_text_color() {
916        let t = Theme::solarized();
917        // Solarized base1 for emphasized content
918        assert_eq!(t.text_color, Color::rgb(0x93, 0xA1, 0xA1));
919    }
920
921    #[test]
922    fn solarized_theme_accent_palette() {
923        let t = Theme::solarized();
924        assert_eq!(t.color_cycle.len(), 8);
925        // First accent is Solarized blue.
926        assert_eq!(t.color_cycle[0], Color::rgb(0x26, 0x8B, 0xD2));
927        // Verify all 8 Solarized accent colors are present.
928        assert_eq!(t.color_cycle[7], Color::rgb(0x6C, 0x71, 0xC4)); // violet
929    }
930
931    #[test]
932    fn solarized_theme_grid_uses_base02() {
933        let t = Theme::solarized();
934        assert_eq!(t.grid_color, Color::rgb(0x07, 0x36, 0x42));
935        assert!(t.show_grid);
936    }
937
938    #[test]
939    fn solarized_theme_sans_serif_font() {
940        let t = Theme::solarized();
941        assert_eq!(t.font_family, Some("sans-serif".to_string()));
942    }
943
944    #[test]
945    fn solarized_theme_despine_look() {
946        let t = Theme::solarized();
947        assert!(!t.show_top_spine);
948        assert!(!t.show_right_spine);
949        assert!(t.show_bottom_spine);
950        assert!(t.show_left_spine);
951    }
952
953    // -- Cross-theme distinctness tests -------------------------------------
954
955    #[test]
956    fn all_themes_have_distinct_backgrounds() {
957        let themes: Vec<(&str, Theme)> = vec![
958            ("default", Theme::default()),
959            ("dark", Theme::dark()),
960            ("seaborn", Theme::seaborn()),
961            ("ggplot", Theme::ggplot()),
962            ("publication", Theme::publication()),
963            ("nature", Theme::nature()),
964            ("solarized", Theme::solarized()),
965        ];
966        // Collect unique (figure_bg, axes_bg) pairs. We expect at least 4
967        // distinct combinations (default/publication/nature share white, but
968        // dark, seaborn, ggplot, solarized differ).
969        let mut backgrounds: Vec<(Color, Color)> = themes
970            .iter()
971            .map(|(_, t)| (t.figure_background, t.axes_background))
972            .collect();
973        backgrounds.sort_by_key(|(f, a)| (f.r, f.g, f.b, a.r, a.g, a.b));
974        backgrounds.dedup();
975        assert!(
976            backgrounds.len() >= 4,
977            "Expected at least 4 distinct background combos, got {}",
978            backgrounds.len()
979        );
980    }
981
982    #[test]
983    fn all_themes_have_reasonable_spine_widths() {
984        let themes = [
985            Theme::default(),
986            Theme::dark(),
987            Theme::seaborn(),
988            Theme::ggplot(),
989            Theme::publication(),
990            Theme::nature(),
991            Theme::solarized(),
992        ];
993        for t in &themes {
994            assert!(
995                t.spine_width >= 0.0 && t.spine_width <= 3.0,
996                "spine_width {} out of reasonable range",
997                t.spine_width
998            );
999        }
1000    }
1001
1002    #[test]
1003    fn all_themes_have_reasonable_tick_sizes() {
1004        let themes = [
1005            Theme::default(),
1006            Theme::dark(),
1007            Theme::seaborn(),
1008            Theme::ggplot(),
1009            Theme::publication(),
1010            Theme::nature(),
1011            Theme::solarized(),
1012        ];
1013        for t in &themes {
1014            assert!(
1015                t.tick_length >= 0.0 && t.tick_length <= 10.0,
1016                "tick_length {} out of reasonable range",
1017                t.tick_length
1018            );
1019            assert!(
1020                t.tick_label_size >= 5.0 && t.tick_label_size <= 16.0,
1021                "tick_label_size {} out of reasonable range",
1022                t.tick_label_size
1023            );
1024        }
1025    }
1026
1027    #[test]
1028    fn each_theme_has_nonempty_color_cycle() {
1029        let themes = [
1030            Theme::default(),
1031            Theme::dark(),
1032            Theme::seaborn(),
1033            Theme::ggplot(),
1034            Theme::publication(),
1035            Theme::nature(),
1036            Theme::solarized(),
1037        ];
1038        for t in &themes {
1039            assert!(
1040                !t.color_cycle.is_empty(),
1041                "color_cycle must not be empty"
1042            );
1043        }
1044    }
1045
1046    #[test]
1047    fn nature_and_publication_are_distinct() {
1048        let n = Theme::nature();
1049        let p = Theme::publication();
1050        // They should differ in font family, spine configuration, and sizes.
1051        assert_ne!(n.font_family, p.font_family);
1052        assert_ne!(n.show_top_spine, p.show_top_spine);
1053        assert!((n.axis_label_size - p.axis_label_size).abs() > f64::EPSILON);
1054    }
1055
1056    #[test]
1057    fn solarized_and_dark_are_distinct() {
1058        let s = Theme::solarized();
1059        let d = Theme::dark();
1060        // Different backgrounds.
1061        assert_ne!(s.figure_background, d.figure_background);
1062        // Different palettes.
1063        assert_ne!(s.color_cycle[0], d.color_cycle[0]);
1064        // Different text colors.
1065        assert_ne!(s.text_color, d.text_color);
1066    }
1067}