1#![forbid(unsafe_code)]
2
3use crate::color::Color;
25use std::env;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum AdaptiveColor {
30 Fixed(Color),
32 Adaptive {
34 light: Color,
36 dark: Color,
38 },
39}
40
41impl AdaptiveColor {
42 #[inline]
44 pub const fn fixed(color: Color) -> Self {
45 Self::Fixed(color)
46 }
47
48 #[inline]
50 pub const fn adaptive(light: Color, dark: Color) -> Self {
51 Self::Adaptive { light, dark }
52 }
53
54 #[inline]
59 pub const fn resolve(&self, is_dark: bool) -> Color {
60 match self {
61 Self::Fixed(c) => *c,
62 Self::Adaptive { light, dark } => {
63 if is_dark {
64 *dark
65 } else {
66 *light
67 }
68 }
69 }
70 }
71
72 #[inline]
74 pub const fn is_adaptive(&self) -> bool {
75 matches!(self, Self::Adaptive { .. })
76 }
77}
78
79impl Default for AdaptiveColor {
80 fn default() -> Self {
81 Self::Fixed(Color::rgb(128, 128, 128))
82 }
83}
84
85impl From<Color> for AdaptiveColor {
86 fn from(color: Color) -> Self {
87 Self::Fixed(color)
88 }
89}
90
91#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct Theme {
97 pub primary: AdaptiveColor,
100 pub secondary: AdaptiveColor,
102 pub accent: AdaptiveColor,
104
105 pub background: AdaptiveColor,
108 pub surface: AdaptiveColor,
110 pub overlay: AdaptiveColor,
112
113 pub text: AdaptiveColor,
116 pub text_muted: AdaptiveColor,
118 pub text_subtle: AdaptiveColor,
120
121 pub success: AdaptiveColor,
124 pub warning: AdaptiveColor,
126 pub error: AdaptiveColor,
128 pub info: AdaptiveColor,
130
131 pub border: AdaptiveColor,
134 pub border_focused: AdaptiveColor,
136
137 pub selection_bg: AdaptiveColor,
140 pub selection_fg: AdaptiveColor,
142
143 pub scrollbar_track: AdaptiveColor,
146 pub scrollbar_thumb: AdaptiveColor,
148}
149
150impl Default for Theme {
151 fn default() -> Self {
152 themes::dark()
153 }
154}
155
156impl Theme {
157 pub fn builder() -> ThemeBuilder {
159 ThemeBuilder::new()
160 }
161
162 #[must_use]
171 pub fn detect_dark_mode() -> bool {
172 Self::detect_dark_mode_from_colorfgbg(env::var("COLORFGBG").ok().as_deref())
173 }
174
175 fn detect_dark_mode_from_colorfgbg(colorfgbg: Option<&str>) -> bool {
176 if let Some(colorfgbg) = colorfgbg
179 && let Some(bg_part) = colorfgbg.split(';').next_back()
180 && let Ok(bg) = bg_part.trim().parse::<u8>()
181 {
182 return bg != 7 && bg != 15;
184 }
185
186 true
188 }
189
190 #[must_use]
194 pub fn resolve(&self, is_dark: bool) -> ResolvedTheme {
195 ResolvedTheme {
196 primary: self.primary.resolve(is_dark),
197 secondary: self.secondary.resolve(is_dark),
198 accent: self.accent.resolve(is_dark),
199 background: self.background.resolve(is_dark),
200 surface: self.surface.resolve(is_dark),
201 overlay: self.overlay.resolve(is_dark),
202 text: self.text.resolve(is_dark),
203 text_muted: self.text_muted.resolve(is_dark),
204 text_subtle: self.text_subtle.resolve(is_dark),
205 success: self.success.resolve(is_dark),
206 warning: self.warning.resolve(is_dark),
207 error: self.error.resolve(is_dark),
208 info: self.info.resolve(is_dark),
209 border: self.border.resolve(is_dark),
210 border_focused: self.border_focused.resolve(is_dark),
211 selection_bg: self.selection_bg.resolve(is_dark),
212 selection_fg: self.selection_fg.resolve(is_dark),
213 scrollbar_track: self.scrollbar_track.resolve(is_dark),
214 scrollbar_thumb: self.scrollbar_thumb.resolve(is_dark),
215 }
216 }
217}
218
219#[derive(Debug, Clone, Copy, PartialEq, Eq)]
223pub struct ResolvedTheme {
224 pub primary: Color,
226 pub secondary: Color,
228 pub accent: Color,
230 pub background: Color,
232 pub surface: Color,
234 pub overlay: Color,
236 pub text: Color,
238 pub text_muted: Color,
240 pub text_subtle: Color,
242 pub success: Color,
244 pub warning: Color,
246 pub error: Color,
248 pub info: Color,
250 pub border: Color,
252 pub border_focused: Color,
254 pub selection_bg: Color,
256 pub selection_fg: Color,
258 pub scrollbar_track: Color,
260 pub scrollbar_thumb: Color,
262}
263
264#[derive(Debug, Clone)]
266#[must_use]
267pub struct ThemeBuilder {
268 theme: Theme,
269}
270
271impl ThemeBuilder {
272 pub fn new() -> Self {
274 Self {
275 theme: themes::dark(),
276 }
277 }
278
279 pub fn from_theme(theme: Theme) -> Self {
281 Self { theme }
282 }
283
284 pub fn primary(mut self, color: impl Into<AdaptiveColor>) -> Self {
286 self.theme.primary = color.into();
287 self
288 }
289
290 pub fn secondary(mut self, color: impl Into<AdaptiveColor>) -> Self {
292 self.theme.secondary = color.into();
293 self
294 }
295
296 pub fn accent(mut self, color: impl Into<AdaptiveColor>) -> Self {
298 self.theme.accent = color.into();
299 self
300 }
301
302 pub fn background(mut self, color: impl Into<AdaptiveColor>) -> Self {
304 self.theme.background = color.into();
305 self
306 }
307
308 pub fn surface(mut self, color: impl Into<AdaptiveColor>) -> Self {
310 self.theme.surface = color.into();
311 self
312 }
313
314 pub fn overlay(mut self, color: impl Into<AdaptiveColor>) -> Self {
316 self.theme.overlay = color.into();
317 self
318 }
319
320 pub fn text(mut self, color: impl Into<AdaptiveColor>) -> Self {
322 self.theme.text = color.into();
323 self
324 }
325
326 pub fn text_muted(mut self, color: impl Into<AdaptiveColor>) -> Self {
328 self.theme.text_muted = color.into();
329 self
330 }
331
332 pub fn text_subtle(mut self, color: impl Into<AdaptiveColor>) -> Self {
334 self.theme.text_subtle = color.into();
335 self
336 }
337
338 pub fn success(mut self, color: impl Into<AdaptiveColor>) -> Self {
340 self.theme.success = color.into();
341 self
342 }
343
344 pub fn warning(mut self, color: impl Into<AdaptiveColor>) -> Self {
346 self.theme.warning = color.into();
347 self
348 }
349
350 pub fn error(mut self, color: impl Into<AdaptiveColor>) -> Self {
352 self.theme.error = color.into();
353 self
354 }
355
356 pub fn info(mut self, color: impl Into<AdaptiveColor>) -> Self {
358 self.theme.info = color.into();
359 self
360 }
361
362 pub fn border(mut self, color: impl Into<AdaptiveColor>) -> Self {
364 self.theme.border = color.into();
365 self
366 }
367
368 pub fn border_focused(mut self, color: impl Into<AdaptiveColor>) -> Self {
370 self.theme.border_focused = color.into();
371 self
372 }
373
374 pub fn selection_bg(mut self, color: impl Into<AdaptiveColor>) -> Self {
376 self.theme.selection_bg = color.into();
377 self
378 }
379
380 pub fn selection_fg(mut self, color: impl Into<AdaptiveColor>) -> Self {
382 self.theme.selection_fg = color.into();
383 self
384 }
385
386 pub fn scrollbar_track(mut self, color: impl Into<AdaptiveColor>) -> Self {
388 self.theme.scrollbar_track = color.into();
389 self
390 }
391
392 pub fn scrollbar_thumb(mut self, color: impl Into<AdaptiveColor>) -> Self {
394 self.theme.scrollbar_thumb = color.into();
395 self
396 }
397
398 pub fn build(self) -> Theme {
400 self.theme
401 }
402}
403
404impl Default for ThemeBuilder {
405 fn default() -> Self {
406 Self::new()
407 }
408}
409
410pub mod themes {
412 use super::*;
413
414 #[must_use]
416 pub fn default() -> Theme {
417 dark()
418 }
419
420 #[must_use]
422 pub fn dark() -> Theme {
423 Theme {
424 primary: AdaptiveColor::fixed(Color::rgb(88, 166, 255)), secondary: AdaptiveColor::fixed(Color::rgb(163, 113, 247)), accent: AdaptiveColor::fixed(Color::rgb(255, 123, 114)), background: AdaptiveColor::fixed(Color::rgb(22, 27, 34)), surface: AdaptiveColor::fixed(Color::rgb(33, 38, 45)), overlay: AdaptiveColor::fixed(Color::rgb(48, 54, 61)), text: AdaptiveColor::fixed(Color::rgb(230, 237, 243)), text_muted: AdaptiveColor::fixed(Color::rgb(139, 148, 158)), text_subtle: AdaptiveColor::fixed(Color::rgb(110, 118, 129)), success: AdaptiveColor::fixed(Color::rgb(63, 185, 80)), warning: AdaptiveColor::fixed(Color::rgb(210, 153, 34)), error: AdaptiveColor::fixed(Color::rgb(248, 81, 73)), info: AdaptiveColor::fixed(Color::rgb(88, 166, 255)), border: AdaptiveColor::fixed(Color::rgb(48, 54, 61)), border_focused: AdaptiveColor::fixed(Color::rgb(88, 166, 255)), selection_bg: AdaptiveColor::fixed(Color::rgb(56, 139, 253)), selection_fg: AdaptiveColor::fixed(Color::rgb(255, 255, 255)), scrollbar_track: AdaptiveColor::fixed(Color::rgb(33, 38, 45)),
448 scrollbar_thumb: AdaptiveColor::fixed(Color::rgb(72, 79, 88)),
449 }
450 }
451
452 #[must_use]
454 pub fn light() -> Theme {
455 Theme {
456 primary: AdaptiveColor::fixed(Color::rgb(9, 105, 218)), secondary: AdaptiveColor::fixed(Color::rgb(130, 80, 223)), accent: AdaptiveColor::fixed(Color::rgb(207, 34, 46)), background: AdaptiveColor::fixed(Color::rgb(255, 255, 255)), surface: AdaptiveColor::fixed(Color::rgb(246, 248, 250)), overlay: AdaptiveColor::fixed(Color::rgb(255, 255, 255)), text: AdaptiveColor::fixed(Color::rgb(31, 35, 40)), text_muted: AdaptiveColor::fixed(Color::rgb(87, 96, 106)), text_subtle: AdaptiveColor::fixed(Color::rgb(140, 149, 159)), success: AdaptiveColor::fixed(Color::rgb(26, 127, 55)), warning: AdaptiveColor::fixed(Color::rgb(158, 106, 3)), error: AdaptiveColor::fixed(Color::rgb(207, 34, 46)), info: AdaptiveColor::fixed(Color::rgb(9, 105, 218)), border: AdaptiveColor::fixed(Color::rgb(208, 215, 222)), border_focused: AdaptiveColor::fixed(Color::rgb(9, 105, 218)), selection_bg: AdaptiveColor::fixed(Color::rgb(221, 244, 255)), selection_fg: AdaptiveColor::fixed(Color::rgb(31, 35, 40)), scrollbar_track: AdaptiveColor::fixed(Color::rgb(246, 248, 250)),
480 scrollbar_thumb: AdaptiveColor::fixed(Color::rgb(175, 184, 193)),
481 }
482 }
483
484 #[must_use]
486 pub fn nord() -> Theme {
487 Theme {
488 primary: AdaptiveColor::fixed(Color::rgb(136, 192, 208)), secondary: AdaptiveColor::fixed(Color::rgb(180, 142, 173)), accent: AdaptiveColor::fixed(Color::rgb(191, 97, 106)), background: AdaptiveColor::fixed(Color::rgb(46, 52, 64)), surface: AdaptiveColor::fixed(Color::rgb(59, 66, 82)), overlay: AdaptiveColor::fixed(Color::rgb(67, 76, 94)), text: AdaptiveColor::fixed(Color::rgb(236, 239, 244)), text_muted: AdaptiveColor::fixed(Color::rgb(216, 222, 233)), text_subtle: AdaptiveColor::fixed(Color::rgb(129, 161, 193)), success: AdaptiveColor::fixed(Color::rgb(163, 190, 140)), warning: AdaptiveColor::fixed(Color::rgb(235, 203, 139)), error: AdaptiveColor::fixed(Color::rgb(191, 97, 106)), info: AdaptiveColor::fixed(Color::rgb(129, 161, 193)), border: AdaptiveColor::fixed(Color::rgb(76, 86, 106)), border_focused: AdaptiveColor::fixed(Color::rgb(136, 192, 208)), selection_bg: AdaptiveColor::fixed(Color::rgb(76, 86, 106)), selection_fg: AdaptiveColor::fixed(Color::rgb(236, 239, 244)), scrollbar_track: AdaptiveColor::fixed(Color::rgb(59, 66, 82)),
512 scrollbar_thumb: AdaptiveColor::fixed(Color::rgb(76, 86, 106)),
513 }
514 }
515
516 #[must_use]
518 pub fn dracula() -> Theme {
519 Theme {
520 primary: AdaptiveColor::fixed(Color::rgb(189, 147, 249)), secondary: AdaptiveColor::fixed(Color::rgb(255, 121, 198)), accent: AdaptiveColor::fixed(Color::rgb(139, 233, 253)), background: AdaptiveColor::fixed(Color::rgb(40, 42, 54)), surface: AdaptiveColor::fixed(Color::rgb(68, 71, 90)), overlay: AdaptiveColor::fixed(Color::rgb(68, 71, 90)), text: AdaptiveColor::fixed(Color::rgb(248, 248, 242)), text_muted: AdaptiveColor::fixed(Color::rgb(188, 188, 188)), text_subtle: AdaptiveColor::fixed(Color::rgb(98, 114, 164)), success: AdaptiveColor::fixed(Color::rgb(80, 250, 123)), warning: AdaptiveColor::fixed(Color::rgb(255, 184, 108)), error: AdaptiveColor::fixed(Color::rgb(255, 85, 85)), info: AdaptiveColor::fixed(Color::rgb(139, 233, 253)), border: AdaptiveColor::fixed(Color::rgb(68, 71, 90)), border_focused: AdaptiveColor::fixed(Color::rgb(189, 147, 249)), selection_bg: AdaptiveColor::fixed(Color::rgb(68, 71, 90)), selection_fg: AdaptiveColor::fixed(Color::rgb(248, 248, 242)), scrollbar_track: AdaptiveColor::fixed(Color::rgb(40, 42, 54)),
544 scrollbar_thumb: AdaptiveColor::fixed(Color::rgb(68, 71, 90)),
545 }
546 }
547
548 #[must_use]
550 pub fn solarized_dark() -> Theme {
551 Theme {
552 primary: AdaptiveColor::fixed(Color::rgb(38, 139, 210)), secondary: AdaptiveColor::fixed(Color::rgb(108, 113, 196)), accent: AdaptiveColor::fixed(Color::rgb(203, 75, 22)), background: AdaptiveColor::fixed(Color::rgb(0, 43, 54)), surface: AdaptiveColor::fixed(Color::rgb(7, 54, 66)), overlay: AdaptiveColor::fixed(Color::rgb(7, 54, 66)), text: AdaptiveColor::fixed(Color::rgb(131, 148, 150)), text_muted: AdaptiveColor::fixed(Color::rgb(101, 123, 131)), text_subtle: AdaptiveColor::fixed(Color::rgb(88, 110, 117)), success: AdaptiveColor::fixed(Color::rgb(133, 153, 0)), warning: AdaptiveColor::fixed(Color::rgb(181, 137, 0)), error: AdaptiveColor::fixed(Color::rgb(220, 50, 47)), info: AdaptiveColor::fixed(Color::rgb(38, 139, 210)), border: AdaptiveColor::fixed(Color::rgb(7, 54, 66)), border_focused: AdaptiveColor::fixed(Color::rgb(38, 139, 210)), selection_bg: AdaptiveColor::fixed(Color::rgb(7, 54, 66)), selection_fg: AdaptiveColor::fixed(Color::rgb(147, 161, 161)), scrollbar_track: AdaptiveColor::fixed(Color::rgb(0, 43, 54)),
576 scrollbar_thumb: AdaptiveColor::fixed(Color::rgb(7, 54, 66)),
577 }
578 }
579
580 #[must_use]
582 pub fn solarized_light() -> Theme {
583 Theme {
584 primary: AdaptiveColor::fixed(Color::rgb(38, 139, 210)), secondary: AdaptiveColor::fixed(Color::rgb(108, 113, 196)), accent: AdaptiveColor::fixed(Color::rgb(203, 75, 22)), background: AdaptiveColor::fixed(Color::rgb(253, 246, 227)), surface: AdaptiveColor::fixed(Color::rgb(238, 232, 213)), overlay: AdaptiveColor::fixed(Color::rgb(253, 246, 227)), text: AdaptiveColor::fixed(Color::rgb(101, 123, 131)), text_muted: AdaptiveColor::fixed(Color::rgb(88, 110, 117)), text_subtle: AdaptiveColor::fixed(Color::rgb(147, 161, 161)), success: AdaptiveColor::fixed(Color::rgb(133, 153, 0)), warning: AdaptiveColor::fixed(Color::rgb(181, 137, 0)), error: AdaptiveColor::fixed(Color::rgb(220, 50, 47)), info: AdaptiveColor::fixed(Color::rgb(38, 139, 210)), border: AdaptiveColor::fixed(Color::rgb(238, 232, 213)), border_focused: AdaptiveColor::fixed(Color::rgb(38, 139, 210)), selection_bg: AdaptiveColor::fixed(Color::rgb(238, 232, 213)), selection_fg: AdaptiveColor::fixed(Color::rgb(88, 110, 117)), scrollbar_track: AdaptiveColor::fixed(Color::rgb(253, 246, 227)),
608 scrollbar_thumb: AdaptiveColor::fixed(Color::rgb(238, 232, 213)),
609 }
610 }
611
612 #[must_use]
614 pub fn monokai() -> Theme {
615 Theme {
616 primary: AdaptiveColor::fixed(Color::rgb(102, 217, 239)), secondary: AdaptiveColor::fixed(Color::rgb(174, 129, 255)), accent: AdaptiveColor::fixed(Color::rgb(249, 38, 114)), background: AdaptiveColor::fixed(Color::rgb(39, 40, 34)), surface: AdaptiveColor::fixed(Color::rgb(60, 61, 54)), overlay: AdaptiveColor::fixed(Color::rgb(60, 61, 54)), text: AdaptiveColor::fixed(Color::rgb(248, 248, 242)), text_muted: AdaptiveColor::fixed(Color::rgb(189, 189, 189)), text_subtle: AdaptiveColor::fixed(Color::rgb(117, 113, 94)), success: AdaptiveColor::fixed(Color::rgb(166, 226, 46)), warning: AdaptiveColor::fixed(Color::rgb(230, 219, 116)), error: AdaptiveColor::fixed(Color::rgb(249, 38, 114)), info: AdaptiveColor::fixed(Color::rgb(102, 217, 239)), border: AdaptiveColor::fixed(Color::rgb(60, 61, 54)), border_focused: AdaptiveColor::fixed(Color::rgb(102, 217, 239)), selection_bg: AdaptiveColor::fixed(Color::rgb(73, 72, 62)), selection_fg: AdaptiveColor::fixed(Color::rgb(248, 248, 242)), scrollbar_track: AdaptiveColor::fixed(Color::rgb(39, 40, 34)),
640 scrollbar_thumb: AdaptiveColor::fixed(Color::rgb(60, 61, 54)),
641 }
642 }
643}
644
645pub struct SharedResolvedTheme {
673 inner: arc_swap::ArcSwap<ResolvedTheme>,
674}
675
676impl SharedResolvedTheme {
677 pub fn new(theme: ResolvedTheme) -> Self {
679 Self {
680 inner: arc_swap::ArcSwap::from_pointee(theme),
681 }
682 }
683
684 #[inline]
686 pub fn load(&self) -> ResolvedTheme {
687 let guard = self.inner.load();
688 **guard
689 }
690
691 #[inline]
693 pub fn store(&self, theme: ResolvedTheme) {
694 self.inner.store(std::sync::Arc::new(theme));
695 }
696}
697
698#[cfg(test)]
699mod tests {
700 use super::*;
701
702 #[test]
703 fn adaptive_color_fixed() {
704 let color = AdaptiveColor::fixed(Color::rgb(255, 0, 0));
705 assert_eq!(color.resolve(true), Color::rgb(255, 0, 0));
706 assert_eq!(color.resolve(false), Color::rgb(255, 0, 0));
707 assert!(!color.is_adaptive());
708 }
709
710 #[test]
711 fn adaptive_color_adaptive() {
712 let color = AdaptiveColor::adaptive(
713 Color::rgb(255, 255, 255), Color::rgb(0, 0, 0), );
716 assert_eq!(color.resolve(true), Color::rgb(0, 0, 0)); assert_eq!(color.resolve(false), Color::rgb(255, 255, 255)); assert!(color.is_adaptive());
719 }
720
721 #[test]
722 fn theme_default_is_dark() {
723 let theme = Theme::default();
724 let bg = theme.background.resolve(true);
726 if let Color::Rgb(rgb) = bg {
727 assert!(rgb.luminance_u8() < 50);
729 }
730 }
731
732 #[test]
733 fn theme_light_has_light_background() {
734 let theme = themes::light();
735 let bg = theme.background.resolve(false);
736 if let Color::Rgb(rgb) = bg {
737 assert!(rgb.luminance_u8() > 200);
739 }
740 }
741
742 #[test]
743 fn theme_has_all_slots() {
744 let theme = Theme::default();
745 let _ = theme.primary.resolve(true);
747 let _ = theme.secondary.resolve(true);
748 let _ = theme.accent.resolve(true);
749 let _ = theme.background.resolve(true);
750 let _ = theme.surface.resolve(true);
751 let _ = theme.overlay.resolve(true);
752 let _ = theme.text.resolve(true);
753 let _ = theme.text_muted.resolve(true);
754 let _ = theme.text_subtle.resolve(true);
755 let _ = theme.success.resolve(true);
756 let _ = theme.warning.resolve(true);
757 let _ = theme.error.resolve(true);
758 let _ = theme.info.resolve(true);
759 let _ = theme.border.resolve(true);
760 let _ = theme.border_focused.resolve(true);
761 let _ = theme.selection_bg.resolve(true);
762 let _ = theme.selection_fg.resolve(true);
763 let _ = theme.scrollbar_track.resolve(true);
764 let _ = theme.scrollbar_thumb.resolve(true);
765 }
766
767 #[test]
768 fn theme_builder_works() {
769 let theme = Theme::builder()
770 .primary(Color::rgb(255, 0, 0))
771 .background(Color::rgb(0, 0, 0))
772 .build();
773
774 assert_eq!(theme.primary.resolve(true), Color::rgb(255, 0, 0));
775 assert_eq!(theme.background.resolve(true), Color::rgb(0, 0, 0));
776 }
777
778 #[test]
779 fn theme_resolve_flattens() {
780 let theme = themes::dark();
781 let resolved = theme.resolve(true);
782
783 assert_eq!(resolved.primary, theme.primary.resolve(true));
785 assert_eq!(resolved.text, theme.text.resolve(true));
786 assert_eq!(resolved.background, theme.background.resolve(true));
787 }
788
789 #[test]
790 fn all_presets_exist() {
791 let _ = themes::default();
792 let _ = themes::dark();
793 let _ = themes::light();
794 let _ = themes::nord();
795 let _ = themes::dracula();
796 let _ = themes::solarized_dark();
797 let _ = themes::solarized_light();
798 let _ = themes::monokai();
799 }
800
801 #[test]
802 fn presets_have_different_colors() {
803 let dark = themes::dark();
804 let light = themes::light();
805 let nord = themes::nord();
806
807 assert_ne!(
809 dark.background.resolve(true),
810 light.background.resolve(false)
811 );
812 assert_ne!(dark.background.resolve(true), nord.background.resolve(true));
813 }
814
815 #[test]
816 fn detect_dark_mode_returns_bool() {
817 let _ = Theme::detect_dark_mode();
819 }
820
821 #[test]
822 fn color_converts_to_adaptive() {
823 let color = Color::rgb(100, 150, 200);
824 let adaptive: AdaptiveColor = color.into();
825 assert_eq!(adaptive.resolve(true), color);
826 assert_eq!(adaptive.resolve(false), color);
827 }
828
829 #[test]
830 fn builder_from_theme() {
831 let base = themes::nord();
832 let modified = ThemeBuilder::from_theme(base.clone())
833 .primary(Color::rgb(255, 0, 0))
834 .build();
835
836 assert_eq!(modified.primary.resolve(true), Color::rgb(255, 0, 0));
838 assert_eq!(modified.secondary, base.secondary);
840 }
841
842 #[test]
844 fn has_at_least_15_semantic_slots() {
845 let theme = Theme::default();
846 let slot_count = 19; assert!(slot_count >= 15);
848
849 let _slots = [
851 &theme.primary,
852 &theme.secondary,
853 &theme.accent,
854 &theme.background,
855 &theme.surface,
856 &theme.overlay,
857 &theme.text,
858 &theme.text_muted,
859 &theme.text_subtle,
860 &theme.success,
861 &theme.warning,
862 &theme.error,
863 &theme.info,
864 &theme.border,
865 &theme.border_focused,
866 &theme.selection_bg,
867 &theme.selection_fg,
868 &theme.scrollbar_track,
869 &theme.scrollbar_thumb,
870 ];
871 }
872
873 #[test]
874 fn adaptive_color_default_is_gray() {
875 let color = AdaptiveColor::default();
876 assert!(!color.is_adaptive());
877 assert_eq!(color.resolve(true), Color::rgb(128, 128, 128));
878 assert_eq!(color.resolve(false), Color::rgb(128, 128, 128));
879 }
880
881 #[test]
882 fn theme_builder_default() {
883 let builder = ThemeBuilder::default();
884 let theme = builder.build();
885 assert_eq!(theme, themes::dark());
887 }
888
889 #[test]
890 fn resolved_theme_has_all_19_slots() {
891 let theme = themes::dark();
892 let resolved = theme.resolve(true);
893 let _colors = [
895 resolved.primary,
896 resolved.secondary,
897 resolved.accent,
898 resolved.background,
899 resolved.surface,
900 resolved.overlay,
901 resolved.text,
902 resolved.text_muted,
903 resolved.text_subtle,
904 resolved.success,
905 resolved.warning,
906 resolved.error,
907 resolved.info,
908 resolved.border,
909 resolved.border_focused,
910 resolved.selection_bg,
911 resolved.selection_fg,
912 resolved.scrollbar_track,
913 resolved.scrollbar_thumb,
914 ];
915 }
916
917 #[test]
918 fn dark_and_light_resolve_differently() {
919 let theme = Theme {
920 text: AdaptiveColor::adaptive(Color::rgb(0, 0, 0), Color::rgb(255, 255, 255)),
921 ..themes::dark()
922 };
923 let dark_resolved = theme.resolve(true);
924 let light_resolved = theme.resolve(false);
925 assert_ne!(dark_resolved.text, light_resolved.text);
926 assert_eq!(dark_resolved.text, Color::rgb(255, 255, 255));
927 assert_eq!(light_resolved.text, Color::rgb(0, 0, 0));
928 }
929
930 #[test]
931 fn all_dark_presets_have_dark_backgrounds() {
932 for (name, theme) in [
933 ("dark", themes::dark()),
934 ("nord", themes::nord()),
935 ("dracula", themes::dracula()),
936 ("solarized_dark", themes::solarized_dark()),
937 ("monokai", themes::monokai()),
938 ] {
939 let bg = theme.background.resolve(true);
940 if let Color::Rgb(rgb) = bg {
941 assert!(
942 rgb.luminance_u8() < 100,
943 "{name} background too bright: {}",
944 rgb.luminance_u8()
945 );
946 }
947 }
948 }
949
950 #[test]
951 fn all_light_presets_have_light_backgrounds() {
952 for (name, theme) in [
953 ("light", themes::light()),
954 ("solarized_light", themes::solarized_light()),
955 ] {
956 let bg = theme.background.resolve(false);
957 if let Color::Rgb(rgb) = bg {
958 assert!(
959 rgb.luminance_u8() > 150,
960 "{name} background too dark: {}",
961 rgb.luminance_u8()
962 );
963 }
964 }
965 }
966
967 #[test]
968 fn theme_default_equals_dark() {
969 assert_eq!(Theme::default(), themes::dark());
970 assert_eq!(themes::default(), themes::dark());
971 }
972
973 #[test]
974 fn builder_all_setters_chain() {
975 let theme = Theme::builder()
976 .primary(Color::rgb(1, 0, 0))
977 .secondary(Color::rgb(2, 0, 0))
978 .accent(Color::rgb(3, 0, 0))
979 .background(Color::rgb(4, 0, 0))
980 .surface(Color::rgb(5, 0, 0))
981 .overlay(Color::rgb(6, 0, 0))
982 .text(Color::rgb(7, 0, 0))
983 .text_muted(Color::rgb(8, 0, 0))
984 .text_subtle(Color::rgb(9, 0, 0))
985 .success(Color::rgb(10, 0, 0))
986 .warning(Color::rgb(11, 0, 0))
987 .error(Color::rgb(12, 0, 0))
988 .info(Color::rgb(13, 0, 0))
989 .border(Color::rgb(14, 0, 0))
990 .border_focused(Color::rgb(15, 0, 0))
991 .selection_bg(Color::rgb(16, 0, 0))
992 .selection_fg(Color::rgb(17, 0, 0))
993 .scrollbar_track(Color::rgb(18, 0, 0))
994 .scrollbar_thumb(Color::rgb(19, 0, 0))
995 .build();
996 assert_eq!(theme.primary.resolve(true), Color::rgb(1, 0, 0));
997 assert_eq!(theme.scrollbar_thumb.resolve(true), Color::rgb(19, 0, 0));
998 }
999
1000 #[test]
1001 fn resolved_theme_is_copy() {
1002 let theme = themes::dark();
1003 let resolved = theme.resolve(true);
1004 let copy = resolved;
1005 assert_eq!(resolved, copy);
1006 }
1007
1008 #[test]
1009 fn detect_dark_mode_with_colorfgbg_dark() {
1010 let result = Theme::detect_dark_mode_from_colorfgbg(Some("0;0"));
1012 assert!(result, "bg=0 should be dark mode");
1013 }
1014
1015 #[test]
1016 fn detect_dark_mode_with_colorfgbg_light_15() {
1017 let result = Theme::detect_dark_mode_from_colorfgbg(Some("0;15"));
1019 assert!(!result, "bg=15 should be light mode");
1020 }
1021
1022 #[test]
1023 fn detect_dark_mode_with_colorfgbg_light_7() {
1024 let result = Theme::detect_dark_mode_from_colorfgbg(Some("0;7"));
1026 assert!(!result, "bg=7 should be light mode");
1027 }
1028
1029 #[test]
1030 fn detect_dark_mode_without_env_defaults_dark() {
1031 let result = Theme::detect_dark_mode_from_colorfgbg(None);
1032 assert!(result, "missing COLORFGBG should default to dark");
1033 }
1034
1035 #[test]
1036 fn detect_dark_mode_with_empty_string() {
1037 let result = Theme::detect_dark_mode_from_colorfgbg(Some(""));
1038 assert!(result, "empty COLORFGBG should default to dark");
1039 }
1040
1041 #[test]
1042 fn detect_dark_mode_with_no_semicolon() {
1043 let result = Theme::detect_dark_mode_from_colorfgbg(Some("0"));
1044 assert!(result, "COLORFGBG without semicolon should default to dark");
1045 }
1046
1047 #[test]
1048 fn detect_dark_mode_with_multiple_semicolons() {
1049 let result = Theme::detect_dark_mode_from_colorfgbg(Some("0;0;extra"));
1051 assert!(result, "COLORFGBG with extra parts should use last as bg");
1052 }
1053
1054 #[test]
1055 fn detect_dark_mode_with_whitespace() {
1056 let result = Theme::detect_dark_mode_from_colorfgbg(Some("0; 15 "));
1057 assert!(!result, "COLORFGBG with whitespace should parse correctly");
1058 }
1059
1060 #[test]
1061 fn detect_dark_mode_with_invalid_number() {
1062 let result = Theme::detect_dark_mode_from_colorfgbg(Some("0;abc"));
1063 assert!(
1064 result,
1065 "COLORFGBG with invalid number should default to dark"
1066 );
1067 }
1068
1069 #[test]
1070 fn theme_clone_produces_equal_theme() {
1071 let theme = themes::nord();
1072 let cloned = theme.clone();
1073 assert_eq!(theme, cloned);
1074 }
1075
1076 #[test]
1077 fn theme_equality_different_themes() {
1078 let dark = themes::dark();
1079 let light = themes::light();
1080 assert_ne!(dark, light);
1081 }
1082
1083 #[test]
1084 fn resolved_theme_different_modes_differ() {
1085 let theme = Theme {
1087 text: AdaptiveColor::adaptive(Color::rgb(0, 0, 0), Color::rgb(255, 255, 255)),
1088 background: AdaptiveColor::adaptive(Color::rgb(255, 255, 255), Color::rgb(0, 0, 0)),
1089 ..themes::dark()
1090 };
1091 let dark_resolved = theme.resolve(true);
1092 let light_resolved = theme.resolve(false);
1093 assert_ne!(dark_resolved, light_resolved);
1094 }
1095
1096 #[test]
1097 fn resolved_theme_equality_same_mode() {
1098 let theme = themes::dark();
1099 let resolved1 = theme.resolve(true);
1100 let resolved2 = theme.resolve(true);
1101 assert_eq!(resolved1, resolved2);
1102 }
1103
1104 #[test]
1105 fn preset_nord_has_characteristic_colors() {
1106 let nord = themes::nord();
1107 let primary = nord.primary.resolve(true);
1109 if let Color::Rgb(rgb) = primary {
1110 assert!(rgb.b > rgb.r, "Nord primary should be bluish");
1111 }
1112 }
1113
1114 #[test]
1115 fn preset_dracula_has_characteristic_colors() {
1116 let dracula = themes::dracula();
1117 let primary = dracula.primary.resolve(true);
1119 if let Color::Rgb(rgb) = primary {
1120 assert!(
1121 rgb.r > 100 && rgb.b > 200,
1122 "Dracula primary should be purple"
1123 );
1124 }
1125 }
1126
1127 #[test]
1128 fn preset_monokai_has_characteristic_colors() {
1129 let monokai = themes::monokai();
1130 let primary = monokai.primary.resolve(true);
1132 if let Color::Rgb(rgb) = primary {
1133 assert!(rgb.g > 200 && rgb.b > 200, "Monokai primary should be cyan");
1134 }
1135 }
1136
1137 #[test]
1138 fn preset_solarized_dark_and_light_share_accent_colors() {
1139 let sol_dark = themes::solarized_dark();
1140 let sol_light = themes::solarized_light();
1141 assert_eq!(
1143 sol_dark.primary.resolve(true),
1144 sol_light.primary.resolve(true),
1145 "Solarized dark and light should share primary accent"
1146 );
1147 }
1148
1149 #[test]
1150 fn builder_accepts_adaptive_color_directly() {
1151 let adaptive = AdaptiveColor::adaptive(Color::rgb(0, 0, 0), Color::rgb(255, 255, 255));
1152 let theme = Theme::builder().text(adaptive).build();
1153 assert!(theme.text.is_adaptive());
1154 }
1155
1156 #[test]
1157 fn all_presets_have_distinct_error_colors_from_info() {
1158 for (name, theme) in [
1159 ("dark", themes::dark()),
1160 ("light", themes::light()),
1161 ("nord", themes::nord()),
1162 ("dracula", themes::dracula()),
1163 ("solarized_dark", themes::solarized_dark()),
1164 ("monokai", themes::monokai()),
1165 ] {
1166 let error = theme.error.resolve(true);
1167 let info = theme.info.resolve(true);
1168 assert_ne!(
1169 error, info,
1170 "{name} should have distinct error and info colors"
1171 );
1172 }
1173 }
1174
1175 #[test]
1176 fn adaptive_color_debug_impl() {
1177 let fixed = AdaptiveColor::fixed(Color::rgb(255, 0, 0));
1178 let adaptive = AdaptiveColor::adaptive(Color::rgb(0, 0, 0), Color::rgb(255, 255, 255));
1179 let _ = format!("{:?}", fixed);
1181 let _ = format!("{:?}", adaptive);
1182 }
1183
1184 #[test]
1185 fn theme_debug_impl() {
1186 let theme = themes::dark();
1187 let debug = format!("{:?}", theme);
1189 assert!(debug.contains("Theme"));
1190 }
1191
1192 #[test]
1193 fn resolved_theme_debug_impl() {
1194 let resolved = themes::dark().resolve(true);
1195 let debug = format!("{:?}", resolved);
1196 assert!(debug.contains("ResolvedTheme"));
1197 }
1198
1199 #[test]
1202 fn shared_theme_load_returns_initial() {
1203 let dark = themes::dark().resolve(true);
1204 let shared = SharedResolvedTheme::new(dark);
1205 assert_eq!(shared.load(), dark);
1206 }
1207
1208 #[test]
1209 fn shared_theme_store_replaces_value() {
1210 let original = themes::dark().resolve(true);
1211 let mut updated = original;
1213 updated.primary = Color::rgb(0, 0, 0);
1214 assert_ne!(original.primary, updated.primary);
1215
1216 let shared = SharedResolvedTheme::new(original);
1217 shared.store(updated);
1218 assert_eq!(shared.load(), updated);
1219 assert_ne!(shared.load(), original);
1220 }
1221
1222 #[test]
1223 fn shared_theme_concurrent_read_write() {
1224 use std::sync::{Arc, Barrier};
1225 use std::thread;
1226
1227 let dark = themes::dark().resolve(true);
1228 let light = themes::dark().resolve(false);
1229 let shared = Arc::new(SharedResolvedTheme::new(dark));
1230 let barrier = Arc::new(Barrier::new(5));
1231
1232 let readers: Vec<_> = (0..4)
1233 .map(|_| {
1234 let s = Arc::clone(&shared);
1235 let b = Arc::clone(&barrier);
1236 let dark_copy = dark;
1237 let light_copy = light;
1238 thread::spawn(move || {
1239 b.wait();
1240 for _ in 0..10_000 {
1241 let theme = s.load();
1242 assert!(
1244 theme == dark_copy || theme == light_copy,
1245 "torn read detected"
1246 );
1247 }
1248 })
1249 })
1250 .collect();
1251
1252 let writer = {
1253 let s = Arc::clone(&shared);
1254 let b = Arc::clone(&barrier);
1255 thread::spawn(move || {
1256 b.wait();
1257 for i in 0..1_000 {
1258 if i % 2 == 0 {
1259 s.store(light);
1260 } else {
1261 s.store(dark);
1262 }
1263 }
1264 })
1265 };
1266
1267 writer.join().unwrap();
1268 for h in readers {
1269 h.join().unwrap();
1270 }
1271 }
1272}