Skip to main content

sqlly_datatable/grid/
theme.rs

1//! `GridTheme` — typed color set used by the widget. Default is monochrome on
2//! white; downstream code that wants a dark mode or accent palette can
3//! construct a custom theme and pass it on the [`crate::grid::GridState`].
4
5use gpui::{Hsla, WindowAppearance};
6
7#[derive(Clone, Debug, PartialEq)]
8pub struct GridTheme {
9    pub bg: Hsla,
10    pub header_bg: Hsla,
11    pub filter_bg: Hsla,
12    pub filter_active_bg: Hsla,
13    pub row_header_bg: Hsla,
14    pub selection_bg: Hsla,
15    pub alt_row_bg: Hsla,
16    pub grid_line: Hsla,
17    pub header_fg: Hsla,
18    pub text_fg: Hsla,
19    pub negative_fg: Hsla,
20    pub sort_indicator: Hsla,
21    pub filter_cursor: Hsla,
22    /// Background fill of the right-click context menu / filter popup surface.
23    pub menu_bg: Hsla,
24    /// Fill drawn behind the menu item currently under the pointer (hover).
25    pub menu_hover_bg: Hsla,
26    /// Foreground color for menu item labels.
27    pub menu_fg: Hsla,
28    /// Muted text color for labels, placeholders, and secondary text inside
29    /// the filter panel and context menu. Chosen for legibility against
30    /// `menu_bg` / `bg` in both light and dark palettes.
31    pub muted_text: Hsla,
32    /// Foreground for the null-value placeholder (see
33    /// [`crate::config::NullFormat`]).
34    pub null_fg: Hsla,
35    /// Distinctive background painted behind null-value cells when the
36    /// column's [`crate::config::NullFormat::background`] is enabled.
37    pub null_bg: Hsla,
38    /// Background of pivot group-header rows (expanded groups).
39    pub pivot_group_bg: Hsla,
40    /// Background of pivot subtotal cells (collapsed groups, "Total"
41    /// columns).
42    pub pivot_subtotal_bg: Hsla,
43    /// Background of the pivot grand-total row/column.
44    pub pivot_grand_total_bg: Hsla,
45    /// Foreground for pivot subtotal / grand-total values and labels.
46    pub pivot_total_fg: Hsla,
47    /// Resting background of a sidebar drop zone.
48    pub pivot_drop_zone_bg: Hsla,
49    /// Background of a drop zone while a compatible chip hovers over it.
50    pub pivot_drop_zone_active_bg: Hsla,
51    /// Background of a field chip in the pivot sidebar.
52    pub pivot_chip_bg: Hsla,
53    /// Label color of a field chip in the pivot sidebar.
54    pub pivot_chip_fg: Hsla,
55}
56
57impl Default for GridTheme {
58    fn default() -> Self {
59        Self {
60            bg: hsla(0.0, 0.0, 1.0, 1.0),
61            header_bg: hsla(0.0, 0.0, 0.93, 1.0),
62            filter_bg: hsla(0.0, 0.0, 0.96, 1.0),
63            filter_active_bg: hsla(0.58, 0.30, 0.85, 1.0),
64            row_header_bg: hsla(0.0, 0.0, 0.90, 1.0),
65            selection_bg: hsla(0.58, 0.50, 0.80, 0.50),
66            alt_row_bg: hsla(0.0, 0.0, 0.95, 1.0),
67            grid_line: hsla(0.0, 0.0, 0.85, 1.0),
68            header_fg: hsla(0.0, 0.0, 0.15, 1.0),
69            text_fg: hsla(0.0, 0.0, 0.1, 1.0),
70            negative_fg: hsla(0.0, 0.75, 0.45, 1.0),
71            sort_indicator: hsla(0.58, 0.50, 0.40, 1.0),
72            filter_cursor: hsla(0.0, 0.0, 0.1, 1.0),
73            menu_bg: hsla(0.0, 0.0, 1.0, 1.0),
74            menu_hover_bg: hsla(0.58, 0.45, 0.85, 1.0),
75            menu_fg: hsla(0.0, 0.0, 0.1, 1.0),
76            muted_text: hsla(0.0, 0.0, 0.5, 1.0),
77            null_fg: hsla(0.0, 0.0, 0.45, 1.0),
78            null_bg: hsla(0.13, 0.55, 0.90, 1.0),
79            pivot_group_bg: hsla(0.58, 0.20, 0.92, 1.0),
80            pivot_subtotal_bg: hsla(0.58, 0.15, 0.88, 1.0),
81            pivot_grand_total_bg: hsla(0.58, 0.25, 0.82, 1.0),
82            pivot_total_fg: hsla(0.0, 0.0, 0.08, 1.0),
83            pivot_drop_zone_bg: hsla(0.0, 0.0, 0.96, 1.0),
84            pivot_drop_zone_active_bg: hsla(0.58, 0.40, 0.88, 1.0),
85            pivot_chip_bg: hsla(0.58, 0.30, 0.90, 1.0),
86            pivot_chip_fg: hsla(0.0, 0.0, 0.12, 1.0),
87        }
88    }
89}
90
91fn hsla(h: f32, s: f32, l: f32, a: f32) -> Hsla {
92    Hsla { h, s, l, a }
93}
94
95impl GridTheme {
96    /// The light palette. Identical to [`GridTheme::default`]; provided as a
97    /// named constructor so callers can be explicit about intent.
98    #[must_use]
99    pub fn light() -> Self {
100        Self::default()
101    }
102
103    /// A dark palette tuned to pair with the light one: light text on dark
104    /// surfaces, matching accent hue (0.58) for selection/sort/menu-hover.
105    #[must_use]
106    pub fn dark() -> Self {
107        Self {
108            bg: hsla(0.0, 0.0, 0.12, 1.0),
109            header_bg: hsla(0.0, 0.0, 0.18, 1.0),
110            filter_bg: hsla(0.0, 0.0, 0.15, 1.0),
111            filter_active_bg: hsla(0.58, 0.40, 0.30, 1.0),
112            row_header_bg: hsla(0.0, 0.0, 0.16, 1.0),
113            selection_bg: hsla(0.58, 0.50, 0.45, 0.50),
114            alt_row_bg: hsla(0.0, 0.0, 0.15, 1.0),
115            grid_line: hsla(0.0, 0.0, 0.28, 1.0),
116            header_fg: hsla(0.0, 0.0, 0.80, 1.0),
117            text_fg: hsla(0.0, 0.0, 0.90, 1.0),
118            negative_fg: hsla(0.0, 0.70, 0.62, 1.0),
119            sort_indicator: hsla(0.58, 0.60, 0.68, 1.0),
120            filter_cursor: hsla(0.0, 0.0, 0.90, 1.0),
121            menu_bg: hsla(0.0, 0.0, 0.16, 1.0),
122            menu_hover_bg: hsla(0.58, 0.45, 0.38, 1.0),
123            menu_fg: hsla(0.0, 0.0, 0.90, 1.0),
124            muted_text: hsla(0.0, 0.0, 0.55, 1.0),
125            null_fg: hsla(0.0, 0.0, 0.60, 1.0),
126            null_bg: hsla(0.13, 0.35, 0.22, 1.0),
127            pivot_group_bg: hsla(0.58, 0.20, 0.20, 1.0),
128            pivot_subtotal_bg: hsla(0.58, 0.18, 0.24, 1.0),
129            pivot_grand_total_bg: hsla(0.58, 0.28, 0.30, 1.0),
130            pivot_total_fg: hsla(0.0, 0.0, 0.95, 1.0),
131            pivot_drop_zone_bg: hsla(0.0, 0.0, 0.15, 1.0),
132            pivot_drop_zone_active_bg: hsla(0.58, 0.40, 0.32, 1.0),
133            pivot_chip_bg: hsla(0.58, 0.35, 0.28, 1.0),
134            pivot_chip_fg: hsla(0.0, 0.0, 0.92, 1.0),
135        }
136    }
137
138    /// Pick the palette that matches the OS window appearance. `Dark` and
139    /// `VibrantDark` resolve to [`GridTheme::dark`]; everything else to
140    /// [`GridTheme::light`].
141    #[must_use]
142    pub fn for_appearance(appearance: WindowAppearance) -> Self {
143        match appearance {
144            WindowAppearance::Dark | WindowAppearance::VibrantDark => Self::dark(),
145            WindowAppearance::Light | WindowAppearance::VibrantLight => Self::light(),
146        }
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    /// The context menu must be paintable from the theme (not a hardcoded
155    /// color), and its hover fill must be visually distinct from the menu
156    /// background so a mouse-over state is actually perceivable. The label
157    /// color must also contrast with the background. This guards the
158    /// dark/light theming + hover-state regression.
159    #[test]
160    fn default_theme_exposes_distinct_menu_colors() {
161        let t = GridTheme::default();
162        // Menu surface must be opaque so it fully covers content beneath it.
163        assert_eq!(t.menu_bg.a, 1.0, "menu background must be opaque");
164        // Hover fill must differ from the surface, else hover is invisible.
165        assert_ne!(
166            t.menu_hover_bg, t.menu_bg,
167            "menu hover fill must differ from the menu background"
168        );
169        // Label color must differ from the surface for legible text.
170        assert_ne!(
171            t.menu_fg, t.menu_bg,
172            "menu label color must contrast with the menu background"
173        );
174    }
175
176    /// `light()` must equal the default palette, and `dark()` must be a
177    /// genuinely different, legible palette (dark surface, light text). This
178    /// guards the OS light/dark following.
179    #[test]
180    fn light_matches_default_and_dark_differs() {
181        assert_eq!(
182            GridTheme::light().bg,
183            GridTheme::default().bg,
184            "light() must alias the default palette"
185        );
186        let dark = GridTheme::dark();
187        assert_ne!(dark.bg, GridTheme::light().bg, "dark bg must differ");
188        // Dark surface should be darker than its text (light-on-dark).
189        assert!(
190            dark.bg.l < dark.text_fg.l,
191            "dark theme must be light text on a dark surface"
192        );
193        assert_eq!(dark.menu_bg.a, 1.0, "dark menu background must be opaque");
194        assert_ne!(
195            dark.menu_hover_bg, dark.menu_bg,
196            "dark menu hover fill must differ from the menu background"
197        );
198    }
199
200    /// Pivot surfaces must be mutually distinguishable and legible in both
201    /// palettes: totals must stand out from ordinary groups, drop-zone
202    /// hover must differ from its resting state, and total text must
203    /// contrast with the total background.
204    #[test]
205    fn pivot_surfaces_are_distinct_in_both_palettes() {
206        for t in [GridTheme::light(), GridTheme::dark()] {
207            assert_ne!(t.pivot_grand_total_bg, t.pivot_group_bg);
208            assert_ne!(t.pivot_grand_total_bg, t.pivot_subtotal_bg);
209            assert_ne!(t.pivot_total_fg, t.pivot_grand_total_bg);
210            assert_ne!(t.pivot_drop_zone_active_bg, t.pivot_drop_zone_bg);
211            assert_ne!(t.pivot_chip_fg, t.pivot_chip_bg);
212        }
213    }
214
215    /// `for_appearance` must map the two dark variants to the dark palette and
216    /// the two light variants to the light palette.
217    #[test]
218    fn for_appearance_maps_dark_and_light_variants() {
219        assert_eq!(
220            GridTheme::for_appearance(WindowAppearance::Dark).bg,
221            GridTheme::dark().bg
222        );
223        assert_eq!(
224            GridTheme::for_appearance(WindowAppearance::VibrantDark).bg,
225            GridTheme::dark().bg
226        );
227        assert_eq!(
228            GridTheme::for_appearance(WindowAppearance::Light).bg,
229            GridTheme::light().bg
230        );
231        assert_eq!(
232            GridTheme::for_appearance(WindowAppearance::VibrantLight).bg,
233            GridTheme::light().bg
234        );
235    }
236}