Skip to main content

sqlly_datatable/grid/
theme.rs

1//! `GridTheme` — typed color set used by the widget, plus the two shipped
2//! theme families.
3//!
4//! Two complete families ship with the crate, each with a light and a dark
5//! variant that follows the OS window appearance:
6//!
7//! - **Neutral** ([`GridThemePair::neutral`]) — chroma-free surfaces with a
8//!   restrained azure accent. Blends into a host application; the default.
9//! - **Signature** ([`GridThemePair::signature`]) — the crate's own look,
10//!   built around a teal anchor (`oklch(0.47 0.115 195)`): tinted neutrals and
11//!   a committed accent carrying selection, chips, and totals.
12//!
13//! Every color is a public field, so downstream code can construct a fully
14//! custom theme (or derive one from a host app's palette) and pass it on the
15//! [`crate::grid::GridState`] or via the widget builder. All shipped palettes
16//! are designed in OKLCH (noted per field) and converted to `Hsla`; text
17//! roles meet WCAG AA contrast (≥ 4.5:1) against every surface they are
18//! painted on — see the `wcag` test module at the bottom of this file, which
19//! verifies the full matrix for all four palettes.
20
21use 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    /// Background fill of the right-click context menu / filter popup surface.
39    pub menu_bg: Hsla,
40    /// Fill drawn behind the menu item currently under the pointer (hover).
41    pub menu_hover_bg: Hsla,
42    /// Foreground color for menu item labels.
43    pub menu_fg: Hsla,
44    /// Muted text color for labels, placeholders, and secondary text inside
45    /// the filter panel and context menu. Chosen for legibility against
46    /// `menu_bg` / `bg` in both light and dark palettes.
47    pub muted_text: Hsla,
48    /// Foreground for the null-value placeholder (see
49    /// [`crate::config::NullFormat`]).
50    pub null_fg: Hsla,
51    /// Distinctive background painted behind null-value cells when the
52    /// column's [`crate::config::NullFormat::background`] is enabled.
53    pub null_bg: Hsla,
54    /// Background of pivot group-header rows (expanded groups).
55    pub pivot_group_bg: Hsla,
56    /// Background of pivot subtotal cells (collapsed groups, "Total"
57    /// columns).
58    pub pivot_subtotal_bg: Hsla,
59    /// Background of the pivot grand-total row/column.
60    pub pivot_grand_total_bg: Hsla,
61    /// Foreground for pivot subtotal / grand-total values and labels.
62    pub pivot_total_fg: Hsla,
63    /// Resting background of a sidebar drop zone.
64    pub pivot_drop_zone_bg: Hsla,
65    /// Background of a drop zone while a compatible chip hovers over it.
66    pub pivot_drop_zone_active_bg: Hsla,
67    /// Background of a field chip in the pivot sidebar.
68    pub pivot_chip_bg: Hsla,
69    /// Label color of a field chip in the pivot sidebar.
70    pub pivot_chip_fg: Hsla,
71    /// Fill of the scrollbar thumb (the track uses `row_header_bg`). Kept at
72    /// ≥ 3:1 contrast against the track in the shipped palettes.
73    pub scrollbar_thumb: Hsla,
74    /// Translucent scrim painted behind modal overlays (e.g. the pivot
75    /// format dialog) to dim the content beneath. The only intentionally
76    /// non-opaque color in the theme.
77    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/// A light/dark pair forming one theme family. [`GridThemePair::for_appearance`]
91/// picks the variant matching the OS window appearance, so a host app can
92/// supply its own pair and keep automatic light/dark following.
93#[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    /// The Neutral family: chroma-free surfaces, one restrained azure accent.
107    #[must_use]
108    pub fn neutral() -> Self {
109        Self {
110            light: GridTheme::neutral_light(),
111            dark: GridTheme::neutral_dark(),
112        }
113    }
114
115    /// The Signature family: teal-anchored tinted neutrals with a committed
116    /// accent.
117    #[must_use]
118    pub fn signature() -> Self {
119        Self {
120            light: GridTheme::signature_light(),
121            dark: GridTheme::signature_dark(),
122        }
123    }
124
125    /// A family derived from `gpui-component`'s built-in light and dark
126    /// palettes via [`GridTheme::from_component_colors`], so the grid matches
127    /// hosts styled with `gpui-component` defaults while keeping automatic
128    /// light/dark following. Hosts running a *custom* component theme should
129    /// instead derive from their own palettes, or from the active theme with
130    /// [`GridTheme::from_component_theme`].
131    ///
132    /// Unlike [`GridThemePair::neutral`] and [`GridThemePair::signature`],
133    /// the component palettes carry no WCAG contrast guarantee from this
134    /// crate.
135    #[must_use]
136    pub fn component() -> Self {
137        Self {
138            light: GridTheme::from_component_colors(&gpui_component::ThemeColor::light(), false),
139            dark: GridTheme::from_component_colors(&gpui_component::ThemeColor::dark(), true),
140        }
141    }
142
143    /// Pick the variant that matches the OS window appearance. `Dark` and
144    /// `VibrantDark` resolve to `self.dark`; everything else to `self.light`.
145    #[must_use]
146    pub fn for_appearance(&self, appearance: WindowAppearance) -> GridTheme {
147        match appearance {
148            WindowAppearance::Dark | WindowAppearance::VibrantDark => self.dark.clone(),
149            WindowAppearance::Light | WindowAppearance::VibrantLight => self.light.clone(),
150        }
151    }
152}
153
154impl GridTheme {
155    /// Derive a `GridTheme` from the active [`gpui_component::Theme`], so the
156    /// grid picks up the exact surfaces, borders, and accents of a host app
157    /// built on `gpui-component`. Reads the theme resolved for the current
158    /// light/dark mode; hosts that switch modes at runtime should re-derive
159    /// and re-apply on change (e.g. from the same place they call
160    /// [`gpui_component::Theme::change`]).
161    ///
162    /// The mapping leans on the component theme's dedicated `table_*` role
163    /// colors for the grid chrome and its `popover`/`list` roles for menus,
164    /// so the result matches what `gpui-component`'s own `Table` would look
165    /// like in the host theme.
166    ///
167    /// ```no_run
168    /// use gpui_component::ActiveTheme as _;
169    /// # fn derive(cx: &mut gpui::App) -> sqlly_datatable::GridTheme {
170    /// sqlly_datatable::GridTheme::from_component_theme(cx.theme())
171    /// # }
172    /// ```
173    #[must_use]
174    pub fn from_component_theme(theme: &gpui_component::Theme) -> Self {
175        Self::from_component_colors(&theme.colors, theme.is_dark())
176    }
177
178    /// The [`GridTheme::from_component_theme`] mapping applied to an explicit
179    /// [`gpui_component::ThemeColor`] set. Useful for building a
180    /// [`GridThemePair`] from a component theme's light and dark palettes
181    /// (see [`GridThemePair::component`]).
182    #[must_use]
183    pub fn from_component_colors(colors: &gpui_component::ThemeColor, is_dark: bool) -> Self {
184        Self {
185            bg: colors.table,
186            header_bg: colors.table_head,
187            filter_bg: colors.table_head,
188            filter_active_bg: colors.accent,
189            row_header_bg: colors.table_head,
190            selection_bg: colors.selection,
191            alt_row_bg: colors.table_even,
192            grid_line: colors.table_row_border,
193            header_fg: colors.table_head_foreground,
194            text_fg: colors.foreground,
195            negative_fg: colors.danger,
196            sort_indicator: colors.primary,
197            filter_cursor: colors.caret,
198            menu_bg: colors.popover,
199            menu_hover_bg: colors.list_hover,
200            menu_fg: colors.popover_foreground,
201            muted_text: colors.muted_foreground,
202            null_fg: colors.muted_foreground,
203            null_bg: colors.muted,
204            pivot_group_bg: colors.accent,
205            pivot_subtotal_bg: colors.secondary,
206            pivot_grand_total_bg: colors.selection,
207            pivot_total_fg: colors.foreground,
208            pivot_drop_zone_bg: colors.muted,
209            pivot_drop_zone_active_bg: colors.drop_target,
210            pivot_chip_bg: colors.secondary,
211            pivot_chip_fg: colors.secondary_foreground,
212            scrollbar_thumb: colors.scrollbar_thumb,
213            overlay_scrim: if is_dark {
214                hsla(0.0, 0.0, 0.0, 0.45)
215            } else {
216                hsla(0.0, 0.0, 0.0, 0.35)
217            },
218        }
219    }
220
221    /// Neutral light: pure-white canvas, gray ramp at zero chroma, azure
222    /// accent reserved for selection, sort, and filter state.
223    #[must_use]
224    pub fn neutral_light() -> Self {
225        Self {
226            bg: hsla(0.0, 0.0, 1.0, 1.0),                            // oklch(1 0 0)
227            header_bg: hsla(0.0, 0.0, 0.941, 1.0),                   // oklch(0.955 0 0)
228            filter_bg: hsla(0.0, 0.0, 0.941, 1.0),                   // oklch(0.955 0 0)
229            filter_active_bg: hsla(0.5778, 1.0, 0.8778, 1.0),        // oklch(0.90 0.06 250)
230            row_header_bg: hsla(0.0, 0.0, 0.9215, 1.0),              // oklch(0.94 0 0)
231            selection_bg: hsla(0.5786, 1.0, 0.8972, 1.0),            // oklch(0.915 0.05 250)
232            alt_row_bg: hsla(0.0, 0.0, 0.9306, 1.0),                 // oklch(0.947 0 0)
233            grid_line: hsla(0.0, 0.0, 0.8634, 1.0),                  // oklch(0.895 0 0)
234            header_fg: hsla(0.0, 0.0, 0.1599, 1.0),                  // oklch(0.28 0 0)
235            text_fg: hsla(0.0, 0.0, 0.0476, 1.0),                    // oklch(0.155 0 0)
236            negative_fg: hsla(0.9991, 0.7103, 0.4152, 1.0),          // oklch(0.50 0.185 27)
237            sort_indicator: hsla(0.5763, 1.0, 0.3224, 1.0),          // oklch(0.46 0.145 250)
238            filter_cursor: hsla(0.0, 0.0, 0.0476, 1.0),              // oklch(0.155 0 0)
239            menu_bg: hsla(0.0, 0.0, 1.0, 1.0),                       // oklch(1 0 0)
240            menu_hover_bg: hsla(0.5798, 1.0, 0.9166, 1.0),           // oklch(0.93 0.04 250)
241            menu_fg: hsla(0.0, 0.0, 0.0476, 1.0),                    // oklch(0.155 0 0)
242            muted_text: hsla(0.0, 0.0, 0.3447, 1.0),                 // oklch(0.46 0 0)
243            null_fg: hsla(0.1215, 0.0996, 0.3291, 1.0),              // oklch(0.46 0.02 90)
244            null_bg: hsla(0.1324, 0.7485, 0.9213, 1.0),              // oklch(0.965 0.032 95)
245            pivot_group_bg: hsla(0.5863, 0.7823, 0.9366, 1.0),       // oklch(0.945 0.022 250)
246            pivot_subtotal_bg: hsla(0.5862, 0.724, 0.9011, 1.0),     // oklch(0.915 0.032 250)
247            pivot_grand_total_bg: hsla(0.5858, 0.7776, 0.8434, 1.0), // oklch(0.865 0.055 250)
248            pivot_total_fg: hsla(0.585, 0.4177, 0.0724, 1.0),        // oklch(0.18 0.02 250)
249            pivot_drop_zone_bg: hsla(0.0, 0.0, 0.954, 1.0),          // oklch(0.965 0 0)
250            pivot_drop_zone_active_bg: hsla(0.5756, 1.0, 0.9004, 1.0), // oklch(0.92 0.05 250)
251            pivot_chip_bg: hsla(0.5798, 1.0, 0.9166, 1.0),           // oklch(0.93 0.04 250)
252            pivot_chip_fg: hsla(0.5844, 0.4223, 0.1374, 1.0),        // oklch(0.25 0.035 250)
253            scrollbar_thumb: hsla(0.0, 0.0, 0.5021, 1.0),            // oklch(0.60 0 0)
254            overlay_scrim: hsla(0.0, 0.0, 0.0, 0.35),
255        }
256    }
257
258    /// Neutral dark: near-black gray ramp; depth comes from surface
259    /// lightness, accents are slightly desaturated to sit on dark.
260    #[must_use]
261    pub fn neutral_dark() -> Self {
262        Self {
263            bg: hsla(0.0, 0.0, 0.0817, 1.0),        // oklch(0.195 0 0)
264            header_bg: hsla(0.0, 0.0, 0.1409, 1.0), // oklch(0.26 0 0)
265            filter_bg: hsla(0.0, 0.0, 0.1409, 1.0), // oklch(0.26 0 0)
266            filter_active_bg: hsla(0.583, 0.4915, 0.2685, 1.0), // oklch(0.38 0.07 250)
267            row_header_bg: hsla(0.0, 0.0, 0.1268, 1.0), // oklch(0.245 0 0)
268            selection_bg: hsla(0.5823, 0.5446, 0.2618, 1.0), // oklch(0.375 0.075 250)
269            alt_row_bg: hsla(0.0, 0.0, 0.1362, 1.0), // oklch(0.255 0 0)
270            grid_line: hsla(0.0, 0.0, 0.2089, 1.0), // oklch(0.33 0 0)
271            header_fg: hsla(0.0, 0.0, 0.806, 1.0),  // oklch(0.85 0 0)
272            text_fg: hsla(0.0, 0.0, 0.9085, 1.0),   // oklch(0.93 0 0)
273            negative_fg: hsla(0.0076, 0.8612, 0.6827, 1.0), // oklch(0.70 0.165 25)
274            sort_indicator: hsla(0.5751, 0.8029, 0.6722, 1.0), // oklch(0.74 0.115 245)
275            filter_cursor: hsla(0.0, 0.0, 0.9085, 1.0), // oklch(0.93 0 0)
276            menu_bg: hsla(0.0, 0.0, 0.1315, 1.0),   // oklch(0.25 0 0)
277            menu_hover_bg: hsla(0.5834, 0.4685, 0.2583, 1.0), // oklch(0.37 0.065 250)
278            menu_fg: hsla(0.0, 0.0, 0.9085, 1.0),   // oklch(0.93 0 0)
279            muted_text: hsla(0.0, 0.0, 0.6326, 1.0), // oklch(0.71 0 0)
280            null_fg: hsla(0.132, 0.2549, 0.6858, 1.0), // oklch(0.79 0.045 95)
281            null_bg: hsla(0.1318, 0.3896, 0.1459, 1.0), // oklch(0.30 0.038 95)
282            pivot_group_bg: hsla(0.5853, 0.2639, 0.1806, 1.0), // oklch(0.295 0.028 250)
283            pivot_subtotal_bg: hsla(0.5847, 0.3421, 0.2171, 1.0), // oklch(0.33 0.042 250)
284            pivot_grand_total_bg: hsla(0.584, 0.3998, 0.2911, 1.0), // oklch(0.40 0.062 250)
285            pivot_total_fg: hsla(0.0, 0.0, 0.941, 1.0), // oklch(0.955 0 0)
286            pivot_drop_zone_bg: hsla(0.0, 0.0, 0.1268, 1.0), // oklch(0.245 0 0)
287            pivot_drop_zone_active_bg: hsla(0.5835, 0.4628, 0.2375, 1.0), // oklch(0.35 0.06 250)
288            pivot_chip_bg: hsla(0.584, 0.4202, 0.2379, 1.0), // oklch(0.35 0.055 250)
289            pivot_chip_fg: hsla(0.0, 0.0, 0.9345, 1.0), // oklch(0.95 0 0)
290            scrollbar_thumb: hsla(0.0, 0.0, 0.4447, 1.0), // oklch(0.55 0 0)
291            overlay_scrim: hsla(0.0, 0.0, 0.0, 0.45),
292        }
293    }
294
295    /// Signature light: pure-white canvas with teal-tinted neutrals; the
296    /// teal anchor (`oklch(0.47 0.115 195)` family) carries selection,
297    /// chips, and the totals hierarchy.
298    #[must_use]
299    pub fn signature_light() -> Self {
300        Self {
301            bg: hsla(0.0, 0.0, 1.0, 1.0),                            // oklch(1 0 0)
302            header_bg: hsla(0.4955, 0.2665, 0.917, 1.0),             // oklch(0.945 0.012 195)
303            filter_bg: hsla(0.4955, 0.2665, 0.917, 1.0),             // oklch(0.945 0.012 195)
304            filter_active_bg: hsla(0.4978, 0.5936, 0.7861, 1.0),     // oklch(0.89 0.065 195)
305            row_header_bg: hsla(0.4956, 0.2469, 0.8956, 1.0),        // oklch(0.93 0.014 195)
306            selection_bg: hsla(0.497, 0.5728, 0.8399, 1.0),          // oklch(0.915 0.048 195)
307            alt_row_bg: hsla(0.4953, 0.1854, 0.9207, 1.0),           // oklch(0.945 0.008 195)
308            grid_line: hsla(0.4957, 0.1778, 0.8359, 1.0),            // oklch(0.885 0.016 195)
309            header_fg: hsla(0.5, 0.6775, 0.1233, 1.0),               // oklch(0.30 0.045 195)
310            text_fg: hsla(0.4982, 0.397, 0.0491, 1.0),               // oklch(0.17 0.015 195)
311            negative_fg: hsla(0.015, 0.7339, 0.4008, 1.0),           // oklch(0.50 0.175 30)
312            sort_indicator: hsla(0.502, 1.0, 0.217, 1.0),            // oklch(0.47 0.115 195)
313            filter_cursor: hsla(0.4982, 0.397, 0.0491, 1.0),         // oklch(0.17 0.015 195)
314            menu_bg: hsla(0.0, 0.0, 1.0, 1.0),                       // oklch(1 0 0)
315            menu_hover_bg: hsla(0.4968, 0.5969, 0.8663, 1.0),        // oklch(0.93 0.042 195)
316            menu_fg: hsla(0.4982, 0.397, 0.0491, 1.0),               // oklch(0.17 0.015 195)
317            muted_text: hsla(0.4975, 0.1546, 0.3177, 1.0),           // oklch(0.46 0.03 195)
318            null_fg: hsla(0.1119, 0.1325, 0.3271, 1.0),              // oklch(0.46 0.025 85)
319            null_bg: hsla(0.1176, 0.7869, 0.9237, 1.0),              // oklch(0.962 0.03 88)
320            pivot_group_bg: hsla(0.4962, 0.5099, 0.8971, 1.0),       // oklch(0.942 0.028 195)
321            pivot_subtotal_bg: hsla(0.4969, 0.5164, 0.8345, 1.0),    // oklch(0.908 0.045 195)
322            pivot_grand_total_bg: hsla(0.4983, 0.5319, 0.7204, 1.0), // oklch(0.85 0.075 195)
323            pivot_total_fg: hsla(0.5035, 1.0, 0.0689, 1.0),          // oklch(0.22 0.06 195)
324            pivot_drop_zone_bg: hsla(0.4953, 0.2775, 0.9468, 1.0),   // oklch(0.965 0.008 195)
325            pivot_drop_zone_active_bg: hsla(0.4974, 0.6519, 0.8275, 1.0), // oklch(0.915 0.058 195)
326            pivot_chip_bg: hsla(0.4973, 0.5837, 0.8185, 1.0),        // oklch(0.905 0.055 195)
327            pivot_chip_fg: hsla(0.503, 1.0, 0.1024, 1.0),            // oklch(0.28 0.075 195)
328            scrollbar_thumb: hsla(0.4975, 0.1513, 0.4634, 1.0),      // oklch(0.60 0.04 195)
329            overlay_scrim: hsla(0.5019, 1.0, 0.0092, 0.35),          // oklch(0.10 0.02 195)
330        }
331    }
332
333    /// Signature dark: teal-tinted near-black; surfaces keep the anchor hue
334    /// at whisper chroma, and the accent brightens to hold on dark.
335    #[must_use]
336    pub fn signature_dark() -> Self {
337        Self {
338            bg: hsla(0.4974, 0.2284, 0.0687, 1.0), // oklch(0.19 0.012 195)
339            header_bg: hsla(0.4977, 0.2102, 0.1219, 1.0), // oklch(0.255 0.018 195)
340            filter_bg: hsla(0.4977, 0.2102, 0.1219, 1.0), // oklch(0.255 0.018 195)
341            filter_active_bg: hsla(0.5016, 1.0, 0.1547, 1.0), // oklch(0.375 0.085 195)
342            row_header_bg: hsla(0.4976, 0.2111, 0.1053, 1.0), // oklch(0.235 0.016 195)
343            selection_bg: hsla(0.5016, 1.0, 0.1547, 1.0), // oklch(0.375 0.085 195)
344            alt_row_bg: hsla(0.4974, 0.1857, 0.1210, 1.0), // oklch(0.252 0.016 195)
345            grid_line: hsla(0.4975, 0.1743, 0.1906, 1.0), // oklch(0.33 0.022 195)
346            header_fg: hsla(0.4959, 0.1709, 0.7876, 1.0), // oklch(0.85 0.02 195)
347            text_fg: hsla(0.4953, 0.1483, 0.9013, 1.0), // oklch(0.93 0.008 195)
348            negative_fg: hsla(0.0092, 0.8161, 0.6929, 1.0), // oklch(0.71 0.15 25)
349            sort_indicator: hsla(0.5, 0.5555, 0.5112, 1.0), // oklch(0.76 0.115 195)
350            filter_cursor: hsla(0.4953, 0.1483, 0.9013, 1.0), // oklch(0.93 0.008 195)
351            menu_bg: hsla(0.4975, 0.1954, 0.1145, 1.0), // oklch(0.245 0.016 195)
352            menu_hover_bg: hsla(0.5011, 1.0, 0.1462, 1.0), // oklch(0.365 0.075 195)
353            menu_fg: hsla(0.4953, 0.1483, 0.9013, 1.0), // oklch(0.93 0.008 195)
354            muted_text: hsla(0.4965, 0.1256, 0.6067, 1.0), // oklch(0.71 0.028 195)
355            null_fg: hsla(0.1175, 0.3234, 0.6993, 1.0), // oklch(0.80 0.05 88)
356            null_bg: hsla(0.1183, 0.3763, 0.1477, 1.0), // oklch(0.295 0.035 88)
357            pivot_group_bg: hsla(0.4992, 0.4103, 0.1392, 1.0), // oklch(0.295 0.035 195)
358            pivot_subtotal_bg: hsla(0.5, 0.6646, 0.1444, 1.0), // oklch(0.33 0.05 195)
359            pivot_grand_total_bg: hsla(0.5005, 1.0, 0.164, 1.0), // oklch(0.40 0.072 195)
360            pivot_total_fg: hsla(0.4952, 0.1907, 0.9421, 1.0), // oklch(0.96 0.006 195)
361            pivot_drop_zone_bg: hsla(0.4975, 0.2029, 0.1099, 1.0), // oklch(0.24 0.016 195)
362            pivot_drop_zone_active_bg: hsla(0.5009, 1.0, 0.1335, 1.0), // oklch(0.345 0.068 195)
363            pivot_chip_bg: hsla(0.5013, 1.0, 0.1383, 1.0), // oklch(0.35 0.075 195)
364            pivot_chip_fg: hsla(0.4954, 0.2469, 0.9254, 1.0), // oklch(0.95 0.01 195)
365            scrollbar_thumb: hsla(0.4967, 0.0942, 0.4236, 1.0), // oklch(0.55 0.024 195)
366            overlay_scrim: hsla(0.5019, 1.0, 0.0011, 0.45), // oklch(0.05 0.01 195)
367        }
368    }
369
370    /// The Neutral light palette. Identical to [`GridTheme::default`];
371    /// provided as a named constructor so callers can be explicit about
372    /// intent.
373    #[must_use]
374    pub fn light() -> Self {
375        Self::neutral_light()
376    }
377
378    /// The Neutral dark palette, tuned to pair with [`GridTheme::light`].
379    #[must_use]
380    pub fn dark() -> Self {
381        Self::neutral_dark()
382    }
383
384    /// Pick the Neutral-family palette that matches the OS window
385    /// appearance. `Dark` and `VibrantDark` resolve to
386    /// [`GridTheme::neutral_dark`]; everything else to
387    /// [`GridTheme::neutral_light`]. For other families use
388    /// [`GridThemePair::for_appearance`].
389    #[must_use]
390    pub fn for_appearance(appearance: WindowAppearance) -> Self {
391        GridThemePair::neutral().for_appearance(appearance)
392    }
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398
399    fn all_palettes() -> [(&'static str, GridTheme); 4] {
400        [
401            ("neutral_light", GridTheme::neutral_light()),
402            ("neutral_dark", GridTheme::neutral_dark()),
403            ("signature_light", GridTheme::signature_light()),
404            ("signature_dark", GridTheme::signature_dark()),
405        ]
406    }
407
408    /// The context menu must be paintable from the theme (not a hardcoded
409    /// color), and its hover fill must be visually distinct from the menu
410    /// background so a mouse-over state is actually perceivable. The label
411    /// color must also contrast with the background. This guards the
412    /// dark/light theming + hover-state regression, for every shipped
413    /// palette.
414    #[test]
415    fn every_palette_exposes_distinct_menu_colors() {
416        for (name, t) in all_palettes() {
417            // Menu surface must be opaque so it fully covers content beneath.
418            assert_eq!(t.menu_bg.a, 1.0, "{name}: menu background must be opaque");
419            assert_ne!(
420                t.menu_hover_bg, t.menu_bg,
421                "{name}: menu hover fill must differ from the menu background"
422            );
423            assert_ne!(
424                t.menu_fg, t.menu_bg,
425                "{name}: menu label color must contrast with the menu background"
426            );
427        }
428    }
429
430    /// `light()`/`default()` must equal the Neutral light palette, `dark()`
431    /// the Neutral dark palette, and dark variants must be genuinely dark
432    /// (light text on a dark surface). This guards the OS light/dark
433    /// following and the back-compat aliases.
434    #[test]
435    fn aliases_and_dark_variants_hold() {
436        assert_eq!(
437            GridTheme::light(),
438            GridTheme::default(),
439            "light() must alias the default palette"
440        );
441        assert_eq!(GridTheme::light(), GridTheme::neutral_light());
442        assert_eq!(GridTheme::dark(), GridTheme::neutral_dark());
443        for (name, t) in [
444            ("neutral_dark", GridTheme::neutral_dark()),
445            ("signature_dark", GridTheme::signature_dark()),
446        ] {
447            assert!(
448                t.bg.l < t.text_fg.l,
449                "{name} must be light text on a dark surface"
450            );
451        }
452        for (name, t) in all_palettes() {
453            assert_eq!(t.bg.a, 1.0, "{name}: grid background must be opaque");
454            assert_eq!(t.selection_bg.a, 1.0, "{name}: selection must be opaque");
455        }
456    }
457
458    /// Pivot surfaces must be mutually distinguishable and legible in every
459    /// palette: totals must stand out from ordinary groups, drop-zone hover
460    /// must differ from its resting state, and total text must contrast
461    /// with the total background.
462    #[test]
463    fn pivot_surfaces_are_distinct_in_every_palette() {
464        for (name, t) in all_palettes() {
465            assert_ne!(t.pivot_grand_total_bg, t.pivot_group_bg, "{name}");
466            assert_ne!(t.pivot_grand_total_bg, t.pivot_subtotal_bg, "{name}");
467            assert_ne!(t.pivot_total_fg, t.pivot_grand_total_bg, "{name}");
468            assert_ne!(t.pivot_drop_zone_active_bg, t.pivot_drop_zone_bg, "{name}");
469            assert_ne!(t.pivot_chip_fg, t.pivot_chip_bg, "{name}");
470        }
471    }
472
473    /// `for_appearance` must map the two dark variants to the dark palette
474    /// and the two light variants to the light palette, on both the static
475    /// Neutral helper and an arbitrary pair.
476    #[test]
477    fn for_appearance_maps_dark_and_light_variants() {
478        assert_eq!(
479            GridTheme::for_appearance(WindowAppearance::Dark).bg,
480            GridTheme::dark().bg
481        );
482        assert_eq!(
483            GridTheme::for_appearance(WindowAppearance::VibrantDark).bg,
484            GridTheme::dark().bg
485        );
486        assert_eq!(
487            GridTheme::for_appearance(WindowAppearance::Light).bg,
488            GridTheme::light().bg
489        );
490        assert_eq!(
491            GridTheme::for_appearance(WindowAppearance::VibrantLight).bg,
492            GridTheme::light().bg
493        );
494        let sig = GridThemePair::signature();
495        assert_eq!(
496            sig.for_appearance(WindowAppearance::Dark).bg,
497            GridTheme::signature_dark().bg
498        );
499        assert_eq!(
500            sig.for_appearance(WindowAppearance::VibrantLight).bg,
501            GridTheme::signature_light().bg
502        );
503    }
504
505    /// The `gpui-component` bridge must map the toolkit's role colors onto
506    /// the grid's fields (not fall back to a shipped palette), keep the
507    /// light and dark derivations distinct, and expose them as a pair.
508    #[test]
509    fn component_bridge_maps_toolkit_roles_per_mode() {
510        let light_colors = gpui_component::ThemeColor::light();
511        let dark_colors = gpui_component::ThemeColor::dark();
512
513        let light = GridTheme::from_component_colors(&light_colors, false);
514        let dark = GridTheme::from_component_colors(&dark_colors, true);
515
516        // Spot-check the role mapping against the source palette.
517        assert_eq!(light.bg, light_colors.table);
518        assert_eq!(light.header_bg, light_colors.table_head);
519        assert_eq!(light.selection_bg, light_colors.selection);
520        assert_eq!(light.menu_bg, light_colors.popover);
521        assert_eq!(light.negative_fg, light_colors.danger);
522        assert_eq!(dark.bg, dark_colors.table);
523
524        // Light and dark derivations must actually differ.
525        assert_ne!(light.bg, dark.bg);
526        assert_ne!(light.text_fg, dark.text_fg);
527        // The dark scrim is heavier than the light one.
528        assert!(dark.overlay_scrim.a > light.overlay_scrim.a);
529
530        // The ready-made pair is exactly those two derivations.
531        let pair = GridThemePair::component();
532        assert_eq!(pair.light, light);
533        assert_eq!(pair.dark, dark);
534    }
535}
536
537/// WCAG contrast verification for the shipped palettes. Every text role is
538/// checked against every surface it is actually painted on in `paint.rs` /
539/// `sidebar.rs` / `widget.rs`, at AA thresholds (4.5:1 for text, 3:1 for
540/// UI indicators), plus perceivable-difference floors for state fills.
541#[cfg(test)]
542mod wcag {
543    use super::*;
544
545    /// Convert an `Hsla` (alpha ignored — all checked colors are opaque,
546    /// guarded by `aliases_and_dark_variants_hold`) to linear-light sRGB
547    /// relative luminance.
548    fn relative_luminance(c: Hsla) -> f32 {
549        // HSL -> sRGB
550        let (h, s, l) = (c.h, c.s, c.l);
551        let q = if l < 0.5 {
552            l * (1.0 + s)
553        } else {
554            l + s - l * s
555        };
556        let p = 2.0 * l - q;
557        let hue = |mut t: f32| -> f32 {
558            if t < 0.0 {
559                t += 1.0;
560            }
561            if t > 1.0 {
562                t -= 1.0;
563            }
564            if t < 1.0 / 6.0 {
565                p + (q - p) * 6.0 * t
566            } else if t < 0.5 {
567                q
568            } else if t < 2.0 / 3.0 {
569                p + (q - p) * (2.0 / 3.0 - t) * 6.0
570            } else {
571                p
572            }
573        };
574        let (r, g, b) = (hue(h + 1.0 / 3.0), hue(h), hue(h - 1.0 / 3.0));
575        // gamma -> linear
576        let lin = |u: f32| -> f32 {
577            if u <= 0.04045 {
578                u / 12.92
579            } else {
580                ((u + 0.055) / 1.055).powf(2.4)
581            }
582        };
583        0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b)
584    }
585
586    fn contrast(a: Hsla, b: Hsla) -> f32 {
587        let (la, lb) = (relative_luminance(a), relative_luminance(b));
588        let (hi, lo) = if la > lb { (la, lb) } else { (lb, la) };
589        (hi + 0.05) / (lo + 0.05)
590    }
591
592    /// (foreground field, background field, minimum ratio, painted where)
593    fn requirements(t: &GridTheme) -> Vec<(Hsla, Hsla, f32, &'static str)> {
594        vec![
595            (t.text_fg, t.bg, 7.0, "body text on bg"),
596            (t.text_fg, t.alt_row_bg, 7.0, "body text on zebra row"),
597            (t.text_fg, t.selection_bg, 4.5, "body text on selection"),
598            (t.text_fg, t.row_header_bg, 4.5, "pivot leaf label"),
599            (t.text_fg, t.header_bg, 4.5, "status bar text"),
600            (t.text_fg, t.filter_active_bg, 4.5, "filter text"),
601            (t.header_fg, t.header_bg, 4.5, "column labels"),
602            (t.header_fg, t.row_header_bg, 4.5, "row numbers"),
603            (t.header_fg, t.selection_bg, 4.5, "selected header label"),
604            (
605                t.header_fg,
606                t.pivot_grand_total_bg,
607                4.5,
608                "pivot header on total bg",
609            ),
610            (t.muted_text, t.bg, 4.5, "placeholder text on bg"),
611            (t.muted_text, t.menu_bg, 4.5, "menu secondary text"),
612            (t.muted_text, t.header_bg, 4.5, "sidebar header hint"),
613            (t.muted_text, t.pivot_drop_zone_bg, 4.5, "drop zone hint"),
614            (t.negative_fg, t.bg, 4.5, "negative numbers on bg"),
615            (
616                t.negative_fg,
617                t.alt_row_bg,
618                4.5,
619                "negative numbers on zebra",
620            ),
621            (
622                t.negative_fg,
623                t.selection_bg,
624                3.0,
625                "negative on selection (parentheses carry the channel too)",
626            ),
627            (t.negative_fg, t.pivot_grand_total_bg, 3.0, "negative total"),
628            (t.menu_fg, t.menu_bg, 7.0, "menu labels"),
629            (t.menu_fg, t.menu_hover_bg, 4.5, "hovered menu label"),
630            (t.menu_fg, t.header_bg, 4.5, "source field chip label"),
631            (
632                t.menu_fg,
633                t.pivot_drop_zone_bg,
634                4.5,
635                "pivot menu on zone bg",
636            ),
637            (t.null_fg, t.bg, 4.5, "null placeholder on bg"),
638            (t.null_fg, t.null_bg, 4.5, "null placeholder on null bg"),
639            (t.null_fg, t.alt_row_bg, 4.5, "null placeholder on zebra"),
640            (
641                t.pivot_total_fg,
642                t.pivot_group_bg,
643                7.0,
644                "group header label",
645            ),
646            (
647                t.pivot_total_fg,
648                t.pivot_subtotal_bg,
649                4.5,
650                "subtotal values",
651            ),
652            (
653                t.pivot_total_fg,
654                t.pivot_grand_total_bg,
655                4.5,
656                "grand total values",
657            ),
658            (t.pivot_chip_fg, t.pivot_chip_bg, 4.5, "chip labels"),
659            (t.bg, t.sort_indicator, 3.0, "checkbox knockout check"),
660            (t.sort_indicator, t.header_bg, 3.0, "sort glyph"),
661            (t.sort_indicator, t.menu_bg, 4.5, "sidebar sort glyph"),
662            (t.sort_indicator, t.bg, 3.0, "grouped-column underline"),
663            (t.filter_cursor, t.filter_active_bg, 4.5, "filter cursor"),
664            (t.scrollbar_thumb, t.row_header_bg, 3.0, "thumb vs track"),
665        ]
666    }
667
668    /// State fills must be perceivably different from what they replace.
669    fn distinctness(t: &GridTheme) -> Vec<(Hsla, Hsla, f32, &'static str)> {
670        vec![
671            (t.selection_bg, t.bg, 1.1, "selection visible on bg"),
672            // Zebra must carry row-tracking across a wide horizontal scroll,
673            // not just technically differ from the base row: hold a genuinely
674            // perceptible band (the shipped palettes sit at ~1.16).
675            (t.alt_row_bg, t.bg, 1.12, "zebra perceptible"),
676            (t.menu_hover_bg, t.menu_bg, 1.1, "menu hover visible"),
677            (
678                t.pivot_drop_zone_active_bg,
679                t.pivot_drop_zone_bg,
680                1.1,
681                "drop-zone hover visible",
682            ),
683            (t.grid_line, t.bg, 1.1, "grid lines visible"),
684            (
685                t.pivot_grand_total_bg,
686                t.pivot_subtotal_bg,
687                1.05,
688                "totals hierarchy readable",
689            ),
690        ]
691    }
692
693    #[test]
694    fn every_palette_meets_wcag_contrast() {
695        for (name, theme) in [
696            ("neutral_light", GridTheme::neutral_light()),
697            ("neutral_dark", GridTheme::neutral_dark()),
698            ("signature_light", GridTheme::signature_light()),
699            ("signature_dark", GridTheme::signature_dark()),
700        ] {
701            for (fg, bg, min, what) in requirements(&theme).into_iter().chain(distinctness(&theme))
702            {
703                let ratio = contrast(fg, bg);
704                assert!(
705                    ratio >= min,
706                    "{name}: {what} — contrast {ratio:.2} below required {min}"
707                );
708            }
709        }
710    }
711}