1use gpui::{Hsla, WindowAppearance};
22
23#[derive(Clone, Debug, PartialEq)]
24pub struct GridTheme {
25 pub bg: Hsla,
26 pub header_bg: Hsla,
27 pub filter_bg: Hsla,
28 pub filter_active_bg: Hsla,
29 pub row_header_bg: Hsla,
30 pub selection_bg: Hsla,
31 pub alt_row_bg: Hsla,
32 pub grid_line: Hsla,
33 pub header_fg: Hsla,
34 pub text_fg: Hsla,
35 pub negative_fg: Hsla,
36 pub sort_indicator: Hsla,
37 pub filter_cursor: Hsla,
38 pub menu_bg: Hsla,
40 pub menu_hover_bg: Hsla,
42 pub menu_fg: Hsla,
44 pub muted_text: Hsla,
48 pub null_fg: Hsla,
51 pub null_bg: Hsla,
54 pub pivot_group_bg: Hsla,
56 pub pivot_subtotal_bg: Hsla,
59 pub pivot_grand_total_bg: Hsla,
61 pub pivot_total_fg: Hsla,
63 pub pivot_drop_zone_bg: Hsla,
65 pub pivot_drop_zone_active_bg: Hsla,
67 pub pivot_chip_bg: Hsla,
69 pub pivot_chip_fg: Hsla,
71 pub scrollbar_thumb: Hsla,
74 pub overlay_scrim: Hsla,
78}
79
80impl Default for GridTheme {
81 fn default() -> Self {
82 Self::neutral_light()
83 }
84}
85
86fn hsla(h: f32, s: f32, l: f32, a: f32) -> Hsla {
87 Hsla { h, s, l, a }
88}
89
90#[derive(Clone, Debug, PartialEq)]
94pub struct GridThemePair {
95 pub light: GridTheme,
96 pub dark: GridTheme,
97}
98
99impl Default for GridThemePair {
100 fn default() -> Self {
101 Self::neutral()
102 }
103}
104
105impl GridThemePair {
106 #[must_use]
108 pub fn neutral() -> Self {
109 Self {
110 light: GridTheme::neutral_light(),
111 dark: GridTheme::neutral_dark(),
112 }
113 }
114
115 #[must_use]
118 pub fn signature() -> Self {
119 Self {
120 light: GridTheme::signature_light(),
121 dark: GridTheme::signature_dark(),
122 }
123 }
124
125 #[must_use]
128 pub fn for_appearance(&self, appearance: WindowAppearance) -> GridTheme {
129 match appearance {
130 WindowAppearance::Dark | WindowAppearance::VibrantDark => self.dark.clone(),
131 WindowAppearance::Light | WindowAppearance::VibrantLight => self.light.clone(),
132 }
133 }
134}
135
136impl GridTheme {
137 #[must_use]
140 pub fn neutral_light() -> Self {
141 Self {
142 bg: hsla(0.0, 0.0, 1.0, 1.0), header_bg: hsla(0.0, 0.0, 0.941, 1.0), filter_bg: hsla(0.0, 0.0, 0.941, 1.0), filter_active_bg: hsla(0.5778, 1.0, 0.8778, 1.0), row_header_bg: hsla(0.0, 0.0, 0.9215, 1.0), selection_bg: hsla(0.5786, 1.0, 0.8972, 1.0), alt_row_bg: hsla(0.0, 0.0, 0.9306, 1.0), grid_line: hsla(0.0, 0.0, 0.8634, 1.0), header_fg: hsla(0.0, 0.0, 0.1599, 1.0), text_fg: hsla(0.0, 0.0, 0.0476, 1.0), negative_fg: hsla(0.9991, 0.7103, 0.4152, 1.0), sort_indicator: hsla(0.5763, 1.0, 0.3224, 1.0), filter_cursor: hsla(0.0, 0.0, 0.0476, 1.0), menu_bg: hsla(0.0, 0.0, 1.0, 1.0), menu_hover_bg: hsla(0.5798, 1.0, 0.9166, 1.0), menu_fg: hsla(0.0, 0.0, 0.0476, 1.0), muted_text: hsla(0.0, 0.0, 0.3447, 1.0), null_fg: hsla(0.1215, 0.0996, 0.3291, 1.0), null_bg: hsla(0.1324, 0.7485, 0.9213, 1.0), pivot_group_bg: hsla(0.5863, 0.7823, 0.9366, 1.0), pivot_subtotal_bg: hsla(0.5862, 0.724, 0.9011, 1.0), pivot_grand_total_bg: hsla(0.5858, 0.7776, 0.8434, 1.0), pivot_total_fg: hsla(0.585, 0.4177, 0.0724, 1.0), pivot_drop_zone_bg: hsla(0.0, 0.0, 0.954, 1.0), pivot_drop_zone_active_bg: hsla(0.5756, 1.0, 0.9004, 1.0), pivot_chip_bg: hsla(0.5798, 1.0, 0.9166, 1.0), pivot_chip_fg: hsla(0.5844, 0.4223, 0.1374, 1.0), scrollbar_thumb: hsla(0.0, 0.0, 0.5021, 1.0), overlay_scrim: hsla(0.0, 0.0, 0.0, 0.35),
171 }
172 }
173
174 #[must_use]
177 pub fn neutral_dark() -> Self {
178 Self {
179 bg: hsla(0.0, 0.0, 0.0817, 1.0), header_bg: hsla(0.0, 0.0, 0.1409, 1.0), filter_bg: hsla(0.0, 0.0, 0.1409, 1.0), filter_active_bg: hsla(0.583, 0.4915, 0.2685, 1.0), row_header_bg: hsla(0.0, 0.0, 0.1268, 1.0), selection_bg: hsla(0.5823, 0.5446, 0.2618, 1.0), alt_row_bg: hsla(0.0, 0.0, 0.1362, 1.0), grid_line: hsla(0.0, 0.0, 0.2089, 1.0), header_fg: hsla(0.0, 0.0, 0.806, 1.0), text_fg: hsla(0.0, 0.0, 0.9085, 1.0), negative_fg: hsla(0.0076, 0.8612, 0.6827, 1.0), sort_indicator: hsla(0.5751, 0.8029, 0.6722, 1.0), filter_cursor: hsla(0.0, 0.0, 0.9085, 1.0), menu_bg: hsla(0.0, 0.0, 0.1315, 1.0), menu_hover_bg: hsla(0.5834, 0.4685, 0.2583, 1.0), menu_fg: hsla(0.0, 0.0, 0.9085, 1.0), muted_text: hsla(0.0, 0.0, 0.6326, 1.0), null_fg: hsla(0.132, 0.2549, 0.6858, 1.0), null_bg: hsla(0.1318, 0.3896, 0.1459, 1.0), pivot_group_bg: hsla(0.5853, 0.2639, 0.1806, 1.0), pivot_subtotal_bg: hsla(0.5847, 0.3421, 0.2171, 1.0), pivot_grand_total_bg: hsla(0.584, 0.3998, 0.2911, 1.0), pivot_total_fg: hsla(0.0, 0.0, 0.941, 1.0), pivot_drop_zone_bg: hsla(0.0, 0.0, 0.1268, 1.0), pivot_drop_zone_active_bg: hsla(0.5835, 0.4628, 0.2375, 1.0), pivot_chip_bg: hsla(0.584, 0.4202, 0.2379, 1.0), pivot_chip_fg: hsla(0.0, 0.0, 0.9345, 1.0), scrollbar_thumb: hsla(0.0, 0.0, 0.4447, 1.0), overlay_scrim: hsla(0.0, 0.0, 0.0, 0.45),
208 }
209 }
210
211 #[must_use]
215 pub fn signature_light() -> Self {
216 Self {
217 bg: hsla(0.0, 0.0, 1.0, 1.0), header_bg: hsla(0.4955, 0.2665, 0.917, 1.0), filter_bg: hsla(0.4955, 0.2665, 0.917, 1.0), filter_active_bg: hsla(0.4978, 0.5936, 0.7861, 1.0), row_header_bg: hsla(0.4956, 0.2469, 0.8956, 1.0), selection_bg: hsla(0.497, 0.5728, 0.8399, 1.0), alt_row_bg: hsla(0.4953, 0.1854, 0.9207, 1.0), grid_line: hsla(0.4957, 0.1778, 0.8359, 1.0), header_fg: hsla(0.5, 0.6775, 0.1233, 1.0), text_fg: hsla(0.4982, 0.397, 0.0491, 1.0), negative_fg: hsla(0.015, 0.7339, 0.4008, 1.0), sort_indicator: hsla(0.502, 1.0, 0.217, 1.0), filter_cursor: hsla(0.4982, 0.397, 0.0491, 1.0), menu_bg: hsla(0.0, 0.0, 1.0, 1.0), menu_hover_bg: hsla(0.4968, 0.5969, 0.8663, 1.0), menu_fg: hsla(0.4982, 0.397, 0.0491, 1.0), muted_text: hsla(0.4975, 0.1546, 0.3177, 1.0), null_fg: hsla(0.1119, 0.1325, 0.3271, 1.0), null_bg: hsla(0.1176, 0.7869, 0.9237, 1.0), pivot_group_bg: hsla(0.4962, 0.5099, 0.8971, 1.0), pivot_subtotal_bg: hsla(0.4969, 0.5164, 0.8345, 1.0), pivot_grand_total_bg: hsla(0.4983, 0.5319, 0.7204, 1.0), pivot_total_fg: hsla(0.5035, 1.0, 0.0689, 1.0), pivot_drop_zone_bg: hsla(0.4953, 0.2775, 0.9468, 1.0), pivot_drop_zone_active_bg: hsla(0.4974, 0.6519, 0.8275, 1.0), pivot_chip_bg: hsla(0.4973, 0.5837, 0.8185, 1.0), pivot_chip_fg: hsla(0.503, 1.0, 0.1024, 1.0), scrollbar_thumb: hsla(0.4975, 0.1513, 0.4634, 1.0), overlay_scrim: hsla(0.5019, 1.0, 0.0092, 0.35), }
247 }
248
249 #[must_use]
252 pub fn signature_dark() -> Self {
253 Self {
254 bg: hsla(0.4974, 0.2284, 0.0687, 1.0), header_bg: hsla(0.4977, 0.2102, 0.1219, 1.0), filter_bg: hsla(0.4977, 0.2102, 0.1219, 1.0), filter_active_bg: hsla(0.5016, 1.0, 0.1547, 1.0), row_header_bg: hsla(0.4976, 0.2111, 0.1053, 1.0), selection_bg: hsla(0.5016, 1.0, 0.1547, 1.0), alt_row_bg: hsla(0.4974, 0.1857, 0.1210, 1.0), grid_line: hsla(0.4975, 0.1743, 0.1906, 1.0), header_fg: hsla(0.4959, 0.1709, 0.7876, 1.0), text_fg: hsla(0.4953, 0.1483, 0.9013, 1.0), negative_fg: hsla(0.0092, 0.8161, 0.6929, 1.0), sort_indicator: hsla(0.5, 0.5555, 0.5112, 1.0), filter_cursor: hsla(0.4953, 0.1483, 0.9013, 1.0), menu_bg: hsla(0.4975, 0.1954, 0.1145, 1.0), menu_hover_bg: hsla(0.5011, 1.0, 0.1462, 1.0), menu_fg: hsla(0.4953, 0.1483, 0.9013, 1.0), muted_text: hsla(0.4965, 0.1256, 0.6067, 1.0), null_fg: hsla(0.1175, 0.3234, 0.6993, 1.0), null_bg: hsla(0.1183, 0.3763, 0.1477, 1.0), pivot_group_bg: hsla(0.4992, 0.4103, 0.1392, 1.0), pivot_subtotal_bg: hsla(0.5, 0.6646, 0.1444, 1.0), pivot_grand_total_bg: hsla(0.5005, 1.0, 0.164, 1.0), pivot_total_fg: hsla(0.4952, 0.1907, 0.9421, 1.0), pivot_drop_zone_bg: hsla(0.4975, 0.2029, 0.1099, 1.0), pivot_drop_zone_active_bg: hsla(0.5009, 1.0, 0.1335, 1.0), pivot_chip_bg: hsla(0.5013, 1.0, 0.1383, 1.0), pivot_chip_fg: hsla(0.4954, 0.2469, 0.9254, 1.0), scrollbar_thumb: hsla(0.4967, 0.0942, 0.4236, 1.0), overlay_scrim: hsla(0.5019, 1.0, 0.0011, 0.45), }
284 }
285
286 #[must_use]
290 pub fn light() -> Self {
291 Self::neutral_light()
292 }
293
294 #[must_use]
296 pub fn dark() -> Self {
297 Self::neutral_dark()
298 }
299
300 #[must_use]
306 pub fn for_appearance(appearance: WindowAppearance) -> Self {
307 GridThemePair::neutral().for_appearance(appearance)
308 }
309}
310
311#[cfg(test)]
312mod tests {
313 use super::*;
314
315 fn all_palettes() -> [(&'static str, GridTheme); 4] {
316 [
317 ("neutral_light", GridTheme::neutral_light()),
318 ("neutral_dark", GridTheme::neutral_dark()),
319 ("signature_light", GridTheme::signature_light()),
320 ("signature_dark", GridTheme::signature_dark()),
321 ]
322 }
323
324 #[test]
331 fn every_palette_exposes_distinct_menu_colors() {
332 for (name, t) in all_palettes() {
333 assert_eq!(t.menu_bg.a, 1.0, "{name}: menu background must be opaque");
335 assert_ne!(
336 t.menu_hover_bg, t.menu_bg,
337 "{name}: menu hover fill must differ from the menu background"
338 );
339 assert_ne!(
340 t.menu_fg, t.menu_bg,
341 "{name}: menu label color must contrast with the menu background"
342 );
343 }
344 }
345
346 #[test]
351 fn aliases_and_dark_variants_hold() {
352 assert_eq!(
353 GridTheme::light(),
354 GridTheme::default(),
355 "light() must alias the default palette"
356 );
357 assert_eq!(GridTheme::light(), GridTheme::neutral_light());
358 assert_eq!(GridTheme::dark(), GridTheme::neutral_dark());
359 for (name, t) in [
360 ("neutral_dark", GridTheme::neutral_dark()),
361 ("signature_dark", GridTheme::signature_dark()),
362 ] {
363 assert!(
364 t.bg.l < t.text_fg.l,
365 "{name} must be light text on a dark surface"
366 );
367 }
368 for (name, t) in all_palettes() {
369 assert_eq!(t.bg.a, 1.0, "{name}: grid background must be opaque");
370 assert_eq!(t.selection_bg.a, 1.0, "{name}: selection must be opaque");
371 }
372 }
373
374 #[test]
379 fn pivot_surfaces_are_distinct_in_every_palette() {
380 for (name, t) in all_palettes() {
381 assert_ne!(t.pivot_grand_total_bg, t.pivot_group_bg, "{name}");
382 assert_ne!(t.pivot_grand_total_bg, t.pivot_subtotal_bg, "{name}");
383 assert_ne!(t.pivot_total_fg, t.pivot_grand_total_bg, "{name}");
384 assert_ne!(t.pivot_drop_zone_active_bg, t.pivot_drop_zone_bg, "{name}");
385 assert_ne!(t.pivot_chip_fg, t.pivot_chip_bg, "{name}");
386 }
387 }
388
389 #[test]
393 fn for_appearance_maps_dark_and_light_variants() {
394 assert_eq!(
395 GridTheme::for_appearance(WindowAppearance::Dark).bg,
396 GridTheme::dark().bg
397 );
398 assert_eq!(
399 GridTheme::for_appearance(WindowAppearance::VibrantDark).bg,
400 GridTheme::dark().bg
401 );
402 assert_eq!(
403 GridTheme::for_appearance(WindowAppearance::Light).bg,
404 GridTheme::light().bg
405 );
406 assert_eq!(
407 GridTheme::for_appearance(WindowAppearance::VibrantLight).bg,
408 GridTheme::light().bg
409 );
410 let sig = GridThemePair::signature();
411 assert_eq!(
412 sig.for_appearance(WindowAppearance::Dark).bg,
413 GridTheme::signature_dark().bg
414 );
415 assert_eq!(
416 sig.for_appearance(WindowAppearance::VibrantLight).bg,
417 GridTheme::signature_light().bg
418 );
419 }
420}
421
422#[cfg(test)]
427mod wcag {
428 use super::*;
429
430 fn relative_luminance(c: Hsla) -> f32 {
434 let (h, s, l) = (c.h, c.s, c.l);
436 let q = if l < 0.5 {
437 l * (1.0 + s)
438 } else {
439 l + s - l * s
440 };
441 let p = 2.0 * l - q;
442 let hue = |mut t: f32| -> f32 {
443 if t < 0.0 {
444 t += 1.0;
445 }
446 if t > 1.0 {
447 t -= 1.0;
448 }
449 if t < 1.0 / 6.0 {
450 p + (q - p) * 6.0 * t
451 } else if t < 0.5 {
452 q
453 } else if t < 2.0 / 3.0 {
454 p + (q - p) * (2.0 / 3.0 - t) * 6.0
455 } else {
456 p
457 }
458 };
459 let (r, g, b) = (hue(h + 1.0 / 3.0), hue(h), hue(h - 1.0 / 3.0));
460 let lin = |u: f32| -> f32 {
462 if u <= 0.04045 {
463 u / 12.92
464 } else {
465 ((u + 0.055) / 1.055).powf(2.4)
466 }
467 };
468 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b)
469 }
470
471 fn contrast(a: Hsla, b: Hsla) -> f32 {
472 let (la, lb) = (relative_luminance(a), relative_luminance(b));
473 let (hi, lo) = if la > lb { (la, lb) } else { (lb, la) };
474 (hi + 0.05) / (lo + 0.05)
475 }
476
477 fn requirements(t: &GridTheme) -> Vec<(Hsla, Hsla, f32, &'static str)> {
479 vec![
480 (t.text_fg, t.bg, 7.0, "body text on bg"),
481 (t.text_fg, t.alt_row_bg, 7.0, "body text on zebra row"),
482 (t.text_fg, t.selection_bg, 4.5, "body text on selection"),
483 (t.text_fg, t.row_header_bg, 4.5, "pivot leaf label"),
484 (t.text_fg, t.header_bg, 4.5, "status bar text"),
485 (t.text_fg, t.filter_active_bg, 4.5, "filter text"),
486 (t.header_fg, t.header_bg, 4.5, "column labels"),
487 (t.header_fg, t.row_header_bg, 4.5, "row numbers"),
488 (t.header_fg, t.selection_bg, 4.5, "selected header label"),
489 (
490 t.header_fg,
491 t.pivot_grand_total_bg,
492 4.5,
493 "pivot header on total bg",
494 ),
495 (t.muted_text, t.bg, 4.5, "placeholder text on bg"),
496 (t.muted_text, t.menu_bg, 4.5, "menu secondary text"),
497 (t.muted_text, t.header_bg, 4.5, "sidebar header hint"),
498 (t.muted_text, t.pivot_drop_zone_bg, 4.5, "drop zone hint"),
499 (t.negative_fg, t.bg, 4.5, "negative numbers on bg"),
500 (
501 t.negative_fg,
502 t.alt_row_bg,
503 4.5,
504 "negative numbers on zebra",
505 ),
506 (
507 t.negative_fg,
508 t.selection_bg,
509 3.0,
510 "negative on selection (parentheses carry the channel too)",
511 ),
512 (t.negative_fg, t.pivot_grand_total_bg, 3.0, "negative total"),
513 (t.menu_fg, t.menu_bg, 7.0, "menu labels"),
514 (t.menu_fg, t.menu_hover_bg, 4.5, "hovered menu label"),
515 (t.menu_fg, t.header_bg, 4.5, "source field chip label"),
516 (
517 t.menu_fg,
518 t.pivot_drop_zone_bg,
519 4.5,
520 "pivot menu on zone bg",
521 ),
522 (t.null_fg, t.bg, 4.5, "null placeholder on bg"),
523 (t.null_fg, t.null_bg, 4.5, "null placeholder on null bg"),
524 (t.null_fg, t.alt_row_bg, 4.5, "null placeholder on zebra"),
525 (
526 t.pivot_total_fg,
527 t.pivot_group_bg,
528 7.0,
529 "group header label",
530 ),
531 (
532 t.pivot_total_fg,
533 t.pivot_subtotal_bg,
534 4.5,
535 "subtotal values",
536 ),
537 (
538 t.pivot_total_fg,
539 t.pivot_grand_total_bg,
540 4.5,
541 "grand total values",
542 ),
543 (t.pivot_chip_fg, t.pivot_chip_bg, 4.5, "chip labels"),
544 (t.bg, t.sort_indicator, 3.0, "checkbox knockout check"),
545 (t.sort_indicator, t.header_bg, 3.0, "sort glyph"),
546 (t.sort_indicator, t.menu_bg, 4.5, "sidebar sort glyph"),
547 (t.sort_indicator, t.bg, 3.0, "grouped-column underline"),
548 (t.filter_cursor, t.filter_active_bg, 4.5, "filter cursor"),
549 (t.scrollbar_thumb, t.row_header_bg, 3.0, "thumb vs track"),
550 ]
551 }
552
553 fn distinctness(t: &GridTheme) -> Vec<(Hsla, Hsla, f32, &'static str)> {
555 vec![
556 (t.selection_bg, t.bg, 1.1, "selection visible on bg"),
557 (t.alt_row_bg, t.bg, 1.12, "zebra perceptible"),
561 (t.menu_hover_bg, t.menu_bg, 1.1, "menu hover visible"),
562 (
563 t.pivot_drop_zone_active_bg,
564 t.pivot_drop_zone_bg,
565 1.1,
566 "drop-zone hover visible",
567 ),
568 (t.grid_line, t.bg, 1.1, "grid lines visible"),
569 (
570 t.pivot_grand_total_bg,
571 t.pivot_subtotal_bg,
572 1.05,
573 "totals hierarchy readable",
574 ),
575 ]
576 }
577
578 #[test]
579 fn every_palette_meets_wcag_contrast() {
580 for (name, theme) in [
581 ("neutral_light", GridTheme::neutral_light()),
582 ("neutral_dark", GridTheme::neutral_dark()),
583 ("signature_light", GridTheme::signature_light()),
584 ("signature_dark", GridTheme::signature_dark()),
585 ] {
586 for (fg, bg, min, what) in requirements(&theme).into_iter().chain(distinctness(&theme))
587 {
588 let ratio = contrast(fg, bg);
589 assert!(
590 ratio >= min,
591 "{name}: {what} — contrast {ratio:.2} below required {min}"
592 );
593 }
594 }
595 }
596}