返回 CodeWhale
theme.rs
1 //! One theme: color roles from the Codewhale design tokens.
2 //!
3 //! Components name a [`Role`]; they never name a color. [`Theme`] resolves a
4 //! role for the terminal it is painting on, every time it paints, so a theme
5 //! or depth change reaches every component on the next frame.
6 //!
7 //! | Depth | Grounds | Ink | State hues |
8 //! |---|---|---|---|
9 //! | TrueColor | exact token RGB | exact RGB | exact RGB |
10 //! | Ansi256 | nearest fixed index | generated table, audited for contrast | generated table |
11 //! | Ansi16 | none (the terminal owns its ground) | `Reset`; `Muted`, `Hint` and `Dim` add `DIM` | Blue, Green, Yellow, Red |
12 //! | Monochrome (`NO_COLOR`) | none | `Reset`; hierarchy by `BOLD`/`DIM` only | `Reset`: the mark and word carry state |
13 //!
14 //! Dark terminals take the blue ombre by default ([`Ground::Ocean`]): the
15 //! token grounds and quiet lines tinted toward the logo's deep blue at the
16 //! same luminance, so every contrast the tokens audit still holds. It shows
17 //! at truecolor only; the 256-color cube has no navy fine enough, so 256
18 //! colors keep the token grounds. [`Ground::Graphite`] keeps the exact token
19 //! grounds everywhere.
20 //!
21 //! When nothing measured the terminal's ground ([`Appearance::Unknown`]), a
22 //! truecolor terminal is painted as at ANSI-16: its named colors were chosen
23 //! for its own ground, and ours were not. This is stricter than the engine's
24 //! palette, which assumes dark. A host that knows better (a theme setting the
25 //! person chose) sets [`Caps::appearance`] itself; a person can force it with
26 //! `CODEWHALE_APPEARANCE=light` or `=dark`.
27
28 use ratatui::style::{Color, Modifier, Style};
29
30 use crate::color::{ColorDepth, rgb};
31 use crate::detect::{Appearance, terminal_background};
32 use crate::roles;
33
34 pub use crate::roles::{Role, TOKENS_VERSION};
35
36 /// The logo's ombre, top-left to bottom-right (`#1E8FD8` to `#0B48BB`), from
37 /// the design direction. The whale wears it; [`Ground::Ocean`] grounds lean
38 /// toward its deep end.
39 pub const LOGO_TOP: u32 = 0x1e8fd8;
40 pub const LOGO_BOTTOM: u32 = 0x0b48bb;
41
42 /// How far [`Ground::Ocean`] moves each dark ground and quiet line toward
43 /// [`LOGO_BOTTOM`] before restoring its luminance.
44 pub const OCEAN_TINT: f64 = 0.5;
45
46 /// Which grounds a dark theme paints.
47 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
48 pub enum Ground {
49 /// Deep navy rising into the logo's blue: the preferred look.
50 #[default]
51 Ocean,
52 /// The exact token grounds (graphite), as the desktop app paints them.
53 Graphite,
54 }
55
56 impl Role {
57 /// Whether [`Ground::Ocean`] tints this role toward the logo's blue: the
58 /// five grounds and quiet lines. Ink, state hues and control edges keep
59 /// their token values; the diff tints are re-mixed over the ombre's own
60 /// background instead ([`Role::is_content_tint`]).
61 #[must_use]
62 pub const fn ocean_tinted(self) -> bool {
63 matches!(
64 self,
65 Role::Sidebar
66 | Role::Background
67 | Role::Surface
68 | Role::Hover
69 | Role::Selected
70 | Role::Border
71 )
72 }
73
74 /// A content tint: a ground behind a line of content, painted behind
75 /// `Foreground` ink only. Where grounds do not paint (16 colors,
76 /// `NO_COLOR`) the tint is dropped and a glyph carries the meaning.
77 #[must_use]
78 pub const fn is_content_tint(self) -> bool {
79 matches!(self, Role::DiffAddedTint | Role::DiffRemovedTint)
80 }
81
82 /// Whether this role is painted as a background: the five grounds and
83 /// the content tints.
84 #[must_use]
85 pub const fn is_ground(self) -> bool {
86 matches!(
87 self,
88 Role::Sidebar | Role::Background | Role::Surface | Role::Hover | Role::Selected
89 ) || self.is_content_tint()
90 }
91
92 /// The named color a 16-color terminal shows for this role, or `None`
93 /// when the role takes the terminal's own foreground.
94 const fn ansi16(self) -> Option<Color> {
95 match self {
96 Role::Primary => Some(Color::Blue),
97 Role::Live => Some(Color::Green),
98 Role::Attention => Some(Color::Yellow),
99 Role::Danger => Some(Color::Red),
100 _ => None,
101 }
102 }
103
104 /// Ink that recedes. Where color is unavailable it recedes with `DIM`.
105 const fn recedes(self) -> bool {
106 matches!(self, Role::Muted | Role::Hint | Role::Dim | Role::Border)
107 }
108 }
109
110 /// What the terminal can show.
111 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
112 pub struct Caps {
113 pub depth: ColorDepth,
114 /// `CODEWHALE_ASCII_SAFE`: draw marks from the ASCII fallbacks.
115 pub ascii: bool,
116 pub appearance: Appearance,
117 }
118
119 impl Caps {
120 /// Read depth, ASCII-safety and appearance from the environment and any
121 /// completed [`crate::detect::probe_terminal_background`].
122 #[must_use]
123 pub fn detect() -> Self {
124 Self::detect_with(
125 |key| std::env::var_os(key),
126 || terminal_background().appearance(),
127 )
128 }
129
130 /// [`Caps::detect`] with an injected environment reader and ground
131 /// detector, so every branch is testable without the process state.
132 ///
133 /// `CODEWHALE_APPEARANCE` (`light` or `dark`) overrides detection: the
134 /// person's word beats a measurement, and the detector is not called.
135 #[must_use]
136 pub fn detect_with(
137 get: impl Fn(&str) -> Option<std::ffi::OsString>,
138 detect_ground: impl FnOnce() -> Appearance,
139 ) -> Self {
140 let ascii =
141 get("CODEWHALE_ASCII_SAFE").is_some_and(|v| !v.is_empty() && v != "0" && v != "false");
142 let forced = get("CODEWHALE_APPEARANCE").and_then(|v| {
143 match v.to_string_lossy().trim().to_ascii_lowercase().as_str() {
144 "light" => Some(Appearance::Light),
145 "dark" => Some(Appearance::Dark),
146 _ => None,
147 }
148 });
149 Self {
150 depth: ColorDepth::detect_with(&get),
151 ascii,
152 appearance: forced.unwrap_or_else(detect_ground),
153 }
154 }
155
156 /// Whether this terminal shows token colors at all.
157 #[must_use]
158 pub const fn paints_tokens(&self) -> bool {
159 matches!(self.depth, ColorDepth::TrueColor | ColorDepth::Ansi256)
160 && !matches!(self.appearance, Appearance::Unknown)
161 }
162 }
163
164 /// The Codewhale theme resolved for one terminal.
165 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
166 pub struct Theme {
167 caps: Caps,
168 grounds: bool,
169 ground: Ground,
170 native: Option<crate::TuiPalette>,
171 }
172
173 impl Theme {
174 #[must_use]
175 pub const fn new(caps: Caps) -> Self {
176 Self {
177 caps,
178 grounds: true,
179 ground: Ground::Ocean,
180 native: None,
181 }
182 }
183
184 /// Choose the dark grounds: the blue ombre (default) or graphite. Light
185 /// terminals always paint the light token grounds.
186 #[must_use]
187 pub const fn ground(mut self, ground: Ground) -> Self {
188 self.ground = ground;
189 self.native = None;
190 self
191 }
192
193 /// Use one of the palettes shipped by the current Codewhale TUI.
194 /// Terminal capability and unknown-ground fallbacks remain in force.
195 #[must_use]
196 pub const fn tui_palette(mut self, palette: crate::TuiPalette) -> Self {
197 self.native = Some(palette);
198 self
199 }
200
201 #[must_use]
202 pub const fn native_palette(&self) -> Option<crate::TuiPalette> {
203 self.native
204 }
205
206 /// A distinct native TUI semantic ink, adapted by the same terminal rules.
207 #[must_use]
208 pub fn tui_ink(&self, ink: crate::TuiInk) -> Style {
209 if self.caps.paints_tokens()
210 && let Some(palette) = self.native
211 {
212 let color = match (self.depth(), palette.ink(ink)) {
213 (ColorDepth::Ansi256, Color::Rgb(r, g, b)) => {
214 Color::Indexed(crate::color::rgb_to_ansi256(r, g, b))
215 }
216 (_, color) => color,
217 };
218 Style::default().fg(color)
219 } else {
220 self.fg(ink.fallback_role())
221 }
222 }
223
224 /// Native background slots such as composer and footer. Limited color
225 /// and unknown-ground terminals keep the existing capability fallbacks.
226 #[must_use]
227 pub fn tui_ground(&self, ground: crate::TuiGround) -> Style {
228 if self.paints_grounds()
229 && let Some(palette) = self.native
230 {
231 let color = match (self.depth(), palette.ground(ground)) {
232 (ColorDepth::Ansi256, Color::Rgb(r, g, b)) => {
233 Color::Indexed(crate::color::rgb_to_ansi256(r, g, b))
234 }
235 (_, color) => color,
236 };
237 Style::default().bg(color)
238 } else {
239 self.bg(ground.fallback_role())
240 }
241 }
242
243 /// Codewhale's default Underwater, or its light palette on a light terminal.
244 #[must_use]
245 pub const fn tui(self) -> Self {
246 self.tui_palette(if self.light() {
247 crate::TuiPalette::WhaleLight
248 } else {
249 crate::TuiPalette::Underwater
250 })
251 }
252
253 /// The dark grounds this theme was built with.
254 #[must_use]
255 pub const fn ground_kind(&self) -> Ground {
256 self.ground
257 }
258
259 /// Detect everything from the environment.
260 #[must_use]
261 pub fn detect() -> Self {
262 Self::new(Caps::detect())
263 }
264
265 /// Leave the terminal's own ground alone, even where token grounds would
266 /// render. Raised panels and selection still paint.
267 #[must_use]
268 pub const fn without_base_ground(mut self) -> Self {
269 self.grounds = false;
270 self
271 }
272
273 #[must_use]
274 pub const fn caps(&self) -> Caps {
275 self.caps
276 }
277
278 #[must_use]
279 pub const fn ascii(&self) -> bool {
280 self.caps.ascii
281 }
282
283 #[must_use]
284 pub const fn depth(&self) -> ColorDepth {
285 self.caps.depth
286 }
287
288 /// Which token table this theme reads. Unknown reads the dark table,
289 /// but [`Caps::paints_tokens`] is false then, so no token color reaches
290 /// the screen.
291 const fn light(&self) -> bool {
292 matches!(self.caps.appearance, Appearance::Light)
293 }
294
295 /// The exact token color (`0xRRGGBB`), before depth adaptation.
296 #[must_use]
297 pub const fn token_hex(&self, role: Role) -> u32 {
298 if let Some(palette) = self.native
299 && let Color::Rgb(r, g, b) = palette.color(role)
300 {
301 return ((r as u32) << 16) | ((g as u32) << 8) | b as u32;
302 }
303 if self.light() {
304 roles::LIGHT[role.index()]
305 } else if matches!(self.ground, Ground::Ocean) {
306 roles::OCEAN[role.index()]
307 } else {
308 roles::DARK[role.index()]
309 }
310 }
311
312 /// The exact token color, before depth adaptation. Under
313 /// [`Ground::Ocean`] on a dark ground, grounds and quiet lines are the
314 /// tinted values; 256 colors still show the token grounds.
315 #[must_use]
316 pub const fn token(&self, role: Role) -> Color {
317 match self.native {
318 Some(palette) => palette.color(role),
319 None => rgb(self.token_hex(role)),
320 }
321 }
322
323 /// The color this terminal shows for `role`, or `None` when it shows the
324 /// terminal's own color.
325 #[must_use]
326 pub fn color(&self, role: Role) -> Option<Color> {
327 if self.caps.paints_tokens() {
328 if self.native.is_some() {
329 return Some(match (self.caps.depth, self.token(role)) {
330 (ColorDepth::Ansi256, Color::Rgb(r, g, b)) => {
331 Color::Indexed(crate::color::rgb_to_ansi256(r, g, b))
332 }
333 (_, color) => color,
334 });
335 }
336 return Some(match self.caps.depth {
337 ColorDepth::TrueColor => self.token(role),
338 _ if self.light() => Color::Indexed(roles::LIGHT_256[role.index()]),
339 _ => Color::Indexed(roles::DARK_256[role.index()]),
340 });
341 }
342 match self.caps.depth {
343 ColorDepth::Monochrome => None,
344 _ => role.ansi16(),
345 }
346 }
347
348 /// Ink: `role` as a foreground.
349 #[must_use]
350 pub fn fg(&self, role: Role) -> Style {
351 let style = Style::default();
352 match self.color(role) {
353 Some(color) => style.fg(color),
354 None if role.recedes() => style.add_modifier(Modifier::DIM),
355 None => style,
356 }
357 }
358
359 /// Ground: `role` as a background, or nothing where grounds do not paint.
360 /// The base `Background` also stays unpainted after
361 /// [`Theme::without_base_ground`].
362 #[must_use]
363 pub fn bg(&self, role: Role) -> Style {
364 if !self.caps.paints_tokens() || (!self.grounds && role == Role::Background) {
365 return Style::default();
366 }
367 match self.color(role) {
368 Some(color) => Style::default().bg(color),
369 None => Style::default(),
370 }
371 }
372
373 /// Whether grounds paint at all. Where they do not, components must draw
374 /// an edge or a mark instead of relying on a fill.
375 #[must_use]
376 pub fn paints_grounds(&self) -> bool {
377 self.caps.paints_tokens()
378 }
379
380 /// Whether grounds `a` and `b` look different on this terminal. False
381 /// where grounds do not paint, and where the two roles quantize to the
382 /// same color (light 256-color `Surface` and `Background`, for one), so
383 /// a component relying on the difference must draw an edge instead.
384 #[must_use]
385 pub fn grounds_differ(&self, a: Role, b: Role) -> bool {
386 self.bg(a) != self.bg(b)
387 }
388
389 /// Whether the base `Background` ground is painted by this theme (false
390 /// without grounds and after [`Theme::without_base_ground`]).
391 #[must_use]
392 pub fn paints_base_ground(&self) -> bool {
393 self.bg(Role::Background).bg.is_some()
394 }
395
396 /// Every role that resolves to `color` here, grounds first for
397 /// backgrounds and ink first for foregrounds. More than one means those
398 /// roles look the same at this depth.
399 #[must_use]
400 pub fn roles_of(&self, color: Color, as_ground: bool) -> Vec<Role> {
401 let (mut preferred, rest): (Vec<Role>, Vec<Role>) = Role::ALL
402 .iter()
403 .copied()
404 .filter(|r| self.color(*r) == Some(color))
405 .partition(|r| r.is_ground() == as_ground);
406 if preferred.is_empty() {
407 preferred = rest;
408 }
409 preferred
410 }
411
412 /// Reverse lookup for snapshots: the first of [`Theme::roles_of`].
413 #[must_use]
414 pub fn role_of(&self, color: Color, as_ground: bool) -> Option<Role> {
415 self.roles_of(color, as_ground).first().copied()
416 }
417 }
418
419 #[cfg(test)]
420 mod tests {
421 use super::*;
422 use crate::color::contrast_ratio;
423
424 fn theme(depth: ColorDepth, appearance: Appearance) -> Theme {
425 Theme::new(Caps {
426 depth,
427 ascii: false,
428 appearance,
429 })
430 }
431
432 #[test]
433 fn truecolor_reads_the_token_tables() {
434 let dark = theme(ColorDepth::TrueColor, Appearance::Dark);
435 assert_eq!(
436 dark.color(Role::Primary),
437 Some(rgb(crate::tokens::DARK.primary))
438 );
439 let light = theme(ColorDepth::TrueColor, Appearance::Light);
440 assert_eq!(
441 light.color(Role::Danger),
442 Some(rgb(crate::tokens::LIGHT.danger))
443 );
444 }
445
446 /// The blue ombre tints dark grounds and the quiet line toward the
447 /// logo's deep blue, keeps every ink, and shows only at truecolor.
448 #[test]
449 fn ocean_tints_dark_grounds_only() {
450 let ocean = theme(ColorDepth::TrueColor, Appearance::Dark);
451 let graphite = ocean.ground(Ground::Graphite);
452 assert_eq!(ocean.ground_kind(), Ground::Ocean, "the preferred look");
453 for role in Role::ALL {
454 let (o, g) = (ocean.token_hex(role), graphite.token_hex(role));
455 if role.is_content_tint() {
456 // Mixed over the ombre's own background; the contrast test
457 // holds them to their floors.
458 assert_ne!(o, g, "{role:?} sits on the ombre, not on graphite");
459 } else if role.ocean_tinted() {
460 let (r, gr, b) = ((o >> 16) & 0xff, (o >> 8) & 0xff, o & 0xff);
461 assert!(b > r && b > gr, "{role:?} #{o:06x} leans blue");
462 let lum = |c| crate::color::relative_luminance(rgb(c)).unwrap();
463 assert!((lum(o) - lum(g)).abs() < 0.002, "{role:?} keeps luminance");
464 } else {
465 assert_eq!(o, g, "{role:?} keeps its token ink");
466 }
467 }
468 for depth in [
469 ColorDepth::Ansi256,
470 ColorDepth::Ansi16,
471 ColorDepth::Monochrome,
472 ] {
473 let o = theme(depth, Appearance::Dark);
474 let g = o.ground(Ground::Graphite);
475 for role in Role::ALL {
476 assert_eq!(o.color(role), g.color(role), "{depth:?} {role:?}");
477 }
478 }
479 let light = theme(ColorDepth::TrueColor, Appearance::Light);
480 for role in Role::ALL {
481 assert_eq!(
482 light.color(role),
483 light.ground(Ground::Graphite).color(role),
484 "light {role:?}"
485 );
486 }
487 }
488
489 #[test]
490 fn unknown_ground_never_paints_token_colors() {
491 let t = theme(ColorDepth::TrueColor, Appearance::Unknown);
492 assert_eq!(t.bg(Role::Background), Style::default());
493 assert_eq!(t.color(Role::Foreground), None);
494 assert_eq!(t.color(Role::Live), Some(Color::Green));
495 assert!(t.fg(Role::Muted).add_modifier.contains(Modifier::DIM));
496 }
497
498 #[test]
499 fn no_color_uses_only_modifiers() {
500 let t = theme(ColorDepth::Monochrome, Appearance::Dark);
501 for role in Role::ALL {
502 let fg = t.fg(role);
503 assert_eq!(fg.fg, None, "{role:?}");
504 assert_eq!(t.bg(role).bg, None, "{role:?}");
505 }
506 assert!(t.fg(Role::Muted).add_modifier.contains(Modifier::DIM));
507 }
508
509 #[test]
510 fn ansi16_state_hues_are_distinct() {
511 let t = theme(ColorDepth::Ansi16, Appearance::Dark);
512 let hues: Vec<_> = [Role::Primary, Role::Live, Role::Attention, Role::Danger]
513 .iter()
514 .map(|r| t.color(*r).unwrap())
515 .collect();
516 for (i, a) in hues.iter().enumerate() {
517 for b in &hues[i + 1..] {
518 assert_ne!(a, b);
519 }
520 }
521 assert_eq!(t.bg(Role::Selected), Style::default());
522 }
523
524 fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<std::ffi::OsString> {
525 let pairs: Vec<(String, String)> = pairs
526 .iter()
527 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
528 .collect();
529 move |key| pairs.iter().find(|(k, _)| k == key).map(|(_, v)| v.into())
530 }
531
532 #[test]
533 fn caps_read_ascii_safety_from_the_environment() {
534 let on = Caps::detect_with(env(&[("CODEWHALE_ASCII_SAFE", "1")]), || Appearance::Dark);
535 assert!(on.ascii);
536 for off in ["", "0", "false"] {
537 let caps =
538 Caps::detect_with(env(&[("CODEWHALE_ASCII_SAFE", off)]), || Appearance::Dark);
539 assert!(!caps.ascii, "{off:?}");
540 }
541 assert!(!Caps::detect_with(env(&[]), || Appearance::Dark).ascii);
542 }
543
544 #[test]
545 fn a_forced_appearance_beats_detection() {
546 let light = Caps::detect_with(
547 env(&[
548 ("CODEWHALE_APPEARANCE", "Light"),
549 ("COLORTERM", "truecolor"),
550 ]),
551 || panic!("forced appearance must not probe"),
552 );
553 assert_eq!(light.appearance, Appearance::Light);
554 assert!(light.paints_tokens());
555 let dark = Caps::detect_with(env(&[("CODEWHALE_APPEARANCE", "dark")]), || {
556 Appearance::Unknown
557 });
558 assert_eq!(dark.appearance, Appearance::Dark);
559 let junk = Caps::detect_with(env(&[("CODEWHALE_APPEARANCE", "sepia")]), || {
560 Appearance::Unknown
561 });
562 assert_eq!(junk.appearance, Appearance::Unknown);
563 }
564
565 /// Profiles the snapshots do not render separately, because they must
566 /// look exactly like one that is rendered.
567 #[test]
568 fn unsnapshotted_profiles_match_their_twins() {
569 let dark16 = theme(ColorDepth::Ansi16, Appearance::Dark);
570 let light16 = theme(ColorDepth::Ansi16, Appearance::Light);
571 let unknown256 = theme(ColorDepth::Ansi256, Appearance::Unknown);
572 for role in Role::ALL {
573 assert_eq!(light16.fg(role), dark16.fg(role), "{role:?}");
574 assert_eq!(light16.bg(role), Style::default(), "{role:?}");
575 assert_eq!(unknown256.fg(role), dark16.fg(role), "{role:?}");
576 assert_eq!(unknown256.bg(role), Style::default(), "{role:?}");
577 }
578 }
579
580 /// Grounds that quantize to one color at 256 colors. Each one needs an
581 /// edge or a mark wherever a component relies on the difference; a new
582 /// collapse fails here so it gets that review.
583 #[test]
584 fn collapsed_grounds_are_known() {
585 let grounds = [
586 Role::Sidebar,
587 Role::Background,
588 Role::Surface,
589 Role::Hover,
590 Role::Selected,
591 ];
592 let mut collapsed = Vec::new();
593 for appearance in [Appearance::Dark, Appearance::Light] {
594 let t = theme(ColorDepth::Ansi256, appearance);
595 for (i, a) in grounds.iter().enumerate() {
596 for b in &grounds[i + 1..] {
597 if !t.grounds_differ(*a, *b) {
598 collapsed.push(format!("{appearance:?} {a:?}={b:?}"));
599 }
600 }
601 }
602 }
603 assert_eq!(
604 collapsed,
605 ["Dark Surface=Hover", "Light Background=Surface"],
606 "Panel edges raised cards where Surface=Background; nothing paints Hover yet"
607 );
608 }
609
610 /// Contrast audit at the two depths that paint token colors, with the
611 /// design's own floors (`generate.py`): every text role clears WCAG AA
612 /// (4.5:1) on every ground it can sit on, and the strong border 3:1.
613 #[test]
614 fn contrast_holds_at_truecolor_and_ansi256() {
615 let grounds = [
616 Role::Background,
617 Role::Surface,
618 Role::Sidebar,
619 Role::Hover,
620 Role::Selected,
621 ];
622 let mut failures = Vec::new();
623 for (appearance, ground) in [
624 (Appearance::Dark, Ground::Ocean),
625 (Appearance::Dark, Ground::Graphite),
626 (Appearance::Light, Ground::Ocean),
627 ] {
628 for depth in [ColorDepth::TrueColor, ColorDepth::Ansi256] {
629 let t = theme(depth, appearance).ground(ground);
630 for ground in grounds {
631 let bg = t.color(ground).unwrap();
632 for (ink, floor) in [
633 (Role::Foreground, 4.5),
634 (Role::Muted, 4.5),
635 (Role::Primary, 4.5),
636 (Role::Live, 4.5),
637 (Role::Attention, 4.5),
638 (Role::Danger, 4.5),
639 (Role::Hint, 4.5),
640 (Role::Dim, 3.0),
641 (Role::BorderStrong, 3.0),
642 ] {
643 let ratio = contrast_ratio(t.color(ink).unwrap(), bg).unwrap();
644 if ratio < floor {
645 failures.push(format!(
646 "{appearance:?} {depth:?} {ink:?} on {ground:?} ({:?}): {ratio:.2} < {floor}",
647 t.ground_kind()
648 ));
649 }
650 }
651 }
652 // Diff tints sit behind body ink and their own state hue;
653 // line numbers (`Muted`) only need the 3:1 the 256-color
654 // cube can give a green.
655 for (tint, hue) in [
656 (Role::DiffAddedTint, Role::Live),
657 (Role::DiffRemovedTint, Role::Danger),
658 ] {
659 let bg = t.color(tint).unwrap();
660 for (ink, floor) in [(Role::Foreground, 4.5), (hue, 4.5), (Role::Muted, 3.0)] {
661 let ratio = contrast_ratio(t.color(ink).unwrap(), bg).unwrap();
662 if ratio < floor {
663 failures.push(format!(
664 "{appearance:?} {depth:?} {ink:?} on {tint:?} ({:?}): {ratio:.2} < {floor}",
665 t.ground_kind()
666 ));
667 }
668 }
669 }
670 let on_primary = contrast_ratio(
671 t.color(Role::PrimaryForeground).unwrap(),
672 t.color(Role::Primary).unwrap(),
673 )
674 .unwrap();
675 if on_primary < 4.5 {
676 failures.push(format!(
677 "{appearance:?} {depth:?} PrimaryForeground on Primary: {on_primary:.2} < 4.5"
678 ));
679 }
680 }
681 }
682 assert!(
683 failures.is_empty(),
684 "contrast failures:\n{}",
685 failures.join("\n")
686 );
687 }
688
689 /// Foreground, Muted, Hint and Dim step down against every ground, in
690 /// every appearance and depth that paints token colors, and no two of
691 /// them (or the strong border) show the same color.
692 #[test]
693 fn the_ink_ladder_steps_down() {
694 let ladder = [Role::Foreground, Role::Muted, Role::Hint, Role::Dim];
695 for (appearance, ground) in [
696 (Appearance::Dark, Ground::Ocean),
697 (Appearance::Dark, Ground::Graphite),
698 (Appearance::Light, Ground::Ocean),
699 ] {
700 for depth in [ColorDepth::TrueColor, ColorDepth::Ansi256] {
701 let t = theme(depth, appearance).ground(ground);
702 for ground in [Role::Background, Role::Surface, Role::Selected] {
703 let bg = t.color(ground).unwrap();
704 let ratios: Vec<f32> = ladder
705 .iter()
706 .map(|r| contrast_ratio(t.color(*r).unwrap(), bg).unwrap())
707 .collect();
708 assert!(
709 ratios.windows(2).all(|w| w[0] > w[1]),
710 "{appearance:?} {depth:?} on {ground:?}: {ratios:?}"
711 );
712 }
713 let inks = [
714 Role::Foreground,
715 Role::Muted,
716 Role::Hint,
717 Role::Dim,
718 Role::BorderStrong,
719 ];
720 for (i, a) in inks.iter().enumerate() {
721 for b in &inks[i + 1..] {
722 if depth == ColorDepth::Ansi256
723 || (*a, *b) != (Role::Dim, Role::BorderStrong)
724 {
725 assert_ne!(
726 t.color(*a),
727 t.color(*b),
728 "{appearance:?} {depth:?}: {a:?} and {b:?} look the same"
729 );
730 }
731 }
732 }
733 }
734 }
735 }
736
737 /// The diff tints are grounds that paint only where grounds paint, and
738 /// they differ from every other ground, so an added line never reads as
739 /// a selected row.
740 #[test]
741 fn diff_tints_are_distinct_grounds_that_drop_where_grounds_cannot_paint() {
742 let others = [
743 Role::Sidebar,
744 Role::Background,
745 Role::Surface,
746 Role::Hover,
747 Role::Selected,
748 ];
749 for appearance in [Appearance::Dark, Appearance::Light] {
750 for depth in [ColorDepth::TrueColor, ColorDepth::Ansi256] {
751 let t = theme(depth, appearance);
752 for tint in [Role::DiffAddedTint, Role::DiffRemovedTint] {
753 assert!(tint.is_ground() && tint.is_content_tint() && tint.is_derived());
754 for other in others {
755 assert!(
756 t.grounds_differ(tint, other),
757 "{appearance:?} {depth:?}: {tint:?} looks like {other:?}"
758 );
759 }
760 }
761 assert!(t.grounds_differ(Role::DiffAddedTint, Role::DiffRemovedTint));
762 }
763 for depth in [ColorDepth::Ansi16, ColorDepth::Monochrome] {
764 let t = theme(depth, appearance);
765 for tint in [Role::DiffAddedTint, Role::DiffRemovedTint] {
766 assert_eq!(t.bg(tint), Style::default(), "{appearance:?} {depth:?}");
767 }
768 }
769 }
770 let unknown = theme(ColorDepth::TrueColor, Appearance::Unknown);
771 assert_eq!(unknown.bg(Role::DiffAddedTint), Style::default());
772 }
773
774 /// Without color, the quiet inks recede by `DIM`, as `Muted` does.
775 #[test]
776 fn hint_and_dim_recede_without_color() {
777 for depth in [ColorDepth::Ansi16, ColorDepth::Monochrome] {
778 let t = theme(depth, Appearance::Dark);
779 for role in [Role::Muted, Role::Hint, Role::Dim] {
780 assert!(
781 t.fg(role).add_modifier.contains(Modifier::DIM),
782 "{depth:?} {role:?}"
783 );
784 }
785 }
786 }
787
788 #[test]
789 fn eighteen_roles_four_of_them_derived() {
790 assert_eq!(Role::COUNT, 18);
791 assert_eq!(Role::ALL.len(), 18);
792 let derived: Vec<Role> = Role::ALL.into_iter().filter(|r| r.is_derived()).collect();
793 assert_eq!(
794 derived,
795 [
796 Role::Hint,
797 Role::Dim,
798 Role::DiffAddedTint,
799 Role::DiffRemovedTint
800 ]
801 );
802 for role in Role::ALL {
803 assert_eq!(role.token_name().is_none(), role.is_derived(), "{role:?}");
804 }
805 }
806 }
807
807 lines RUST