Skip to main content

oxiui_table/
theme_integration.rs

1//! `oxiui-theme` integration for `oxiui-table`.
2//!
3//! Provides [`TableTheme`] — a colour token set derived from
4//! `oxiui_theme::DesignTokens` and `oxiui_core::Palette` that encodes the
5//! visual appearance of the table without coupling the core table logic to
6//! any specific renderer.
7//!
8//! Renderers (egui, iced, soft, wgpu) can call `TableTheme::from_palette`
9//! or `TableTheme::from_tokens` (both require the `theme-table` feature) to
10//! obtain a backend-agnostic colour description, then map the `[u8; 4]` RGBA
11//! values to their native colour types.
12//!
13//! # Usage
14//!
15//! ```rust
16//! use oxiui_table::theme_integration::TableTheme;
17//!
18//! let theme = TableTheme::default();
19//! // Header background: semi-transparent primary colour.
20//! let [r, g, b, a] = theme.header_bg;
21//! assert!(a > 0);
22//! ```
23//!
24//! # Feature flag
25//!
26//! The `TableTheme::from_palette` and `TableTheme::from_tokens`
27//! constructors are only available when the `theme-table` feature is enabled
28//! (which gates `oxiui-theme`).  The struct itself and the `Default` impl are
29//! always compiled so that downstream code does not need conditional
30//! compilation at the usage site.
31
32#[cfg(feature = "theme-table")]
33use oxiui_core::Palette;
34#[cfg(feature = "theme-table")]
35use oxiui_theme::tokens::DesignTokens;
36
37// ── TableTheme ────────────────────────────────────────────────────────────────
38
39/// Colour tokens for the table widget, expressed as RGBA `[u8; 4]` arrays.
40///
41/// All colours use pre-multiplied-alpha conventions: `a == 0` means
42/// fully transparent, `a == 255` means fully opaque.
43///
44/// The defaults correspond to the COOLJAPAN Tokyo Night dark palette.
45#[derive(Clone, Debug, PartialEq)]
46pub struct TableTheme {
47    /// Background colour of the column header row.
48    pub header_bg: [u8; 4],
49    /// Foreground (text) colour of the column header row.
50    pub header_fg: [u8; 4],
51    /// Background colour for data rows (even rows when zebra striping is off).
52    pub row_bg: [u8; 4],
53    /// Background colour for alternate (odd) rows in zebra-striping mode.
54    pub row_stripe_bg: [u8; 4],
55    /// Background colour for the currently selected / highlighted row.
56    pub selection_bg: [u8; 4],
57    /// Foreground (text) colour for the currently selected row.
58    pub selection_fg: [u8; 4],
59    /// Border / grid-line colour between rows and columns.
60    pub border_color: [u8; 4],
61    /// Background colour of the cell that holds keyboard focus.
62    pub focus_ring_color: [u8; 4],
63    /// Default foreground (text) colour for all data cells.
64    pub cell_fg: [u8; 4],
65    /// Background colour for the footer (aggregate) row, if present.
66    pub footer_bg: [u8; 4],
67    /// Foreground (text) colour for the footer row.
68    pub footer_fg: [u8; 4],
69    /// Cell horizontal padding derived from the spacing scale (logical pixels).
70    pub cell_padding_x: f32,
71    /// Cell vertical padding derived from the spacing scale (logical pixels).
72    pub cell_padding_y: f32,
73    /// Border radius for the focus ring (logical pixels).
74    pub focus_radius: f32,
75}
76
77impl Default for TableTheme {
78    /// COOLJAPAN Tokyo Night dark defaults.
79    ///
80    /// These values are hard-coded so the struct is usable without the
81    /// `theme-table` feature.  When the feature is enabled, prefer
82    /// `TableTheme::from_palette` to derive colours from the active theme.
83    fn default() -> Self {
84        Self {
85            // Header: #24283B surface colour, fully opaque text.
86            header_bg: [36, 40, 59, 255],
87            header_fg: [192, 202, 245, 255], // #C0CAF5
88            // Rows: dark background (#1A1B26), slightly lighter surface (#1F2035).
89            row_bg: [26, 27, 38, 255],
90            row_stripe_bg: [31, 32, 53, 255],
91            // Selection: primary #7AA2F7 at 30 % opacity over a dark base.
92            selection_bg: [122, 162, 247, 77], // alpha = 0.30 * 255 ≈ 77
93            selection_fg: [255, 255, 255, 255],
94            // Subtle borders.
95            border_color: [86, 95, 137, 128], // muted at 50 % alpha
96            // Focus ring: primary colour, 50 % alpha.
97            focus_ring_color: [122, 162, 247, 128],
98            // Regular cell text.
99            cell_fg: [192, 202, 245, 255],
100            // Footer: same as header.
101            footer_bg: [36, 40, 59, 255],
102            footer_fg: [192, 202, 245, 200],
103            // Spacing defaults (4-px grid).
104            cell_padding_x: 8.0,
105            cell_padding_y: 4.0,
106            focus_radius: 2.0,
107        }
108    }
109}
110
111impl TableTheme {
112    /// Build a `TableTheme` from a [`Palette`] and optional [`DesignTokens`].
113    ///
114    /// When the `theme-table` feature is **disabled** this constructor is
115    /// unavailable; use [`TableTheme::default`] instead.
116    #[cfg(feature = "theme-table")]
117    pub fn from_palette(palette: &Palette, tokens: Option<&DesignTokens>) -> Self {
118        use oxiui_theme::color::darken;
119
120        // Derive zebra-stripe colour by darkening the base background slightly.
121        let stripe_color = darken(palette.background, 0.05);
122
123        // Selection background: primary colour at 30 % alpha.
124        let sel_a = (0.30_f32 * 255.0_f32).round() as u8;
125        let selection_bg = [
126            palette.primary.0,
127            palette.primary.1,
128            palette.primary.2,
129            sel_a,
130        ];
131
132        // Border: muted colour at 50 % alpha.
133        let border_color = [palette.muted.0, palette.muted.1, palette.muted.2, 128];
134
135        // Focus ring: primary colour at 50 % alpha.
136        let focus_ring_color = [palette.primary.0, palette.primary.1, palette.primary.2, 128];
137
138        // Footer: surface colour, slightly dimmed text.
139        let footer_fg = [palette.text.0, palette.text.1, palette.text.2, 200];
140
141        // Spacing from tokens if provided, otherwise the 4-px grid defaults.
142        let (cell_padding_x, cell_padding_y, focus_radius) = if let Some(t) = tokens {
143            use oxiui_theme::tokens::{RadiusStep, SpacingStep};
144            (
145                t.spacing(SpacingStep::Sm), // 8 px
146                t.spacing(SpacingStep::Xs), // 4 px
147                t.radius(RadiusStep::Sm),   // 2 px
148            )
149        } else {
150            (8.0, 4.0, 2.0)
151        };
152
153        Self {
154            header_bg: [palette.surface.0, palette.surface.1, palette.surface.2, 255],
155            header_fg: [palette.text.0, palette.text.1, palette.text.2, 255],
156            row_bg: [
157                palette.background.0,
158                palette.background.1,
159                palette.background.2,
160                255,
161            ],
162            row_stripe_bg: [stripe_color.0, stripe_color.1, stripe_color.2, 255],
163            selection_bg,
164            selection_fg: [
165                palette.on_primary.0,
166                palette.on_primary.1,
167                palette.on_primary.2,
168                255,
169            ],
170            border_color,
171            focus_ring_color,
172            cell_fg: [palette.text.0, palette.text.1, palette.text.2, 255],
173            footer_bg: [palette.surface.0, palette.surface.1, palette.surface.2, 255],
174            footer_fg,
175            cell_padding_x,
176            cell_padding_y,
177            focus_radius,
178        }
179    }
180
181    /// Build a `TableTheme` from a [`DesignTokens`] alone, falling back to the
182    /// COOLJAPAN default palette colours.
183    ///
184    /// Only available when the `theme-table` feature is enabled.
185    #[cfg(feature = "theme-table")]
186    pub fn from_tokens(tokens: &DesignTokens) -> Self {
187        use oxiui_core::Theme;
188        use oxiui_core::{Color, FontSpec, Palette};
189        use oxiui_theme::CooljapanTheme;
190
191        // Instantiate the default COOLJAPAN dark theme to obtain its palette.
192        let theme = CooljapanTheme::new(
193            Palette {
194                background: Color(26, 27, 38, 255),
195                surface: Color(36, 40, 59, 255),
196                primary: Color(122, 162, 247, 255),
197                on_primary: Color(26, 27, 38, 255),
198                text: Color(192, 202, 245, 255),
199                muted: Color(86, 95, 137, 255),
200            },
201            FontSpec::new("Inter", 14.0, 400),
202        );
203        Self::from_palette(theme.palette(), Some(tokens))
204    }
205
206    /// Whether the colour scheme is perceived as dark (background luminance < 0.5).
207    ///
208    /// Useful for selecting icon variants or blend modes in renderers.
209    pub fn is_dark(&self) -> bool {
210        let [r, g, b, _] = self.row_bg;
211        // WCAG relative luminance approximation (not gamma-correct but fast).
212        let luma =
213            0.2126 * (r as f32 / 255.0) + 0.7152 * (g as f32 / 255.0) + 0.0722 * (b as f32 / 255.0);
214        luma < 0.5
215    }
216
217    /// Apply an alpha blend: mix the theme's `selection_bg` over `row_bg` for
218    /// a given `is_selected` flag.
219    ///
220    /// Returns the resulting RGBA colour for the row background.
221    pub fn effective_row_bg(&self, row_index: usize, is_selected: bool, zebra: bool) -> [u8; 4] {
222        if is_selected {
223            // Blend selection over the base row colour.
224            let base = if zebra && row_index % 2 == 1 {
225                self.row_stripe_bg
226            } else {
227                self.row_bg
228            };
229            alpha_blend(self.selection_bg, base)
230        } else if zebra && row_index % 2 == 1 {
231            self.row_stripe_bg
232        } else {
233            self.row_bg
234        }
235    }
236}
237
238/// Alpha-blend `src` (with `src.a` alpha) over `dst` (opaque).
239///
240/// Result is fully opaque.  Uses integer arithmetic to avoid floating-point.
241fn alpha_blend(src: [u8; 4], dst: [u8; 4]) -> [u8; 4] {
242    let a = src[3] as u32;
243    let ia = 255 - a;
244    let blend = |s: u8, d: u8| -> u8 {
245        let v = a * s as u32 + ia * d as u32;
246        ((v + 127) / 255) as u8
247    };
248    [
249        blend(src[0], dst[0]),
250        blend(src[1], dst[1]),
251        blend(src[2], dst[2]),
252        255,
253    ]
254}
255
256// ── Tests ─────────────────────────────────────────────────────────────────────
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    #[test]
263    fn default_theme_is_dark() {
264        assert!(TableTheme::default().is_dark());
265    }
266
267    #[test]
268    fn default_header_bg_is_surface() {
269        let theme = TableTheme::default();
270        // Surface = #24283B = (36, 40, 59)
271        assert_eq!(theme.header_bg[0], 36);
272        assert_eq!(theme.header_bg[1], 40);
273        assert_eq!(theme.header_bg[2], 59);
274        assert_eq!(theme.header_bg[3], 255);
275    }
276
277    #[test]
278    fn effective_row_bg_normal() {
279        let theme = TableTheme::default();
280        let bg = theme.effective_row_bg(0, false, false);
281        assert_eq!(bg, theme.row_bg);
282    }
283
284    #[test]
285    fn effective_row_bg_zebra_odd() {
286        let theme = TableTheme::default();
287        let bg = theme.effective_row_bg(1, false, true);
288        assert_eq!(bg, theme.row_stripe_bg);
289    }
290
291    #[test]
292    fn effective_row_bg_zebra_even() {
293        let theme = TableTheme::default();
294        let bg = theme.effective_row_bg(0, false, true);
295        assert_eq!(bg, theme.row_bg);
296    }
297
298    #[test]
299    fn effective_row_bg_selected_is_blended() {
300        let theme = TableTheme::default();
301        let bg = theme.effective_row_bg(0, true, false);
302        // The selection_bg has alpha=77; the result must differ from both the
303        // raw row_bg and the raw selection_bg (it's a blend).
304        assert_ne!(bg, theme.row_bg);
305        // Result must be fully opaque.
306        assert_eq!(bg[3], 255);
307    }
308
309    #[test]
310    fn alpha_blend_fully_transparent_is_dst() {
311        let src = [100, 150, 200, 0]; // fully transparent
312        let dst = [10, 20, 30, 255];
313        let result = alpha_blend(src, dst);
314        // Transparent src → dst unchanged (modulo rounding).
315        assert!((result[0] as i32 - dst[0] as i32).abs() <= 1);
316        assert!((result[1] as i32 - dst[1] as i32).abs() <= 1);
317        assert!((result[2] as i32 - dst[2] as i32).abs() <= 1);
318    }
319
320    #[test]
321    fn alpha_blend_fully_opaque_is_src() {
322        let src = [100, 150, 200, 255]; // fully opaque
323        let dst = [10, 20, 30, 255];
324        let result = alpha_blend(src, dst);
325        assert_eq!(result[0], 100);
326        assert_eq!(result[1], 150);
327        assert_eq!(result[2], 200);
328        assert_eq!(result[3], 255);
329    }
330
331    #[test]
332    fn selection_bg_has_partial_alpha() {
333        // The default selection_bg must have partial alpha so blending works.
334        let theme = TableTheme::default();
335        let a = theme.selection_bg[3];
336        assert!(
337            a > 0 && a < 255,
338            "selection_bg alpha should be partial, got {a}"
339        );
340    }
341
342    #[test]
343    fn cell_padding_positive() {
344        let theme = TableTheme::default();
345        assert!(theme.cell_padding_x > 0.0);
346        assert!(theme.cell_padding_y > 0.0);
347    }
348
349    #[test]
350    fn focus_radius_non_negative() {
351        let theme = TableTheme::default();
352        assert!(theme.focus_radius >= 0.0);
353    }
354
355    #[cfg(feature = "theme-table")]
356    #[test]
357    fn from_tokens_returns_valid_theme() {
358        use oxiui_theme::tokens::DesignTokens;
359        let tokens = DesignTokens::default();
360        let theme = TableTheme::from_tokens(&tokens);
361        // Cell padding must match the Sm/Xs spacing steps (8.0 / 4.0).
362        assert!((theme.cell_padding_x - 8.0).abs() < f32::EPSILON);
363        assert!((theme.cell_padding_y - 4.0).abs() < f32::EPSILON);
364    }
365
366    #[cfg(feature = "theme-table")]
367    #[test]
368    fn from_palette_header_bg_is_surface() {
369        use oxiui_core::{Color, Palette};
370        let palette = Palette {
371            background: Color(10, 10, 10, 255),
372            surface: Color(30, 30, 30, 255),
373            primary: Color(100, 150, 200, 255),
374            on_primary: Color(0, 0, 0, 255),
375            text: Color(220, 220, 220, 255),
376            muted: Color(80, 80, 80, 255),
377        };
378        let theme = TableTheme::from_palette(&palette, None);
379        assert_eq!(theme.header_bg[0], 30);
380        assert_eq!(theme.header_bg[1], 30);
381        assert_eq!(theme.header_bg[2], 30);
382    }
383
384    #[cfg(feature = "theme-table")]
385    #[test]
386    fn from_palette_selection_has_partial_alpha() {
387        use oxiui_core::{Color, Palette};
388        let palette = Palette {
389            background: Color(10, 10, 10, 255),
390            surface: Color(30, 30, 30, 255),
391            primary: Color(100, 150, 200, 255),
392            on_primary: Color(0, 0, 0, 255),
393            text: Color(220, 220, 220, 255),
394            muted: Color(80, 80, 80, 255),
395        };
396        let theme = TableTheme::from_palette(&palette, None);
397        let a = theme.selection_bg[3];
398        assert!(a > 0 && a < 255, "selection alpha must be partial, got {a}");
399    }
400}