1use std::collections::HashMap;
4
5use serde::{Deserialize, Serialize};
6
7use crate::color::Color;
8
9#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub struct ColorSet {
16 accent: String,
18 dim: String,
20 hover_border: String,
22}
23
24impl ColorSet {
25 pub fn new(accent: &str, dim: &str, hover_border: &str) -> Self {
27 Self {
28 accent: accent.to_owned(),
29 dim: dim.to_owned(),
30 hover_border: hover_border.to_owned(),
31 }
32 }
33
34 pub fn accent(&self) -> &str {
36 &self.accent
37 }
38
39 pub fn dim(&self) -> &str {
41 &self.dim
42 }
43
44 pub fn hover_border(&self) -> &str {
46 &self.hover_border
47 }
48}
49
50#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
52#[serde(transparent)]
53pub struct ThemePalette(HashMap<Color, ColorSet>);
54
55impl ThemePalette {
56 pub fn new(entries: HashMap<Color, ColorSet>) -> Self {
58 Self(entries)
59 }
60
61 pub fn get(&self, color: Color) -> Option<&ColorSet> {
63 self.0.get(&color)
64 }
65
66 pub fn insert(&mut self, color: Color, set: ColorSet) {
68 self.0.insert(color, set);
69 }
70
71 pub fn iter(&self) -> impl Iterator<Item = (&Color, &ColorSet)> {
73 self.0.iter()
74 }
75
76 pub fn merge(&mut self, overrides: ThemePalette) {
78 for (color, set) in overrides.0 {
79 self.0.insert(color, set);
80 }
81 }
82}
83
84#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
86#[serde(rename_all = "snake_case")]
87pub struct Backgrounds {
88 page: String,
90 card: String,
92 card_hover: String,
94}
95
96impl Backgrounds {
97 pub fn new(page: &str, card: &str, card_hover: &str) -> Self {
99 Self {
100 page: page.to_owned(),
101 card: card.to_owned(),
102 card_hover: card_hover.to_owned(),
103 }
104 }
105
106 pub fn page(&self) -> &str {
108 &self.page
109 }
110
111 pub fn card(&self) -> &str {
113 &self.card
114 }
115
116 pub fn card_hover(&self) -> &str {
118 &self.card_hover
119 }
120}
121
122#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
124#[serde(rename_all = "snake_case")]
125pub struct TextColors {
126 primary: String,
128 dim: String,
130 bright: String,
132}
133
134impl TextColors {
135 pub fn new(primary: &str, dim: &str, bright: &str) -> Self {
137 Self {
138 primary: primary.to_owned(),
139 dim: dim.to_owned(),
140 bright: bright.to_owned(),
141 }
142 }
143
144 pub fn primary(&self) -> &str {
146 &self.primary
147 }
148
149 pub fn dim(&self) -> &str {
151 &self.dim
152 }
153
154 pub fn bright(&self) -> &str {
156 &self.bright
157 }
158}
159
160#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
162#[serde(rename_all = "snake_case")]
163pub struct Borders {
164 normal: String,
166 highlight: String,
168}
169
170impl Borders {
171 pub fn new(normal: &str, highlight: &str) -> Self {
173 Self {
174 normal: normal.to_owned(),
175 highlight: highlight.to_owned(),
176 }
177 }
178
179 pub fn normal(&self) -> &str {
181 &self.normal
182 }
183
184 pub fn highlight(&self) -> &str {
186 &self.highlight
187 }
188}
189
190#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
192#[serde(rename_all = "snake_case")]
193pub struct Fonts {
194 display: String,
196 body: String,
198}
199
200impl Fonts {
201 pub fn new(display: &str, body: &str) -> Self {
203 Self {
204 display: display.to_owned(),
205 body: body.to_owned(),
206 }
207 }
208
209 pub fn display(&self) -> &str {
211 &self.display
212 }
213
214 pub fn body(&self) -> &str {
216 &self.body
217 }
218}
219
220#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
222#[serde(rename_all = "snake_case")]
223pub struct Spacing {
224 radius: u32,
226 radius_sm: u32,
228 shadow: String,
230 canvas_width: u32,
232}
233
234impl Spacing {
235 pub fn new(radius: u32, radius_sm: u32, shadow: &str, canvas_width: u32) -> Self {
237 Self {
238 radius,
239 radius_sm,
240 shadow: shadow.to_owned(),
241 canvas_width,
242 }
243 }
244
245 pub fn radius(&self) -> u32 {
247 self.radius
248 }
249
250 pub fn radius_sm(&self) -> u32 {
252 self.radius_sm
253 }
254
255 pub fn shadow(&self) -> &str {
257 &self.shadow
258 }
259
260 pub fn canvas_width(&self) -> u32 {
262 self.canvas_width
263 }
264}
265
266#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
271#[serde(rename_all = "snake_case")]
272pub struct Theme {
273 name: String,
275 palette: ThemePalette,
277 backgrounds: Backgrounds,
279 text: TextColors,
281 borders: Borders,
283 fonts: Fonts,
285 spacing: Spacing,
287 animate: bool,
289}
290
291impl Theme {
292 pub fn name(&self) -> &str {
294 &self.name
295 }
296
297 pub fn palette(&self) -> &ThemePalette {
299 &self.palette
300 }
301
302 pub fn backgrounds(&self) -> &Backgrounds {
304 &self.backgrounds
305 }
306
307 pub fn text(&self) -> &TextColors {
309 &self.text
310 }
311
312 pub fn borders(&self) -> &Borders {
314 &self.borders
315 }
316
317 pub fn fonts(&self) -> &Fonts {
319 &self.fonts
320 }
321
322 pub fn spacing(&self) -> &Spacing {
324 &self.spacing
325 }
326
327 pub fn animate(&self) -> bool {
329 self.animate
330 }
331
332 pub fn dark() -> Self {
334 let mut palette = HashMap::new();
335 palette.insert(
336 Color::Blue,
337 ColorSet::new("#4fc3f7", "rgba(79, 195, 247, 0.10)", "#4fc3f7"),
338 );
339 palette.insert(
340 Color::Green,
341 ColorSet::new("#3ddc84", "rgba(61, 220, 132, 0.12)", "#3ddc84"),
342 );
343 palette.insert(
344 Color::Amber,
345 ColorSet::new("#ffb74d", "rgba(255, 183, 77, 0.10)", "#ffb74d"),
346 );
347 palette.insert(
348 Color::Purple,
349 ColorSet::new("#b39ddb", "rgba(179, 157, 219, 0.10)", "#b39ddb"),
350 );
351 palette.insert(
352 Color::Red,
353 ColorSet::new("#ef5350", "rgba(239, 83, 80, 0.10)", "#ef5350"),
354 );
355 palette.insert(
356 Color::Teal,
357 ColorSet::new("#4dd0e1", "rgba(77, 208, 225, 0.10)", "#4dd0e1"),
358 );
359
360 Self {
361 name: "dark".to_owned(),
362 palette: ThemePalette::new(palette),
363 backgrounds: Backgrounds::new("#0a0e14", "#111820", "#161e29"),
364 text: TextColors::new("#c4cdd9", "#5a6a7a", "#e8edf3"),
365 borders: Borders::new("#1e2a3a", "#2a3f5a"),
366 fonts: Fonts::new("JetBrains Mono", "DM Sans"),
367 spacing: Spacing::new(10, 6, "0 2px 20px rgba(0,0,0,0.3)", 1100),
368 animate: true,
369 }
370 }
371
372 pub fn merge(mut self, overrides: ThemeOverrides) -> Self {
377 if let Some(palette) = overrides.palette {
378 self.palette.merge(palette);
379 }
380 if let Some(backgrounds) = overrides.backgrounds {
381 self.backgrounds = backgrounds;
382 }
383 if let Some(text) = overrides.text {
384 self.text = text;
385 }
386 if let Some(borders) = overrides.borders {
387 self.borders = borders;
388 }
389 if let Some(fonts) = overrides.fonts {
390 self.fonts = fonts;
391 }
392 if let Some(spacing) = overrides.spacing {
393 self.spacing = spacing;
394 }
395 if let Some(animate) = overrides.animate {
396 self.animate = animate;
397 }
398 self
399 }
400}
401
402#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
406#[serde(rename_all = "snake_case")]
407pub struct ThemeOverrides {
408 #[serde(default, skip_serializing_if = "Option::is_none")]
410 pub palette: Option<ThemePalette>,
411 #[serde(default, skip_serializing_if = "Option::is_none")]
413 pub backgrounds: Option<Backgrounds>,
414 #[serde(default, skip_serializing_if = "Option::is_none")]
416 pub text: Option<TextColors>,
417 #[serde(default, skip_serializing_if = "Option::is_none")]
419 pub borders: Option<Borders>,
420 #[serde(default, skip_serializing_if = "Option::is_none")]
422 pub fonts: Option<Fonts>,
423 #[serde(default, skip_serializing_if = "Option::is_none")]
425 pub spacing: Option<Spacing>,
426 #[serde(default, skip_serializing_if = "Option::is_none")]
428 pub animate: Option<bool>,
429}
430
431#[cfg(test)]
432mod tests {
433 use super::*;
434
435 #[test]
436 fn test_dark_theme_name() {
437 let theme = Theme::dark();
438 assert_eq!(theme.name(), "dark");
439 }
440
441 #[test]
442 fn test_dark_theme_palette_has_all_colors() {
443 let theme = Theme::dark();
444 assert!(theme.palette().get(Color::Blue).is_some());
445 assert!(theme.palette().get(Color::Green).is_some());
446 assert!(theme.palette().get(Color::Amber).is_some());
447 assert!(theme.palette().get(Color::Purple).is_some());
448 assert!(theme.palette().get(Color::Red).is_some());
449 assert!(theme.palette().get(Color::Teal).is_some());
450 }
451
452 #[test]
453 fn test_dark_theme_exact_blue_values() {
454 let theme = Theme::dark();
455 let blue = theme.palette().get(Color::Blue).unwrap();
456 assert_eq!(blue.accent(), "#4fc3f7");
457 assert_eq!(blue.dim(), "rgba(79, 195, 247, 0.10)");
458 }
459
460 #[test]
461 fn test_dark_theme_exact_green_values() {
462 let theme = Theme::dark();
463 let green = theme.palette().get(Color::Green).unwrap();
464 assert_eq!(green.accent(), "#3ddc84");
465 assert_eq!(green.dim(), "rgba(61, 220, 132, 0.12)");
466 }
467
468 #[test]
469 fn test_dark_theme_backgrounds() {
470 let theme = Theme::dark();
471 assert_eq!(theme.backgrounds().page(), "#0a0e14");
472 assert_eq!(theme.backgrounds().card(), "#111820");
473 assert_eq!(theme.backgrounds().card_hover(), "#161e29");
474 }
475
476 #[test]
477 fn test_dark_theme_text_colors() {
478 let theme = Theme::dark();
479 assert_eq!(theme.text().primary(), "#c4cdd9");
480 assert_eq!(theme.text().dim(), "#5a6a7a");
481 assert_eq!(theme.text().bright(), "#e8edf3");
482 }
483
484 #[test]
485 fn test_dark_theme_borders() {
486 let theme = Theme::dark();
487 assert_eq!(theme.borders().normal(), "#1e2a3a");
488 assert_eq!(theme.borders().highlight(), "#2a3f5a");
489 }
490
491 #[test]
492 fn test_dark_theme_fonts() {
493 let theme = Theme::dark();
494 assert_eq!(theme.fonts().display(), "JetBrains Mono");
495 assert_eq!(theme.fonts().body(), "DM Sans");
496 }
497
498 #[test]
499 fn test_dark_theme_spacing() {
500 let theme = Theme::dark();
501 assert_eq!(theme.spacing().radius(), 10);
502 assert_eq!(theme.spacing().radius_sm(), 6);
503 assert_eq!(theme.spacing().canvas_width(), 1100);
504 }
505
506 #[test]
507 fn test_dark_theme_animate() {
508 let theme = Theme::dark();
509 assert!(theme.animate());
510 }
511
512 #[test]
513 fn test_merge_overrides_palette() {
514 let base = Theme::dark();
515 let mut override_palette = HashMap::new();
516 override_palette.insert(
517 Color::Blue,
518 ColorSet::new("#0000ff", "rgba(0,0,255,0.1)", "#0000ff"),
519 );
520 let overrides = ThemeOverrides {
521 palette: Some(ThemePalette::new(override_palette)),
522 ..Default::default()
523 };
524 let merged = base.merge(overrides);
525 assert_eq!(
526 merged.palette().get(Color::Blue).unwrap().accent(),
527 "#0000ff"
528 );
529 assert_eq!(
531 merged.palette().get(Color::Green).unwrap().accent(),
532 "#3ddc84"
533 );
534 }
535
536 #[test]
537 fn test_merge_overrides_animate() {
538 let base = Theme::dark();
539 let overrides = ThemeOverrides {
540 animate: Some(false),
541 ..Default::default()
542 };
543 let merged = base.merge(overrides);
544 assert!(!merged.animate());
545 assert_eq!(merged.name(), "dark");
547 assert_eq!(merged.backgrounds().page(), "#0a0e14");
548 }
549
550 #[test]
551 fn test_merge_overrides_backgrounds() {
552 let base = Theme::dark();
553 let overrides = ThemeOverrides {
554 backgrounds: Some(Backgrounds::new("#000000", "#111111", "#222222")),
555 ..Default::default()
556 };
557 let merged = base.merge(overrides);
558 assert_eq!(merged.backgrounds().page(), "#000000");
559 assert_eq!(merged.backgrounds().card(), "#111111");
560 }
561
562 #[test]
563 fn test_serde_round_trip_theme() {
564 let theme = Theme::dark();
565 let json = serde_json::to_string_pretty(&theme).unwrap();
566 let deserialized: Theme = serde_json::from_str(&json).unwrap();
567 assert_eq!(theme, deserialized);
568 }
569
570 #[test]
571 fn test_serde_round_trip_overrides() {
572 let overrides = ThemeOverrides {
573 animate: Some(false),
574 backgrounds: Some(Backgrounds::new("#000", "#111", "#222")),
575 ..Default::default()
576 };
577 let json = serde_json::to_string_pretty(&overrides).unwrap();
578 let deserialized: ThemeOverrides = serde_json::from_str(&json).unwrap();
579 assert_eq!(overrides, deserialized);
580 }
581
582 #[test]
583 fn test_serde_overrides_empty_fields_omitted() {
584 let overrides = ThemeOverrides {
585 animate: Some(true),
586 ..Default::default()
587 };
588 let json = serde_json::to_string(&overrides).unwrap();
589 assert!(!json.contains("palette"));
590 assert!(!json.contains("backgrounds"));
591 assert!(json.contains("animate"));
592 }
593
594 #[test]
595 fn test_yaml_round_trip_theme() {
596 let theme = Theme::dark();
597 let yaml = serde_yml::to_string(&theme).unwrap();
598 let deserialized: Theme = serde_yml::from_str(&yaml).unwrap();
599 assert_eq!(theme, deserialized);
600 }
601
602 #[test]
603 fn test_colorset_accessors() {
604 let cs = ColorSet::new("#abc", "rgba(0,0,0,0.5)", "#def");
605 assert_eq!(cs.accent(), "#abc");
606 assert_eq!(cs.dim(), "rgba(0,0,0,0.5)");
607 assert_eq!(cs.hover_border(), "#def");
608 }
609
610 #[test]
611 fn test_palette_insert_and_get() {
612 let mut palette = ThemePalette::new(HashMap::new());
613 assert!(palette.get(Color::Red).is_none());
614 palette.insert(
615 Color::Red,
616 ColorSet::new("#f00", "rgba(255,0,0,0.1)", "#f00"),
617 );
618 assert_eq!(palette.get(Color::Red).unwrap().accent(), "#f00");
619 }
620
621 #[test]
622 fn test_palette_merge() {
623 let mut base = ThemePalette::new(HashMap::new());
624 base.insert(Color::Red, ColorSet::new("#f00", "r", "#f00"));
625 base.insert(Color::Blue, ColorSet::new("#00f", "b", "#00f"));
626
627 let mut overrides = ThemePalette::new(HashMap::new());
628 overrides.insert(Color::Red, ColorSet::new("#ff0000", "rr", "#ff0000"));
629
630 base.merge(overrides);
631 assert_eq!(base.get(Color::Red).unwrap().accent(), "#ff0000");
632 assert_eq!(base.get(Color::Blue).unwrap().accent(), "#00f");
633 }
634
635 #[test]
636 fn test_dark_yml_matches_dark_constructor() {
637 let yaml = include_str!("../../../themes/dark.yml");
638 let from_file: Theme = serde_yml::from_str(yaml).unwrap();
639 let from_code = Theme::dark();
640 assert_eq!(from_file, from_code);
641 }
642
643 #[test]
644 fn test_merge_overrides_text() {
645 let base = Theme::dark();
646 let overrides = ThemeOverrides {
647 text: Some(TextColors::new("#aaa", "#bbb", "#ccc")),
648 ..Default::default()
649 };
650 let merged = base.merge(overrides);
651 assert_eq!(merged.text().primary(), "#aaa");
652 assert_eq!(merged.text().dim(), "#bbb");
653 assert_eq!(merged.text().bright(), "#ccc");
654 }
655
656 #[test]
657 fn test_merge_overrides_borders() {
658 let base = Theme::dark();
659 let overrides = ThemeOverrides {
660 borders: Some(Borders::new("#111", "#222")),
661 ..Default::default()
662 };
663 let merged = base.merge(overrides);
664 assert_eq!(merged.borders().normal(), "#111");
665 assert_eq!(merged.borders().highlight(), "#222");
666 }
667
668 #[test]
669 fn test_merge_overrides_fonts() {
670 let base = Theme::dark();
671 let overrides = ThemeOverrides {
672 fonts: Some(Fonts::new("Fira Code", "Inter")),
673 ..Default::default()
674 };
675 let merged = base.merge(overrides);
676 assert_eq!(merged.fonts().display(), "Fira Code");
677 assert_eq!(merged.fonts().body(), "Inter");
678 }
679
680 #[test]
681 fn test_merge_overrides_spacing() {
682 let base = Theme::dark();
683 let overrides = ThemeOverrides {
684 spacing: Some(Spacing::new(12, 8, "none", 900)),
685 ..Default::default()
686 };
687 let merged = base.merge(overrides);
688 assert_eq!(merged.spacing().radius(), 12);
689 assert_eq!(merged.spacing().radius_sm(), 8);
690 assert_eq!(merged.spacing().shadow(), "none");
691 assert_eq!(merged.spacing().canvas_width(), 900);
692 }
693
694 #[test]
695 fn test_palette_iter() {
696 let theme = Theme::dark();
697 let count = theme.palette().iter().count();
698 assert_eq!(count, 6); }
700
701 #[test]
702 fn test_dark_theme_exact_amber_values() {
703 let theme = Theme::dark();
704 let amber = theme.palette().get(Color::Amber).unwrap();
705 assert_eq!(amber.accent(), "#ffb74d");
706 assert_eq!(amber.dim(), "rgba(255, 183, 77, 0.10)");
707 assert_eq!(amber.hover_border(), "#ffb74d");
708 }
709
710 #[test]
711 fn test_dark_theme_exact_purple_values() {
712 let theme = Theme::dark();
713 let purple = theme.palette().get(Color::Purple).unwrap();
714 assert_eq!(purple.accent(), "#b39ddb");
715 }
716
717 #[test]
718 fn test_dark_theme_exact_red_values() {
719 let theme = Theme::dark();
720 let red = theme.palette().get(Color::Red).unwrap();
721 assert_eq!(red.accent(), "#ef5350");
722 }
723
724 #[test]
725 fn test_dark_theme_exact_teal_values() {
726 let theme = Theme::dark();
727 let teal = theme.palette().get(Color::Teal).unwrap();
728 assert_eq!(teal.accent(), "#4dd0e1");
729 }
730}