1#![forbid(unsafe_code)]
2
3use crate::color::{
25 Color, Rgb, WCAG_AA_LARGE_TEXT, WCAG_AA_NORMAL_TEXT, WCAG_AAA_NORMAL_TEXT, contrast_ratio,
26};
27use std::env;
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum AdaptiveColor {
32 Fixed(Color),
34 Adaptive {
36 light: Color,
38 dark: Color,
40 },
41}
42
43impl AdaptiveColor {
44 #[inline]
46 pub const fn fixed(color: Color) -> Self {
47 Self::Fixed(color)
48 }
49
50 #[inline]
52 pub const fn adaptive(light: Color, dark: Color) -> Self {
53 Self::Adaptive { light, dark }
54 }
55
56 #[inline]
61 pub const fn resolve(&self, is_dark: bool) -> Color {
62 match self {
63 Self::Fixed(c) => *c,
64 Self::Adaptive { light, dark } => {
65 if is_dark {
66 *dark
67 } else {
68 *light
69 }
70 }
71 }
72 }
73
74 #[inline]
76 pub const fn is_adaptive(&self) -> bool {
77 matches!(self, Self::Adaptive { .. })
78 }
79}
80
81impl Default for AdaptiveColor {
82 fn default() -> Self {
83 Self::Fixed(Color::rgb(128, 128, 128))
84 }
85}
86
87impl From<Color> for AdaptiveColor {
88 fn from(color: Color) -> Self {
89 Self::Fixed(color)
90 }
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct Theme {
99 pub primary: AdaptiveColor,
102 pub secondary: AdaptiveColor,
104 pub accent: AdaptiveColor,
106
107 pub background: AdaptiveColor,
110 pub surface: AdaptiveColor,
112 pub overlay: AdaptiveColor,
114
115 pub text: AdaptiveColor,
118 pub text_muted: AdaptiveColor,
120 pub text_subtle: AdaptiveColor,
122
123 pub success: AdaptiveColor,
126 pub warning: AdaptiveColor,
128 pub error: AdaptiveColor,
130 pub info: AdaptiveColor,
132
133 pub border: AdaptiveColor,
136 pub border_focused: AdaptiveColor,
138
139 pub selection_bg: AdaptiveColor,
142 pub selection_fg: AdaptiveColor,
144
145 pub scrollbar_track: AdaptiveColor,
148 pub scrollbar_thumb: AdaptiveColor,
150}
151
152impl Default for Theme {
153 fn default() -> Self {
154 themes::dark()
155 }
156}
157
158impl Theme {
159 pub fn builder() -> ThemeBuilder {
161 ThemeBuilder::new()
162 }
163
164 #[must_use]
173 pub fn detect_dark_mode() -> bool {
174 Self::detect_dark_mode_from_colorfgbg(env::var("COLORFGBG").ok().as_deref())
175 }
176
177 fn detect_dark_mode_from_colorfgbg(colorfgbg: Option<&str>) -> bool {
178 if let Some(colorfgbg) = colorfgbg
181 && let Some(bg_part) = colorfgbg.split(';').next_back()
182 && let Ok(bg) = bg_part.trim().parse::<u8>()
183 {
184 return bg != 7 && bg != 15;
186 }
187
188 true
190 }
191
192 #[must_use]
196 pub fn resolve(&self, is_dark: bool) -> ResolvedTheme {
197 ResolvedTheme {
198 primary: self.primary.resolve(is_dark),
199 secondary: self.secondary.resolve(is_dark),
200 accent: self.accent.resolve(is_dark),
201 background: self.background.resolve(is_dark),
202 surface: self.surface.resolve(is_dark),
203 overlay: self.overlay.resolve(is_dark),
204 text: self.text.resolve(is_dark),
205 text_muted: self.text_muted.resolve(is_dark),
206 text_subtle: self.text_subtle.resolve(is_dark),
207 success: self.success.resolve(is_dark),
208 warning: self.warning.resolve(is_dark),
209 error: self.error.resolve(is_dark),
210 info: self.info.resolve(is_dark),
211 border: self.border.resolve(is_dark),
212 border_focused: self.border_focused.resolve(is_dark),
213 selection_bg: self.selection_bg.resolve(is_dark),
214 selection_fg: self.selection_fg.resolve(is_dark),
215 scrollbar_track: self.scrollbar_track.resolve(is_dark),
216 scrollbar_thumb: self.scrollbar_thumb.resolve(is_dark),
217 }
218 }
219}
220
221#[derive(Debug, Clone, Copy, PartialEq, Eq)]
225pub struct ResolvedTheme {
226 pub primary: Color,
228 pub secondary: Color,
230 pub accent: Color,
232 pub background: Color,
234 pub surface: Color,
236 pub overlay: Color,
238 pub text: Color,
240 pub text_muted: Color,
242 pub text_subtle: Color,
244 pub success: Color,
246 pub warning: Color,
248 pub error: Color,
250 pub info: Color,
252 pub border: Color,
254 pub border_focused: Color,
256 pub selection_bg: Color,
258 pub selection_fg: Color,
260 pub scrollbar_track: Color,
262 pub scrollbar_thumb: Color,
264}
265
266#[derive(Debug, Clone, Copy, PartialEq, Eq)]
288pub struct PaneAffordanceTheme {
289 pub background: Color,
291 pub splitter_idle: Color,
293 pub splitter_hover: Color,
295 pub splitter_active: Color,
297 pub snap: Color,
299 pub focus_ring: Color,
301 pub high_contrast: bool,
303}
304
305impl PaneAffordanceTheme {
306 #[must_use]
311 pub fn from_resolved(theme: &ResolvedTheme, high_contrast: bool) -> Self {
312 let bg = theme.surface;
313 let (idle_target, emph_target) = if high_contrast {
314 (WCAG_AAA_NORMAL_TEXT, WCAG_AAA_NORMAL_TEXT)
315 } else {
316 (WCAG_AA_LARGE_TEXT, WCAG_AA_NORMAL_TEXT)
317 };
318 Self {
319 background: bg,
320 splitter_idle: ensure_min_contrast(theme.border, bg, idle_target),
321 splitter_hover: ensure_min_contrast(theme.primary, bg, emph_target),
322 splitter_active: ensure_min_contrast(theme.border_focused, bg, emph_target),
323 snap: ensure_min_contrast(theme.warning, bg, emph_target),
324 focus_ring: ensure_min_contrast(theme.border_focused, bg, emph_target),
325 high_contrast,
326 }
327 }
328
329 #[must_use]
332 pub fn min_contrast_ratio(&self) -> f64 {
333 let bg = self.background.to_rgb();
334 [
335 self.splitter_idle,
336 self.splitter_hover,
337 self.splitter_active,
338 self.snap,
339 self.focus_ring,
340 ]
341 .into_iter()
342 .map(|c| contrast_ratio(c.to_rgb(), bg))
343 .fold(f64::INFINITY, f64::min)
344 }
345}
346
347fn ensure_min_contrast(color: Color, bg: Color, target: f64) -> Color {
355 let bg_rgb = bg.to_rgb();
356 let c_rgb = color.to_rgb();
357 if contrast_ratio(c_rgb, bg_rgb) >= target {
358 return color;
359 }
360 let white = Rgb::new(255, 255, 255);
365 let black = Rgb::new(0, 0, 0);
366 let extreme = if contrast_ratio(white, bg_rgb) >= contrast_ratio(black, bg_rgb) {
367 white
368 } else {
369 black
370 };
371 const STEPS: u16 = 16;
372 for step in 1..=STEPS {
373 let t = f64::from(step) / f64::from(STEPS);
374 let blended = lerp_rgb(c_rgb, extreme, t);
375 if contrast_ratio(blended, bg_rgb) >= target {
376 return Color::Rgb(blended);
377 }
378 }
379 Color::Rgb(extreme)
380}
381
382fn lerp_rgb(a: Rgb, b: Rgb, t: f64) -> Rgb {
384 let t = t.clamp(0.0, 1.0);
385 let lerp = |x: u8, y: u8| -> u8 {
386 let xf = f64::from(x);
387 let yf = f64::from(y);
388 (xf + (yf - xf) * t).round().clamp(0.0, 255.0) as u8
389 };
390 Rgb::new(lerp(a.r, b.r), lerp(a.g, b.g), lerp(a.b, b.b))
391}
392
393#[derive(Debug, Clone)]
395#[must_use]
396pub struct ThemeBuilder {
397 theme: Theme,
398}
399
400impl ThemeBuilder {
401 pub fn new() -> Self {
403 Self {
404 theme: themes::dark(),
405 }
406 }
407
408 pub fn from_theme(theme: Theme) -> Self {
410 Self { theme }
411 }
412
413 pub fn primary(mut self, color: impl Into<AdaptiveColor>) -> Self {
415 self.theme.primary = color.into();
416 self
417 }
418
419 pub fn secondary(mut self, color: impl Into<AdaptiveColor>) -> Self {
421 self.theme.secondary = color.into();
422 self
423 }
424
425 pub fn accent(mut self, color: impl Into<AdaptiveColor>) -> Self {
427 self.theme.accent = color.into();
428 self
429 }
430
431 pub fn background(mut self, color: impl Into<AdaptiveColor>) -> Self {
433 self.theme.background = color.into();
434 self
435 }
436
437 pub fn surface(mut self, color: impl Into<AdaptiveColor>) -> Self {
439 self.theme.surface = color.into();
440 self
441 }
442
443 pub fn overlay(mut self, color: impl Into<AdaptiveColor>) -> Self {
445 self.theme.overlay = color.into();
446 self
447 }
448
449 pub fn text(mut self, color: impl Into<AdaptiveColor>) -> Self {
451 self.theme.text = color.into();
452 self
453 }
454
455 pub fn text_muted(mut self, color: impl Into<AdaptiveColor>) -> Self {
457 self.theme.text_muted = color.into();
458 self
459 }
460
461 pub fn text_subtle(mut self, color: impl Into<AdaptiveColor>) -> Self {
463 self.theme.text_subtle = color.into();
464 self
465 }
466
467 pub fn success(mut self, color: impl Into<AdaptiveColor>) -> Self {
469 self.theme.success = color.into();
470 self
471 }
472
473 pub fn warning(mut self, color: impl Into<AdaptiveColor>) -> Self {
475 self.theme.warning = color.into();
476 self
477 }
478
479 pub fn error(mut self, color: impl Into<AdaptiveColor>) -> Self {
481 self.theme.error = color.into();
482 self
483 }
484
485 pub fn info(mut self, color: impl Into<AdaptiveColor>) -> Self {
487 self.theme.info = color.into();
488 self
489 }
490
491 pub fn border(mut self, color: impl Into<AdaptiveColor>) -> Self {
493 self.theme.border = color.into();
494 self
495 }
496
497 pub fn border_focused(mut self, color: impl Into<AdaptiveColor>) -> Self {
499 self.theme.border_focused = color.into();
500 self
501 }
502
503 pub fn selection_bg(mut self, color: impl Into<AdaptiveColor>) -> Self {
505 self.theme.selection_bg = color.into();
506 self
507 }
508
509 pub fn selection_fg(mut self, color: impl Into<AdaptiveColor>) -> Self {
511 self.theme.selection_fg = color.into();
512 self
513 }
514
515 pub fn scrollbar_track(mut self, color: impl Into<AdaptiveColor>) -> Self {
517 self.theme.scrollbar_track = color.into();
518 self
519 }
520
521 pub fn scrollbar_thumb(mut self, color: impl Into<AdaptiveColor>) -> Self {
523 self.theme.scrollbar_thumb = color.into();
524 self
525 }
526
527 pub fn build(self) -> Theme {
529 self.theme
530 }
531}
532
533impl Default for ThemeBuilder {
534 fn default() -> Self {
535 Self::new()
536 }
537}
538
539pub mod themes {
541 use super::*;
542
543 #[must_use]
545 pub fn default() -> Theme {
546 dark()
547 }
548
549 #[must_use]
551 pub fn dark() -> Theme {
552 Theme {
553 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)),
577 scrollbar_thumb: AdaptiveColor::fixed(Color::rgb(72, 79, 88)),
578 }
579 }
580
581 #[must_use]
583 pub fn light() -> Theme {
584 Theme {
585 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)),
609 scrollbar_thumb: AdaptiveColor::fixed(Color::rgb(175, 184, 193)),
610 }
611 }
612
613 #[must_use]
615 pub fn nord() -> Theme {
616 Theme {
617 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)),
641 scrollbar_thumb: AdaptiveColor::fixed(Color::rgb(76, 86, 106)),
642 }
643 }
644
645 #[must_use]
647 pub fn dracula() -> Theme {
648 Theme {
649 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)),
673 scrollbar_thumb: AdaptiveColor::fixed(Color::rgb(68, 71, 90)),
674 }
675 }
676
677 #[must_use]
679 pub fn solarized_dark() -> Theme {
680 Theme {
681 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)),
705 scrollbar_thumb: AdaptiveColor::fixed(Color::rgb(7, 54, 66)),
706 }
707 }
708
709 #[must_use]
711 pub fn solarized_light() -> Theme {
712 Theme {
713 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)),
737 scrollbar_thumb: AdaptiveColor::fixed(Color::rgb(238, 232, 213)),
738 }
739 }
740
741 #[must_use]
743 pub fn monokai() -> Theme {
744 Theme {
745 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)),
769 scrollbar_thumb: AdaptiveColor::fixed(Color::rgb(60, 61, 54)),
770 }
771 }
772
773 #[must_use]
775 pub fn doom() -> Theme {
776 Theme {
777 primary: AdaptiveColor::fixed(Color::rgb(178, 34, 34)), secondary: AdaptiveColor::fixed(Color::rgb(50, 205, 50)), accent: AdaptiveColor::fixed(Color::rgb(255, 255, 0)), background: AdaptiveColor::fixed(Color::rgb(26, 26, 26)), surface: AdaptiveColor::fixed(Color::rgb(47, 47, 47)), overlay: AdaptiveColor::fixed(Color::rgb(64, 64, 64)), text: AdaptiveColor::fixed(Color::rgb(211, 211, 211)), text_muted: AdaptiveColor::fixed(Color::rgb(128, 128, 128)), text_subtle: AdaptiveColor::fixed(Color::rgb(105, 105, 105)), success: AdaptiveColor::fixed(Color::rgb(50, 205, 50)), warning: AdaptiveColor::fixed(Color::rgb(255, 215, 0)), error: AdaptiveColor::fixed(Color::rgb(139, 0, 0)), info: AdaptiveColor::fixed(Color::rgb(65, 105, 225)), border: AdaptiveColor::fixed(Color::rgb(105, 105, 105)), border_focused: AdaptiveColor::fixed(Color::rgb(178, 34, 34)), selection_bg: AdaptiveColor::fixed(Color::rgb(139, 0, 0)), selection_fg: AdaptiveColor::fixed(Color::rgb(255, 255, 255)), scrollbar_track: AdaptiveColor::fixed(Color::rgb(26, 26, 26)),
801 scrollbar_thumb: AdaptiveColor::fixed(Color::rgb(178, 34, 34)),
802 }
803 }
804
805 #[must_use]
807 pub fn quake() -> Theme {
808 Theme {
809 primary: AdaptiveColor::fixed(Color::rgb(139, 69, 19)), secondary: AdaptiveColor::fixed(Color::rgb(85, 107, 47)), accent: AdaptiveColor::fixed(Color::rgb(205, 133, 63)), background: AdaptiveColor::fixed(Color::rgb(28, 28, 28)), surface: AdaptiveColor::fixed(Color::rgb(46, 39, 34)), overlay: AdaptiveColor::fixed(Color::rgb(62, 54, 48)), text: AdaptiveColor::fixed(Color::rgb(210, 180, 140)), text_muted: AdaptiveColor::fixed(Color::rgb(139, 115, 85)), text_subtle: AdaptiveColor::fixed(Color::rgb(101, 84, 61)), success: AdaptiveColor::fixed(Color::rgb(85, 107, 47)), warning: AdaptiveColor::fixed(Color::rgb(210, 105, 30)), error: AdaptiveColor::fixed(Color::rgb(128, 0, 0)), info: AdaptiveColor::fixed(Color::rgb(70, 130, 180)), border: AdaptiveColor::fixed(Color::rgb(93, 64, 55)), border_focused: AdaptiveColor::fixed(Color::rgb(205, 133, 63)), selection_bg: AdaptiveColor::fixed(Color::rgb(139, 69, 19)), selection_fg: AdaptiveColor::fixed(Color::rgb(255, 222, 173)), scrollbar_track: AdaptiveColor::fixed(Color::rgb(28, 28, 28)),
833 scrollbar_thumb: AdaptiveColor::fixed(Color::rgb(139, 69, 19)),
834 }
835 }
836}
837
838pub struct SharedResolvedTheme {
866 inner: arc_swap::ArcSwap<ResolvedTheme>,
867}
868
869impl SharedResolvedTheme {
870 pub fn new(theme: ResolvedTheme) -> Self {
872 Self {
873 inner: arc_swap::ArcSwap::from_pointee(theme),
874 }
875 }
876
877 #[inline]
879 pub fn load(&self) -> ResolvedTheme {
880 let guard = self.inner.load();
881 **guard
882 }
883
884 #[inline]
886 pub fn store(&self, theme: ResolvedTheme) {
887 self.inner.store(std::sync::Arc::new(theme));
888 }
889}
890
891#[cfg(test)]
892mod tests {
893 use super::*;
894
895 fn all_named_themes() -> Vec<(&'static str, Theme)> {
897 vec![
898 ("default", themes::default()),
899 ("dark", themes::dark()),
900 ("light", themes::light()),
901 ("nord", themes::nord()),
902 ("dracula", themes::dracula()),
903 ("solarized_dark", themes::solarized_dark()),
904 ("solarized_light", themes::solarized_light()),
905 ("monokai", themes::monokai()),
906 ("doom", themes::doom()),
907 ("quake", themes::quake()),
908 ]
909 }
910
911 fn max_achievable_contrast(bg: Color) -> f64 {
913 let bg = bg.to_rgb();
914 contrast_ratio(Rgb::new(255, 255, 255), bg).max(contrast_ratio(Rgb::new(0, 0, 0), bg))
915 }
916
917 fn assert_meets(state: Color, bg: Color, target: f64, ctx: &str) {
918 let bar = target.min(max_achievable_contrast(bg));
921 let got = contrast_ratio(state.to_rgb(), bg.to_rgb());
922 assert!(
923 got + 1e-9 >= bar,
924 "{ctx}: contrast {got:.3} below required {bar:.3}"
925 );
926 }
927
928 #[test]
929 fn pane_affordance_theme_is_visible_in_every_named_theme() {
930 for (name, theme) in all_named_themes() {
931 for &is_dark in &[true, false] {
932 let resolved = theme.resolve(is_dark);
933 let bg = resolved.surface;
934
935 let aff = PaneAffordanceTheme::from_resolved(&resolved, false);
938 let ctx = format!("{name}/dark={is_dark}/default");
939 assert_meets(aff.splitter_idle, bg, WCAG_AA_LARGE_TEXT, &ctx);
940 assert_meets(aff.splitter_hover, bg, WCAG_AA_NORMAL_TEXT, &ctx);
941 assert_meets(aff.splitter_active, bg, WCAG_AA_NORMAL_TEXT, &ctx);
942 assert_meets(aff.snap, bg, WCAG_AA_NORMAL_TEXT, &ctx);
943 assert_meets(aff.focus_ring, bg, WCAG_AA_NORMAL_TEXT, &ctx);
944 assert!(!aff.high_contrast);
945
946 let hc = PaneAffordanceTheme::from_resolved(&resolved, true);
949 let hctx = format!("{name}/dark={is_dark}/high_contrast");
950 assert_meets(hc.splitter_idle, bg, WCAG_AAA_NORMAL_TEXT, &hctx);
951 assert_meets(hc.splitter_hover, bg, WCAG_AAA_NORMAL_TEXT, &hctx);
952 assert_meets(hc.splitter_active, bg, WCAG_AAA_NORMAL_TEXT, &hctx);
953 assert_meets(hc.snap, bg, WCAG_AAA_NORMAL_TEXT, &hctx);
954 assert_meets(hc.focus_ring, bg, WCAG_AAA_NORMAL_TEXT, &hctx);
955 assert!(hc.high_contrast);
956
957 assert!(
959 hc.min_contrast_ratio() + 1e-9 >= aff.min_contrast_ratio(),
960 "{name}/dark={is_dark}: high-contrast min {:.3} < default min {:.3}",
961 hc.min_contrast_ratio(),
962 aff.min_contrast_ratio()
963 );
964 }
965 }
966 }
967
968 #[test]
969 fn pane_affordance_states_are_derived_from_the_theme() {
970 let resolved = themes::dark().resolve(true);
971 let dark = PaneAffordanceTheme::from_resolved(&resolved, false);
972 let light = PaneAffordanceTheme::from_resolved(&themes::light().resolve(false), false);
973
974 assert_eq!(dark.background, resolved.surface);
976
977 assert!(
981 contrast_ratio(resolved.primary.to_rgb(), resolved.surface.to_rgb())
982 >= WCAG_AA_NORMAL_TEXT
983 );
984 assert_eq!(dark.splitter_hover, resolved.primary);
985
986 assert_ne!(dark.background, light.background);
989 assert_ne!(dark.splitter_hover, light.splitter_hover);
990 }
991
992 #[test]
993 fn ensure_min_contrast_is_a_noop_when_already_compliant() {
994 let bg = Color::rgb(10, 12, 16);
996 let fg = Color::rgb(255, 255, 255);
997 assert_eq!(ensure_min_contrast(fg, bg, WCAG_AAA_NORMAL_TEXT), fg);
998 }
999
1000 #[test]
1001 fn ensure_min_contrast_lifts_low_contrast_colors() {
1002 let bg = Color::rgb(120, 120, 120);
1004 let fg = Color::rgb(130, 130, 130);
1005 let before = contrast_ratio(fg.to_rgb(), bg.to_rgb());
1006 assert!(before < WCAG_AA_LARGE_TEXT);
1007 let fixed = ensure_min_contrast(fg, bg, WCAG_AA_LARGE_TEXT);
1008 let after = contrast_ratio(fixed.to_rgb(), bg.to_rgb());
1009 assert!(
1010 after + 1e-9 >= WCAG_AA_LARGE_TEXT.min(max_achievable_contrast(bg)),
1011 "clamp failed to lift contrast: {before:.3} -> {after:.3}"
1012 );
1013 }
1014
1015 #[test]
1016 fn from_resolved_is_deterministic() {
1017 let resolved = themes::nord().resolve(true);
1018 let a = PaneAffordanceTheme::from_resolved(&resolved, true);
1019 let b = PaneAffordanceTheme::from_resolved(&resolved, true);
1020 assert_eq!(a, b);
1021 }
1022
1023 #[test]
1024 fn adaptive_color_fixed() {
1025 let color = AdaptiveColor::fixed(Color::rgb(255, 0, 0));
1026 assert_eq!(color.resolve(true), Color::rgb(255, 0, 0));
1027 assert_eq!(color.resolve(false), Color::rgb(255, 0, 0));
1028 assert!(!color.is_adaptive());
1029 }
1030
1031 #[test]
1032 fn adaptive_color_adaptive() {
1033 let color = AdaptiveColor::adaptive(
1034 Color::rgb(255, 255, 255), Color::rgb(0, 0, 0), );
1037 assert_eq!(color.resolve(true), Color::rgb(0, 0, 0)); assert_eq!(color.resolve(false), Color::rgb(255, 255, 255)); assert!(color.is_adaptive());
1040 }
1041
1042 #[test]
1043 fn theme_default_is_dark() {
1044 let theme = Theme::default();
1045 let bg = theme.background.resolve(true);
1047 if let Color::Rgb(rgb) = bg {
1048 assert!(rgb.luminance_u8() < 50);
1050 }
1051 }
1052
1053 #[test]
1054 fn theme_light_has_light_background() {
1055 let theme = themes::light();
1056 let bg = theme.background.resolve(false);
1057 if let Color::Rgb(rgb) = bg {
1058 assert!(rgb.luminance_u8() > 200);
1060 }
1061 }
1062
1063 #[test]
1064 fn theme_has_all_slots() {
1065 let theme = Theme::default();
1066 let _ = theme.primary.resolve(true);
1068 let _ = theme.secondary.resolve(true);
1069 let _ = theme.accent.resolve(true);
1070 let _ = theme.background.resolve(true);
1071 let _ = theme.surface.resolve(true);
1072 let _ = theme.overlay.resolve(true);
1073 let _ = theme.text.resolve(true);
1074 let _ = theme.text_muted.resolve(true);
1075 let _ = theme.text_subtle.resolve(true);
1076 let _ = theme.success.resolve(true);
1077 let _ = theme.warning.resolve(true);
1078 let _ = theme.error.resolve(true);
1079 let _ = theme.info.resolve(true);
1080 let _ = theme.border.resolve(true);
1081 let _ = theme.border_focused.resolve(true);
1082 let _ = theme.selection_bg.resolve(true);
1083 let _ = theme.selection_fg.resolve(true);
1084 let _ = theme.scrollbar_track.resolve(true);
1085 let _ = theme.scrollbar_thumb.resolve(true);
1086 }
1087
1088 #[test]
1089 fn theme_builder_works() {
1090 let theme = Theme::builder()
1091 .primary(Color::rgb(255, 0, 0))
1092 .background(Color::rgb(0, 0, 0))
1093 .build();
1094
1095 assert_eq!(theme.primary.resolve(true), Color::rgb(255, 0, 0));
1096 assert_eq!(theme.background.resolve(true), Color::rgb(0, 0, 0));
1097 }
1098
1099 #[test]
1100 fn theme_resolve_flattens() {
1101 let theme = themes::dark();
1102 let resolved = theme.resolve(true);
1103
1104 assert_eq!(resolved.primary, theme.primary.resolve(true));
1106 assert_eq!(resolved.text, theme.text.resolve(true));
1107 assert_eq!(resolved.background, theme.background.resolve(true));
1108 }
1109
1110 #[test]
1111 fn all_presets_exist() {
1112 let _ = themes::default();
1113 let _ = themes::dark();
1114 let _ = themes::light();
1115 let _ = themes::nord();
1116 let _ = themes::dracula();
1117 let _ = themes::solarized_dark();
1118 let _ = themes::solarized_light();
1119 let _ = themes::monokai();
1120 }
1121
1122 #[test]
1123 fn presets_have_different_colors() {
1124 let dark = themes::dark();
1125 let light = themes::light();
1126 let nord = themes::nord();
1127
1128 assert_ne!(
1130 dark.background.resolve(true),
1131 light.background.resolve(false)
1132 );
1133 assert_ne!(dark.background.resolve(true), nord.background.resolve(true));
1134 }
1135
1136 #[test]
1137 fn detect_dark_mode_returns_bool() {
1138 let _ = Theme::detect_dark_mode();
1140 }
1141
1142 #[test]
1143 fn color_converts_to_adaptive() {
1144 let color = Color::rgb(100, 150, 200);
1145 let adaptive: AdaptiveColor = color.into();
1146 assert_eq!(adaptive.resolve(true), color);
1147 assert_eq!(adaptive.resolve(false), color);
1148 }
1149
1150 #[test]
1151 fn builder_from_theme() {
1152 let base = themes::nord();
1153 let modified = ThemeBuilder::from_theme(base.clone())
1154 .primary(Color::rgb(255, 0, 0))
1155 .build();
1156
1157 assert_eq!(modified.primary.resolve(true), Color::rgb(255, 0, 0));
1159 assert_eq!(modified.secondary, base.secondary);
1161 }
1162
1163 #[test]
1165 fn has_at_least_15_semantic_slots() {
1166 let theme = Theme::default();
1167 let slot_count = 19; assert!(slot_count >= 15);
1169
1170 let _slots = [
1172 &theme.primary,
1173 &theme.secondary,
1174 &theme.accent,
1175 &theme.background,
1176 &theme.surface,
1177 &theme.overlay,
1178 &theme.text,
1179 &theme.text_muted,
1180 &theme.text_subtle,
1181 &theme.success,
1182 &theme.warning,
1183 &theme.error,
1184 &theme.info,
1185 &theme.border,
1186 &theme.border_focused,
1187 &theme.selection_bg,
1188 &theme.selection_fg,
1189 &theme.scrollbar_track,
1190 &theme.scrollbar_thumb,
1191 ];
1192 }
1193
1194 #[test]
1195 fn adaptive_color_default_is_gray() {
1196 let color = AdaptiveColor::default();
1197 assert!(!color.is_adaptive());
1198 assert_eq!(color.resolve(true), Color::rgb(128, 128, 128));
1199 assert_eq!(color.resolve(false), Color::rgb(128, 128, 128));
1200 }
1201
1202 #[test]
1203 fn theme_builder_default() {
1204 let builder = ThemeBuilder::default();
1205 let theme = builder.build();
1206 assert_eq!(theme, themes::dark());
1208 }
1209
1210 #[test]
1211 fn resolved_theme_has_all_19_slots() {
1212 let theme = themes::dark();
1213 let resolved = theme.resolve(true);
1214 let _colors = [
1216 resolved.primary,
1217 resolved.secondary,
1218 resolved.accent,
1219 resolved.background,
1220 resolved.surface,
1221 resolved.overlay,
1222 resolved.text,
1223 resolved.text_muted,
1224 resolved.text_subtle,
1225 resolved.success,
1226 resolved.warning,
1227 resolved.error,
1228 resolved.info,
1229 resolved.border,
1230 resolved.border_focused,
1231 resolved.selection_bg,
1232 resolved.selection_fg,
1233 resolved.scrollbar_track,
1234 resolved.scrollbar_thumb,
1235 ];
1236 }
1237
1238 #[test]
1239 fn dark_and_light_resolve_differently() {
1240 let theme = Theme {
1241 text: AdaptiveColor::adaptive(Color::rgb(0, 0, 0), Color::rgb(255, 255, 255)),
1242 ..themes::dark()
1243 };
1244 let dark_resolved = theme.resolve(true);
1245 let light_resolved = theme.resolve(false);
1246 assert_ne!(dark_resolved.text, light_resolved.text);
1247 assert_eq!(dark_resolved.text, Color::rgb(255, 255, 255));
1248 assert_eq!(light_resolved.text, Color::rgb(0, 0, 0));
1249 }
1250
1251 #[test]
1252 fn all_dark_presets_have_dark_backgrounds() {
1253 for (name, theme) in [
1254 ("dark", themes::dark()),
1255 ("nord", themes::nord()),
1256 ("dracula", themes::dracula()),
1257 ("solarized_dark", themes::solarized_dark()),
1258 ("monokai", themes::monokai()),
1259 ] {
1260 let bg = theme.background.resolve(true);
1261 if let Color::Rgb(rgb) = bg {
1262 assert!(
1263 rgb.luminance_u8() < 100,
1264 "{name} background too bright: {}",
1265 rgb.luminance_u8()
1266 );
1267 }
1268 }
1269 }
1270
1271 #[test]
1272 fn all_light_presets_have_light_backgrounds() {
1273 for (name, theme) in [
1274 ("light", themes::light()),
1275 ("solarized_light", themes::solarized_light()),
1276 ] {
1277 let bg = theme.background.resolve(false);
1278 if let Color::Rgb(rgb) = bg {
1279 assert!(
1280 rgb.luminance_u8() > 150,
1281 "{name} background too dark: {}",
1282 rgb.luminance_u8()
1283 );
1284 }
1285 }
1286 }
1287
1288 #[test]
1289 fn theme_default_equals_dark() {
1290 assert_eq!(Theme::default(), themes::dark());
1291 assert_eq!(themes::default(), themes::dark());
1292 }
1293
1294 #[test]
1295 fn builder_all_setters_chain() {
1296 let theme = Theme::builder()
1297 .primary(Color::rgb(1, 0, 0))
1298 .secondary(Color::rgb(2, 0, 0))
1299 .accent(Color::rgb(3, 0, 0))
1300 .background(Color::rgb(4, 0, 0))
1301 .surface(Color::rgb(5, 0, 0))
1302 .overlay(Color::rgb(6, 0, 0))
1303 .text(Color::rgb(7, 0, 0))
1304 .text_muted(Color::rgb(8, 0, 0))
1305 .text_subtle(Color::rgb(9, 0, 0))
1306 .success(Color::rgb(10, 0, 0))
1307 .warning(Color::rgb(11, 0, 0))
1308 .error(Color::rgb(12, 0, 0))
1309 .info(Color::rgb(13, 0, 0))
1310 .border(Color::rgb(14, 0, 0))
1311 .border_focused(Color::rgb(15, 0, 0))
1312 .selection_bg(Color::rgb(16, 0, 0))
1313 .selection_fg(Color::rgb(17, 0, 0))
1314 .scrollbar_track(Color::rgb(18, 0, 0))
1315 .scrollbar_thumb(Color::rgb(19, 0, 0))
1316 .build();
1317 assert_eq!(theme.primary.resolve(true), Color::rgb(1, 0, 0));
1318 assert_eq!(theme.scrollbar_thumb.resolve(true), Color::rgb(19, 0, 0));
1319 }
1320
1321 #[test]
1322 fn resolved_theme_is_copy() {
1323 let theme = themes::dark();
1324 let resolved = theme.resolve(true);
1325 let copy = resolved;
1326 assert_eq!(resolved, copy);
1327 }
1328
1329 #[test]
1330 fn detect_dark_mode_with_colorfgbg_dark() {
1331 let result = Theme::detect_dark_mode_from_colorfgbg(Some("0;0"));
1333 assert!(result, "bg=0 should be dark mode");
1334 }
1335
1336 #[test]
1337 fn detect_dark_mode_with_colorfgbg_light_15() {
1338 let result = Theme::detect_dark_mode_from_colorfgbg(Some("0;15"));
1340 assert!(!result, "bg=15 should be light mode");
1341 }
1342
1343 #[test]
1344 fn detect_dark_mode_with_colorfgbg_light_7() {
1345 let result = Theme::detect_dark_mode_from_colorfgbg(Some("0;7"));
1347 assert!(!result, "bg=7 should be light mode");
1348 }
1349
1350 #[test]
1351 fn detect_dark_mode_without_env_defaults_dark() {
1352 let result = Theme::detect_dark_mode_from_colorfgbg(None);
1353 assert!(result, "missing COLORFGBG should default to dark");
1354 }
1355
1356 #[test]
1357 fn detect_dark_mode_with_empty_string() {
1358 let result = Theme::detect_dark_mode_from_colorfgbg(Some(""));
1359 assert!(result, "empty COLORFGBG should default to dark");
1360 }
1361
1362 #[test]
1363 fn detect_dark_mode_with_no_semicolon() {
1364 let result = Theme::detect_dark_mode_from_colorfgbg(Some("0"));
1365 assert!(result, "COLORFGBG without semicolon should default to dark");
1366 }
1367
1368 #[test]
1369 fn detect_dark_mode_with_multiple_semicolons() {
1370 let result = Theme::detect_dark_mode_from_colorfgbg(Some("0;0;extra"));
1372 assert!(result, "COLORFGBG with extra parts should use last as bg");
1373 }
1374
1375 #[test]
1376 fn detect_dark_mode_with_whitespace() {
1377 let result = Theme::detect_dark_mode_from_colorfgbg(Some("0; 15 "));
1378 assert!(!result, "COLORFGBG with whitespace should parse correctly");
1379 }
1380
1381 #[test]
1382 fn detect_dark_mode_with_invalid_number() {
1383 let result = Theme::detect_dark_mode_from_colorfgbg(Some("0;abc"));
1384 assert!(
1385 result,
1386 "COLORFGBG with invalid number should default to dark"
1387 );
1388 }
1389
1390 #[test]
1391 fn theme_clone_produces_equal_theme() {
1392 let theme = themes::nord();
1393 let cloned = theme.clone();
1394 assert_eq!(theme, cloned);
1395 }
1396
1397 #[test]
1398 fn theme_equality_different_themes() {
1399 let dark = themes::dark();
1400 let light = themes::light();
1401 assert_ne!(dark, light);
1402 }
1403
1404 #[test]
1405 fn resolved_theme_different_modes_differ() {
1406 let theme = Theme {
1408 text: AdaptiveColor::adaptive(Color::rgb(0, 0, 0), Color::rgb(255, 255, 255)),
1409 background: AdaptiveColor::adaptive(Color::rgb(255, 255, 255), Color::rgb(0, 0, 0)),
1410 ..themes::dark()
1411 };
1412 let dark_resolved = theme.resolve(true);
1413 let light_resolved = theme.resolve(false);
1414 assert_ne!(dark_resolved, light_resolved);
1415 }
1416
1417 #[test]
1418 fn resolved_theme_equality_same_mode() {
1419 let theme = themes::dark();
1420 let resolved1 = theme.resolve(true);
1421 let resolved2 = theme.resolve(true);
1422 assert_eq!(resolved1, resolved2);
1423 }
1424
1425 #[test]
1426 fn preset_nord_has_characteristic_colors() {
1427 let nord = themes::nord();
1428 let primary = nord.primary.resolve(true);
1430 if let Color::Rgb(rgb) = primary {
1431 assert!(rgb.b > rgb.r, "Nord primary should be bluish");
1432 }
1433 }
1434
1435 #[test]
1436 fn preset_dracula_has_characteristic_colors() {
1437 let dracula = themes::dracula();
1438 let primary = dracula.primary.resolve(true);
1440 if let Color::Rgb(rgb) = primary {
1441 assert!(
1442 rgb.r > 100 && rgb.b > 200,
1443 "Dracula primary should be purple"
1444 );
1445 }
1446 }
1447
1448 #[test]
1449 fn preset_monokai_has_characteristic_colors() {
1450 let monokai = themes::monokai();
1451 let primary = monokai.primary.resolve(true);
1453 if let Color::Rgb(rgb) = primary {
1454 assert!(rgb.g > 200 && rgb.b > 200, "Monokai primary should be cyan");
1455 }
1456 }
1457
1458 #[test]
1459 fn preset_solarized_dark_and_light_share_accent_colors() {
1460 let sol_dark = themes::solarized_dark();
1461 let sol_light = themes::solarized_light();
1462 assert_eq!(
1464 sol_dark.primary.resolve(true),
1465 sol_light.primary.resolve(true),
1466 "Solarized dark and light should share primary accent"
1467 );
1468 }
1469
1470 #[test]
1471 fn builder_accepts_adaptive_color_directly() {
1472 let adaptive = AdaptiveColor::adaptive(Color::rgb(0, 0, 0), Color::rgb(255, 255, 255));
1473 let theme = Theme::builder().text(adaptive).build();
1474 assert!(theme.text.is_adaptive());
1475 }
1476
1477 #[test]
1478 fn all_presets_have_distinct_error_colors_from_info() {
1479 for (name, theme) in [
1480 ("dark", themes::dark()),
1481 ("light", themes::light()),
1482 ("nord", themes::nord()),
1483 ("dracula", themes::dracula()),
1484 ("solarized_dark", themes::solarized_dark()),
1485 ("monokai", themes::monokai()),
1486 ] {
1487 let error = theme.error.resolve(true);
1488 let info = theme.info.resolve(true);
1489 assert_ne!(
1490 error, info,
1491 "{name} should have distinct error and info colors"
1492 );
1493 }
1494 }
1495
1496 #[test]
1497 fn adaptive_color_debug_impl() {
1498 let fixed = AdaptiveColor::fixed(Color::rgb(255, 0, 0));
1499 let adaptive = AdaptiveColor::adaptive(Color::rgb(0, 0, 0), Color::rgb(255, 255, 255));
1500 let _ = format!("{:?}", fixed);
1502 let _ = format!("{:?}", adaptive);
1503 }
1504
1505 #[test]
1506 fn theme_debug_impl() {
1507 let theme = themes::dark();
1508 let debug = format!("{:?}", theme);
1510 assert!(debug.contains("Theme"));
1511 }
1512
1513 #[test]
1514 fn resolved_theme_debug_impl() {
1515 let resolved = themes::dark().resolve(true);
1516 let debug = format!("{:?}", resolved);
1517 assert!(debug.contains("ResolvedTheme"));
1518 }
1519
1520 #[test]
1523 fn shared_theme_load_returns_initial() {
1524 let dark = themes::dark().resolve(true);
1525 let shared = SharedResolvedTheme::new(dark);
1526 assert_eq!(shared.load(), dark);
1527 }
1528
1529 #[test]
1530 fn shared_theme_store_replaces_value() {
1531 let original = themes::dark().resolve(true);
1532 let mut updated = original;
1534 updated.primary = Color::rgb(0, 0, 0);
1535 assert_ne!(original.primary, updated.primary);
1536
1537 let shared = SharedResolvedTheme::new(original);
1538 shared.store(updated);
1539 assert_eq!(shared.load(), updated);
1540 assert_ne!(shared.load(), original);
1541 }
1542
1543 #[test]
1544 fn shared_theme_concurrent_read_write() {
1545 use std::sync::{Arc, Barrier};
1546 use std::thread;
1547
1548 let dark = themes::dark().resolve(true);
1549 let light = themes::dark().resolve(false);
1550 let shared = Arc::new(SharedResolvedTheme::new(dark));
1551 let barrier = Arc::new(Barrier::new(5));
1552
1553 let readers: Vec<_> = (0..4)
1554 .map(|_| {
1555 let s = Arc::clone(&shared);
1556 let b = Arc::clone(&barrier);
1557 let dark_copy = dark;
1558 let light_copy = light;
1559 thread::spawn(move || {
1560 b.wait();
1561 for _ in 0..10_000 {
1562 let theme = s.load();
1563 assert!(
1565 theme == dark_copy || theme == light_copy,
1566 "torn read detected"
1567 );
1568 }
1569 })
1570 })
1571 .collect();
1572
1573 let writer = {
1574 let s = Arc::clone(&shared);
1575 let b = Arc::clone(&barrier);
1576 thread::spawn(move || {
1577 b.wait();
1578 for i in 0..1_000 {
1579 if i % 2 == 0 {
1580 s.store(light);
1581 } else {
1582 s.store(dark);
1583 }
1584 }
1585 })
1586 };
1587
1588 writer.join().unwrap();
1589 for h in readers {
1590 h.join().unwrap();
1591 }
1592 }
1593}