Skip to main content

gitkraft_gui/
theme.rs

1//! Theme helpers for GitKraft's UI.
2//!
3//! Colours are now derived from the unified `gitkraft_core::AppTheme`
4//! definitions so that both the GUI and TUI render the exact same palette for
5//! every theme.  The old `from_theme()` constructor is kept as a convenience
6//! fallback that maps an `iced::Theme` to the closest core theme index.
7
8use iced::widget::{button, container, scrollable};
9use iced::{Background, Color};
10use std::cell::RefCell;
11
12// ── ThemeColors ───────────────────────────────────────────────────────────────
13
14/// A resolved set of colours derived from the active `iced::Theme`.
15///
16/// Create one at the top of each view function with
17/// `let c = ThemeColors::from_theme(&state.theme);` and then reference
18/// `c.accent`, `c.green`, etc. instead of the old hard-coded constants.
19#[derive(Debug, Clone, Copy)]
20pub struct ThemeColors {
21    pub accent: Color,
22    pub text_primary: Color,
23    pub text_secondary: Color,
24    pub muted: Color,
25    pub bg: Color,
26    pub surface: Color,
27    pub surface_highlight: Color,
28    pub header_bg: Color,
29    pub sidebar_bg: Color,
30    pub border: Color,
31    pub selection: Color,
32    pub green: Color,
33    pub red: Color,
34    pub yellow: Color,
35    pub diff_add_bg: Color,
36    pub diff_del_bg: Color,
37    pub diff_hunk_bg: Color,
38    pub error_bg: Color,
39    pub graph_colors: [Color; 8],
40}
41
42/// Clamp a single channel to `[0.0, 1.0]`.
43fn clamp(v: f32) -> f32 {
44    v.clamp(0.0, 1.0)
45}
46
47/// Shift every RGB channel of `base` by `delta` (positive = lighter, negative = darker).
48fn shift(base: Color, delta: f32) -> Color {
49    Color {
50        r: clamp(base.r + delta),
51        g: clamp(base.g + delta),
52        b: clamp(base.b + delta),
53        a: base.a,
54    }
55}
56
57/// Scale every RGB channel of `base` by `factor`.
58#[cfg(test)]
59fn scale(base: Color, factor: f32) -> Color {
60    Color {
61        r: clamp(base.r * factor),
62        g: clamp(base.g * factor),
63        b: clamp(base.b * factor),
64        a: base.a,
65    }
66}
67
68/// Convert a core [`gitkraft_core::Rgb`] to an [`iced::Color`].
69fn rgb_to_iced(rgb: gitkraft_core::Rgb) -> Color {
70    Color::from_rgb8(rgb.r, rgb.g, rgb.b)
71}
72
73/// Mix `base` with `tint` at the given `amount` (0.0 = all base, 1.0 = all tint).
74fn mix(base: Color, tint: Color, amount: f32) -> Color {
75    let inv = 1.0 - amount;
76    Color {
77        r: clamp(base.r * inv + tint.r * amount),
78        g: clamp(base.g * inv + tint.g * amount),
79        b: clamp(base.b * inv + tint.b * amount),
80        a: 1.0,
81    }
82}
83
84thread_local! {
85    static THEME_CACHE: RefCell<Option<(String, ThemeColors)>> = const { RefCell::new(None) };
86}
87
88impl ThemeColors {
89    /// Build a complete GUI colour set from the core's platform-agnostic theme.
90    ///
91    /// This is the **primary** constructor — it guarantees that the GUI renders
92    /// the exact same palette as the TUI for every theme index.
93    pub fn from_core(t: &gitkraft_core::AppTheme) -> Self {
94        let bg = rgb_to_iced(t.background);
95        let surface = rgb_to_iced(t.surface);
96        let success = rgb_to_iced(t.success);
97        let error = rgb_to_iced(t.error);
98        let hunk = rgb_to_iced(t.diff_hunk);
99
100        let sign: f32 = if t.is_dark { 1.0 } else { -1.0 };
101        let surface_highlight = shift(surface, sign * 0.04);
102        let header_bg = shift(bg, sign * 0.02);
103        let sidebar_bg = shift(bg, sign * 0.03);
104
105        // Diff backgrounds — faint tint of the semantic colour over the bg
106        let tint_amount = if t.is_dark { 0.18 } else { 0.12 };
107        let diff_add_bg = mix(bg, success, tint_amount);
108        let diff_del_bg = mix(bg, error, tint_amount);
109        let diff_hunk_bg = mix(bg, hunk, tint_amount);
110
111        // Error banner background — faint tint of the error colour over the bg
112        let error_bg = mix(bg, error, tint_amount);
113
114        // Graph lane colours — convert all eight from the core theme
115        let graph_colors = {
116            let gc = &t.graph_colors;
117            [
118                rgb_to_iced(gc[0]),
119                rgb_to_iced(gc[1]),
120                rgb_to_iced(gc[2]),
121                rgb_to_iced(gc[3]),
122                rgb_to_iced(gc[4]),
123                rgb_to_iced(gc[5]),
124                rgb_to_iced(gc[6]),
125                rgb_to_iced(gc[7]),
126            ]
127        };
128
129        Self {
130            accent: rgb_to_iced(t.accent),
131            text_primary: rgb_to_iced(t.text_primary),
132            text_secondary: rgb_to_iced(t.text_secondary),
133            muted: rgb_to_iced(t.text_muted),
134            bg,
135            surface,
136            surface_highlight,
137            header_bg,
138            sidebar_bg,
139            border: rgb_to_iced(t.border),
140            selection: rgb_to_iced(t.selection),
141            green: success,
142            red: error,
143            yellow: rgb_to_iced(t.warning),
144            diff_add_bg,
145            diff_del_bg,
146            diff_hunk_bg,
147            error_bg,
148            graph_colors,
149        }
150    }
151
152    /// Derive colours from an `iced::Theme` by mapping it to the closest core
153    /// theme and then calling [`from_core`](Self::from_core).
154    ///
155    /// This keeps backward-compatibility for any code that still holds an
156    /// `iced::Theme` value.
157    pub fn from_theme(theme: &iced::Theme) -> Self {
158        THEME_CACHE.with(|cache| {
159            let mut cache = cache.borrow_mut();
160            let name = theme.to_string();
161            if let Some((ref cached_name, cached_colors)) = *cache {
162                if *cached_name == name {
163                    return cached_colors;
164                }
165            }
166            let index = gitkraft_core::theme_index_by_name(&name);
167            let colors = Self::from_core(&gitkraft_core::theme_by_index(index));
168            *cache = Some((name, colors));
169            colors
170        })
171    }
172}
173
174// ── Container styles ──────────────────────────────────────────────────────────
175
176/// Style for a container with the main window background.
177pub fn bg_style(theme: &iced::Theme) -> container::Style {
178    let c = ThemeColors::from_theme(theme);
179    container::Style {
180        background: Some(Background::Color(c.bg)),
181        ..Default::default()
182    }
183}
184
185/// Style for the error banner background (faint red tint).
186pub fn error_banner_style(theme: &iced::Theme) -> container::Style {
187    let c = ThemeColors::from_theme(theme);
188    container::Style {
189        background: Some(Background::Color(c.error_bg)),
190        ..Default::default()
191    }
192}
193
194/// Style for a container with the standard surface background.
195pub fn surface_style(theme: &iced::Theme) -> container::Style {
196    let c = ThemeColors::from_theme(theme);
197    container::Style {
198        background: Some(Background::Color(c.surface)),
199        ..Default::default()
200    }
201}
202
203/// Style for a container with the sidebar background.
204pub fn sidebar_style(theme: &iced::Theme) -> container::Style {
205    let c = ThemeColors::from_theme(theme);
206    container::Style {
207        background: Some(Background::Color(c.sidebar_bg)),
208        ..Default::default()
209    }
210}
211
212/// Style for a container with the header / toolbar background.
213pub fn header_style(theme: &iced::Theme) -> container::Style {
214    let c = ThemeColors::from_theme(theme);
215    container::Style {
216        background: Some(Background::Color(c.header_bg)),
217        ..Default::default()
218    }
219}
220
221/// Style for the floating context-menu panel.
222pub fn context_menu_style(theme: &iced::Theme) -> container::Style {
223    let c = ThemeColors::from_theme(theme);
224    container::Style {
225        background: Some(Background::Color(c.surface_highlight)),
226        border: iced::Border {
227            color: c.border,
228            width: 1.0,
229            radius: 6.0.into(),
230        },
231        shadow: iced::Shadow {
232            color: iced::Color {
233                r: 0.0,
234                g: 0.0,
235                b: 0.0,
236                a: 0.35,
237            },
238            offset: iced::Vector::new(0.0, 4.0),
239            blur_radius: 12.0,
240        },
241        ..Default::default()
242    }
243}
244
245/// Style for the semi-transparent backdrop behind an open context menu.
246pub fn backdrop_style(_theme: &iced::Theme) -> container::Style {
247    container::Style {
248        background: Some(Background::Color(iced::Color {
249            r: 0.0,
250            g: 0.0,
251            b: 0.0,
252            a: 0.15,
253        })),
254        ..Default::default()
255    }
256}
257
258/// Style for a selected / highlighted row.
259pub fn selected_row_style(theme: &iced::Theme) -> container::Style {
260    let c = ThemeColors::from_theme(theme);
261    container::Style {
262        background: Some(Background::Color(c.surface_highlight)),
263        ..Default::default()
264    }
265}
266
267/// Style for a diff addition line.
268pub fn diff_add_style(theme: &iced::Theme) -> container::Style {
269    let c = ThemeColors::from_theme(theme);
270    container::Style {
271        background: Some(Background::Color(c.diff_add_bg)),
272        ..Default::default()
273    }
274}
275
276/// Style for a diff deletion line.
277pub fn diff_del_style(theme: &iced::Theme) -> container::Style {
278    let c = ThemeColors::from_theme(theme);
279    container::Style {
280        background: Some(Background::Color(c.diff_del_bg)),
281        ..Default::default()
282    }
283}
284
285/// Style for a diff hunk header line.
286pub fn diff_hunk_style(theme: &iced::Theme) -> container::Style {
287    let c = ThemeColors::from_theme(theme);
288    container::Style {
289        background: Some(Background::Color(c.diff_hunk_bg)),
290        ..Default::default()
291    }
292}
293
294// ── Button styles ─────────────────────────────────────────────────────────────
295
296/// Completely transparent button — no background, no border.  Used for
297/// clickable rows in the commit log, branch list, staging area, etc.
298pub fn ghost_button(theme: &iced::Theme, status: button::Status) -> button::Style {
299    let c = ThemeColors::from_theme(theme);
300    match status {
301        button::Status::Active => button::Style {
302            background: None,
303            text_color: c.text_primary,
304            border: iced::Border::default(),
305            shadow: iced::Shadow::default(),
306        },
307        button::Status::Hovered => button::Style {
308            background: Some(Background::Color(c.surface_highlight)),
309            text_color: c.text_primary,
310            border: iced::Border::default(),
311            shadow: iced::Shadow::default(),
312        },
313        button::Status::Pressed => button::Style {
314            background: Some(Background::Color(c.border)),
315            text_color: c.text_primary,
316            border: iced::Border::default(),
317            shadow: iced::Shadow::default(),
318        },
319        button::Status::Disabled => button::Style {
320            background: None,
321            text_color: c.muted,
322            border: iced::Border::default(),
323            shadow: iced::Shadow::default(),
324        },
325    }
326}
327
328/// Active tab button — has a visible bottom accent border to indicate selection.
329pub fn active_tab_button(theme: &iced::Theme, status: button::Status) -> button::Style {
330    let c = ThemeColors::from_theme(theme);
331    let active_border = iced::Border {
332        color: c.accent,
333        width: 0.0,
334        radius: 0.0.into(),
335    };
336    match status {
337        button::Status::Active => button::Style {
338            background: Some(Background::Color(c.surface)),
339            text_color: c.text_primary,
340            border: active_border,
341            shadow: iced::Shadow::default(),
342        },
343        button::Status::Hovered => button::Style {
344            background: Some(Background::Color(c.surface_highlight)),
345            text_color: c.text_primary,
346            border: active_border,
347            shadow: iced::Shadow::default(),
348        },
349        button::Status::Pressed => button::Style {
350            background: Some(Background::Color(c.border)),
351            text_color: c.text_primary,
352            border: active_border,
353            shadow: iced::Shadow::default(),
354        },
355        button::Status::Disabled => button::Style {
356            background: Some(Background::Color(c.surface)),
357            text_color: c.muted,
358            border: active_border,
359            shadow: iced::Shadow::default(),
360        },
361    }
362}
363
364/// Context menu item button — transparent at rest, accent-tinted on hover.
365pub fn context_menu_item(theme: &iced::Theme, status: button::Status) -> button::Style {
366    let c = ThemeColors::from_theme(theme);
367    match status {
368        button::Status::Active => button::Style {
369            background: None,
370            text_color: c.text_primary,
371            border: iced::Border::default(),
372            shadow: iced::Shadow::default(),
373        },
374        button::Status::Hovered => button::Style {
375            background: Some(Background::Color(iced::Color {
376                r: c.accent.r,
377                g: c.accent.g,
378                b: c.accent.b,
379                a: 0.15,
380            })),
381            text_color: c.text_primary,
382            border: iced::Border {
383                color: iced::Color::TRANSPARENT,
384                width: 0.0,
385                radius: 4.0.into(),
386            },
387            shadow: iced::Shadow::default(),
388        },
389        button::Status::Pressed => button::Style {
390            background: Some(Background::Color(iced::Color {
391                r: c.accent.r,
392                g: c.accent.g,
393                b: c.accent.b,
394                a: 0.28,
395            })),
396            text_color: c.text_primary,
397            border: iced::Border {
398                color: iced::Color::TRANSPARENT,
399                width: 0.0,
400                radius: 4.0.into(),
401            },
402            shadow: iced::Shadow::default(),
403        },
404        button::Status::Disabled => button::Style {
405            background: None,
406            text_color: c.muted,
407            border: iced::Border::default(),
408            shadow: iced::Shadow::default(),
409        },
410    }
411}
412
413/// Overlay scrollbar — invisible at rest, thin rounded thumb on hover/drag.
414///
415/// - **Active** / not hovered: completely invisible — the scrollbar takes no
416///   visual space and the content fills the full width.
417/// - **Hovered** (cursor anywhere over the scrollable, not just the rail):
418///   a thin, semi-transparent rounded thumb floats over the right edge.
419/// - **Dragged**: same thumb, slightly more opaque for feedback.
420///
421/// Apply with a 6 px `Direction::Vertical` width so there is a small grab
422/// target even though the rendered thumb is only 4 px wide.
423pub fn overlay_scrollbar(theme: &iced::Theme, status: scrollable::Status) -> scrollable::Style {
424    let c = ThemeColors::from_theme(theme);
425
426    let hidden = scrollable::Rail {
427        background: None,
428        border: iced::Border::default(),
429        scroller: scrollable::Scroller {
430            color: Color::TRANSPARENT,
431            border: iced::Border::default(),
432        },
433    };
434
435    let thumb = |alpha: f32| scrollable::Rail {
436        background: None,
437        border: iced::Border::default(),
438        scroller: scrollable::Scroller {
439            color: Color {
440                r: c.muted.r,
441                g: c.muted.g,
442                b: c.muted.b,
443                a: alpha,
444            },
445            border: iced::Border {
446                radius: 3.0.into(),
447                ..Default::default()
448            },
449        },
450    };
451
452    let v_rail = match status {
453        scrollable::Status::Active => hidden,
454        scrollable::Status::Hovered { .. } => thumb(0.45),
455        scrollable::Status::Dragged { .. } => thumb(0.70),
456    };
457
458    scrollable::Style {
459        container: container::Style::default(),
460        vertical_rail: v_rail,
461        horizontal_rail: hidden,
462        gap: None,
463    }
464}
465
466/// Subtle toolbar button — transparent at rest, light surface on hover.
467pub fn toolbar_button(theme: &iced::Theme, status: button::Status) -> button::Style {
468    let c = ThemeColors::from_theme(theme);
469    let border = iced::Border {
470        color: c.border,
471        width: 1.0,
472        radius: 4.0.into(),
473    };
474    match status {
475        button::Status::Active => button::Style {
476            background: Some(Background::Color(c.surface)),
477            text_color: c.text_primary,
478            border,
479            shadow: iced::Shadow::default(),
480        },
481        button::Status::Hovered => button::Style {
482            background: Some(Background::Color(c.surface_highlight)),
483            text_color: c.text_primary,
484            border,
485            shadow: iced::Shadow::default(),
486        },
487        button::Status::Pressed => button::Style {
488            background: Some(Background::Color(c.border)),
489            text_color: c.text_primary,
490            border,
491            shadow: iced::Shadow::default(),
492        },
493        button::Status::Disabled => button::Style {
494            background: Some(Background::Color(c.surface)),
495            text_color: c.muted,
496            border,
497            shadow: iced::Shadow::default(),
498        },
499    }
500}
501
502/// Small icon-only action button (stage, unstage, delete, etc.)
503pub fn icon_button(theme: &iced::Theme, status: button::Status) -> button::Style {
504    let c = ThemeColors::from_theme(theme);
505    match status {
506        button::Status::Active => button::Style {
507            background: None,
508            text_color: c.text_secondary,
509            border: iced::Border::default(),
510            shadow: iced::Shadow::default(),
511        },
512        button::Status::Hovered => button::Style {
513            background: Some(Background::Color(c.surface_highlight)),
514            text_color: c.text_primary,
515            border: iced::Border {
516                radius: 3.0.into(),
517                ..Default::default()
518            },
519            shadow: iced::Shadow::default(),
520        },
521        button::Status::Pressed => button::Style {
522            background: Some(Background::Color(c.border)),
523            text_color: c.text_primary,
524            border: iced::Border {
525                radius: 3.0.into(),
526                ..Default::default()
527            },
528            shadow: iced::Shadow::default(),
529        },
530        button::Status::Disabled => button::Style {
531            background: None,
532            text_color: c.muted,
533            border: iced::Border::default(),
534            shadow: iced::Shadow::default(),
535        },
536    }
537}
538
539// ── Semantic colour helpers ───────────────────────────────────────────────────
540
541/// Return the colour corresponding to a file-status badge.
542pub fn status_color(status: &gitkraft_core::FileStatus, c: &ThemeColors) -> Color {
543    match status {
544        gitkraft_core::FileStatus::New | gitkraft_core::FileStatus::Untracked => c.green,
545        gitkraft_core::FileStatus::Modified | gitkraft_core::FileStatus::Typechange => c.yellow,
546        gitkraft_core::FileStatus::Deleted => c.red,
547        gitkraft_core::FileStatus::Renamed | gitkraft_core::FileStatus::Copied => c.accent,
548    }
549}
550
551// ── Tests ─────────────────────────────────────────────────────────────────────
552
553#[cfg(test)]
554mod tests {
555    use super::*;
556
557    #[test]
558    fn from_core_dark_theme() {
559        let core = gitkraft_core::theme_by_index(0); // Default (dark)
560        let colors = ThemeColors::from_core(&core);
561        // Dark theme should have a dark background
562        assert!(colors.bg.r < 0.5);
563        // Accent, green, red should all be non-zero
564        assert!(colors.accent.r > 0.0 || colors.accent.g > 0.0 || colors.accent.b > 0.0);
565        assert!(colors.green.g > 0.0);
566        assert!(colors.red.r > 0.0);
567    }
568
569    #[test]
570    fn from_core_light_theme() {
571        let core = gitkraft_core::theme_by_index(11); // Solarized Light
572        let colors = ThemeColors::from_core(&core);
573        // Light theme should have a light background
574        assert!(colors.bg.r > 0.5);
575    }
576
577    #[test]
578    fn from_theme_fallback_still_works() {
579        let colors = ThemeColors::from_theme(&iced::Theme::Dark);
580        // Should resolve to the Default core theme (dark bg)
581        assert!(colors.bg.r < 0.5);
582    }
583
584    #[test]
585    fn status_color_variants() {
586        let core = gitkraft_core::theme_by_index(0);
587        let c = ThemeColors::from_core(&core);
588        // New / Untracked → green
589        assert_eq!(status_color(&gitkraft_core::FileStatus::New, &c), c.green);
590        assert_eq!(
591            status_color(&gitkraft_core::FileStatus::Untracked, &c),
592            c.green
593        );
594        // Modified → yellow
595        assert_eq!(
596            status_color(&gitkraft_core::FileStatus::Modified, &c),
597            c.yellow
598        );
599        // Deleted → red
600        assert_eq!(status_color(&gitkraft_core::FileStatus::Deleted, &c), c.red);
601        // Renamed → accent
602        assert_eq!(
603            status_color(&gitkraft_core::FileStatus::Renamed, &c),
604            c.accent
605        );
606    }
607
608    #[test]
609    fn clamp_stays_in_range() {
610        assert_eq!(clamp(-0.1), 0.0);
611        assert_eq!(clamp(1.5), 1.0);
612        assert!((clamp(0.5) - 0.5).abs() < f32::EPSILON);
613    }
614
615    #[test]
616    fn shift_and_scale_stay_in_range() {
617        let base = Color {
618            r: 0.9,
619            g: 0.1,
620            b: 0.5,
621            a: 1.0,
622        };
623        let shifted = shift(base, 0.2);
624        assert!(shifted.r <= 1.0 && shifted.g >= 0.0);
625
626        let scaled = scale(base, 2.0);
627        assert!(scaled.r <= 1.0);
628    }
629
630    #[test]
631    fn all_27_core_themes_produce_valid_colors() {
632        for i in 0..gitkraft_core::THEME_COUNT {
633            let core = gitkraft_core::theme_by_index(i);
634            let c = ThemeColors::from_core(&core);
635            // bg channels should be in [0, 1]
636            assert!(
637                c.bg.r >= 0.0 && c.bg.r <= 1.0,
638                "theme {i} bg.r out of range"
639            );
640            assert!(
641                c.bg.g >= 0.0 && c.bg.g <= 1.0,
642                "theme {i} bg.g out of range"
643            );
644            assert!(
645                c.bg.b >= 0.0 && c.bg.b <= 1.0,
646                "theme {i} bg.b out of range"
647            );
648        }
649    }
650
651    #[test]
652    fn graph_colors_populated_for_all_themes() {
653        for i in 0..gitkraft_core::THEME_COUNT {
654            let core = gitkraft_core::theme_by_index(i);
655            let c = ThemeColors::from_core(&core);
656            // All 8 graph lane colours should be valid (channels in [0, 1])
657            for (lane, color) in c.graph_colors.iter().enumerate() {
658                assert!(
659                    color.r >= 0.0 && color.r <= 1.0,
660                    "theme {i} graph_colors[{lane}].r out of range"
661                );
662                assert!(
663                    color.g >= 0.0 && color.g <= 1.0,
664                    "theme {i} graph_colors[{lane}].g out of range"
665                );
666                assert!(
667                    color.b >= 0.0 && color.b <= 1.0,
668                    "theme {i} graph_colors[{lane}].b out of range"
669                );
670            }
671        }
672    }
673
674    #[test]
675    fn graph_colors_are_not_all_identical() {
676        for i in 0..gitkraft_core::THEME_COUNT {
677            let core = gitkraft_core::theme_by_index(i);
678            let c = ThemeColors::from_core(&core);
679            // At least two distinct colours among the 8 lanes
680            let first = c.graph_colors[0];
681            let all_same = c.graph_colors.iter().all(|gc| {
682                (gc.r - first.r).abs() < f32::EPSILON
683                    && (gc.g - first.g).abs() < f32::EPSILON
684                    && (gc.b - first.b).abs() < f32::EPSILON
685            });
686            assert!(!all_same, "theme {i} has all identical graph lane colours");
687        }
688    }
689
690    #[test]
691    fn error_bg_differs_from_plain_bg() {
692        for i in 0..gitkraft_core::THEME_COUNT {
693            let core = gitkraft_core::theme_by_index(i);
694            let c = ThemeColors::from_core(&core);
695            // error_bg should be a tinted version of bg, not identical
696            let same = (c.error_bg.r - c.bg.r).abs() < f32::EPSILON
697                && (c.error_bg.g - c.bg.g).abs() < f32::EPSILON
698                && (c.error_bg.b - c.bg.b).abs() < f32::EPSILON;
699            assert!(
700                !same,
701                "theme {i} error_bg is identical to bg — tint not applied"
702            );
703        }
704    }
705
706    #[test]
707    fn selection_is_valid_color() {
708        for i in 0..gitkraft_core::THEME_COUNT {
709            let core = gitkraft_core::theme_by_index(i);
710            let c = ThemeColors::from_core(&core);
711            assert!(
712                c.selection.r >= 0.0 && c.selection.r <= 1.0,
713                "theme {i} selection.r out of range"
714            );
715            assert!(
716                c.selection.g >= 0.0 && c.selection.g <= 1.0,
717                "theme {i} selection.g out of range"
718            );
719            assert!(
720                c.selection.b >= 0.0 && c.selection.b <= 1.0,
721                "theme {i} selection.b out of range"
722            );
723        }
724    }
725
726    #[test]
727    fn selection_differs_from_bg() {
728        for i in 0..gitkraft_core::THEME_COUNT {
729            let core = gitkraft_core::theme_by_index(i);
730            let c = ThemeColors::from_core(&core);
731            let same = (c.selection.r - c.bg.r).abs() < f32::EPSILON
732                && (c.selection.g - c.bg.g).abs() < f32::EPSILON
733                && (c.selection.b - c.bg.b).abs() < f32::EPSILON;
734            assert!(
735                !same,
736                "theme {i} selection is identical to bg — should be distinguishable"
737            );
738        }
739    }
740}