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            snap: false,
307        },
308        button::Status::Hovered => button::Style {
309            background: Some(Background::Color(c.surface_highlight)),
310            text_color: c.text_primary,
311            border: iced::Border::default(),
312            shadow: iced::Shadow::default(),
313            snap: false,
314        },
315        button::Status::Pressed => button::Style {
316            background: Some(Background::Color(c.border)),
317            text_color: c.text_primary,
318            border: iced::Border::default(),
319            shadow: iced::Shadow::default(),
320            snap: false,
321        },
322        button::Status::Disabled => button::Style {
323            background: None,
324            text_color: c.muted,
325            border: iced::Border::default(),
326            shadow: iced::Shadow::default(),
327            snap: false,
328        },
329    }
330}
331
332/// Active tab button — has a visible bottom accent border to indicate selection.
333pub fn active_tab_button(theme: &iced::Theme, status: button::Status) -> button::Style {
334    let c = ThemeColors::from_theme(theme);
335    let active_border = iced::Border {
336        color: c.accent,
337        width: 0.0,
338        radius: 0.0.into(),
339    };
340    match status {
341        button::Status::Active => button::Style {
342            background: Some(Background::Color(c.surface)),
343            text_color: c.text_primary,
344            border: active_border,
345            shadow: iced::Shadow::default(),
346            snap: false,
347        },
348        button::Status::Hovered => button::Style {
349            background: Some(Background::Color(c.surface_highlight)),
350            text_color: c.text_primary,
351            border: active_border,
352            shadow: iced::Shadow::default(),
353            snap: false,
354        },
355        button::Status::Pressed => button::Style {
356            background: Some(Background::Color(c.border)),
357            text_color: c.text_primary,
358            border: active_border,
359            shadow: iced::Shadow::default(),
360            snap: false,
361        },
362        button::Status::Disabled => button::Style {
363            background: Some(Background::Color(c.surface)),
364            text_color: c.muted,
365            border: active_border,
366            shadow: iced::Shadow::default(),
367            snap: false,
368        },
369    }
370}
371
372/// Context menu item button — transparent at rest, accent-tinted on hover.
373pub fn context_menu_item(theme: &iced::Theme, status: button::Status) -> button::Style {
374    let c = ThemeColors::from_theme(theme);
375    match status {
376        button::Status::Active => button::Style {
377            background: None,
378            text_color: c.text_primary,
379            border: iced::Border::default(),
380            shadow: iced::Shadow::default(),
381            snap: false,
382        },
383        button::Status::Hovered => button::Style {
384            background: Some(Background::Color(iced::Color {
385                r: c.accent.r,
386                g: c.accent.g,
387                b: c.accent.b,
388                a: 0.15,
389            })),
390            text_color: c.text_primary,
391            border: iced::Border {
392                color: iced::Color::TRANSPARENT,
393                width: 0.0,
394                radius: 4.0.into(),
395            },
396            shadow: iced::Shadow::default(),
397            snap: false,
398        },
399        button::Status::Pressed => button::Style {
400            background: Some(Background::Color(iced::Color {
401                r: c.accent.r,
402                g: c.accent.g,
403                b: c.accent.b,
404                a: 0.28,
405            })),
406            text_color: c.text_primary,
407            border: iced::Border {
408                color: iced::Color::TRANSPARENT,
409                width: 0.0,
410                radius: 4.0.into(),
411            },
412            shadow: iced::Shadow::default(),
413            snap: false,
414        },
415        button::Status::Disabled => button::Style {
416            background: None,
417            text_color: c.muted,
418            border: iced::Border::default(),
419            shadow: iced::Shadow::default(),
420            snap: false,
421        },
422    }
423}
424
425/// Overlay scrollbar — invisible at rest, thin rounded thumb on hover/drag.
426///
427/// - **Active** / not hovered: completely invisible — the scrollbar takes no
428///   visual space and the content fills the full width.
429/// - **Hovered** (cursor anywhere over the scrollable, not just the rail):
430///   a thin, semi-transparent rounded thumb floats over the right edge.
431/// - **Dragged**: same thumb, slightly more opaque for feedback.
432///
433/// Apply with a 6 px `Direction::Vertical` width so there is a small grab
434/// target even though the rendered thumb is only 4 px wide.
435pub fn overlay_scrollbar(theme: &iced::Theme, status: scrollable::Status) -> scrollable::Style {
436    let c = ThemeColors::from_theme(theme);
437
438    let hidden = scrollable::Rail {
439        background: None,
440        border: iced::Border::default(),
441        scroller: scrollable::Scroller {
442            background: Background::Color(Color::TRANSPARENT),
443            border: iced::Border::default(),
444        },
445    };
446
447    let thumb = |alpha: f32| scrollable::Rail {
448        background: None,
449        border: iced::Border::default(),
450        scroller: scrollable::Scroller {
451            background: Background::Color(Color {
452                r: c.muted.r,
453                g: c.muted.g,
454                b: c.muted.b,
455                a: alpha,
456            }),
457            border: iced::Border {
458                radius: 3.0.into(),
459                ..Default::default()
460            },
461        },
462    };
463
464    let v_rail = match status {
465        scrollable::Status::Active { .. } => hidden,
466        scrollable::Status::Hovered { .. } => thumb(0.45),
467        scrollable::Status::Dragged { .. } => thumb(0.70),
468    };
469
470    scrollable::Style {
471        container: container::Style::default(),
472        vertical_rail: v_rail,
473        horizontal_rail: hidden,
474        gap: None,
475        auto_scroll: scrollable::AutoScroll {
476            background: Background::Color(Color::TRANSPARENT),
477            border: iced::Border::default(),
478            shadow: iced::Shadow::default(),
479            icon: Color::TRANSPARENT,
480        },
481    }
482}
483
484/// Subtle toolbar button — transparent at rest, light surface on hover.
485pub fn toolbar_button(theme: &iced::Theme, status: button::Status) -> button::Style {
486    let c = ThemeColors::from_theme(theme);
487    let border = iced::Border {
488        color: c.border,
489        width: 1.0,
490        radius: 4.0.into(),
491    };
492    match status {
493        button::Status::Active => button::Style {
494            background: Some(Background::Color(c.surface)),
495            text_color: c.text_primary,
496            border,
497            shadow: iced::Shadow::default(),
498            snap: false,
499        },
500        button::Status::Hovered => button::Style {
501            background: Some(Background::Color(c.surface_highlight)),
502            text_color: c.text_primary,
503            border,
504            shadow: iced::Shadow::default(),
505            snap: false,
506        },
507        button::Status::Pressed => button::Style {
508            background: Some(Background::Color(c.border)),
509            text_color: c.text_primary,
510            border,
511            shadow: iced::Shadow::default(),
512            snap: false,
513        },
514        button::Status::Disabled => button::Style {
515            background: Some(Background::Color(c.surface)),
516            text_color: c.muted,
517            border,
518            shadow: iced::Shadow::default(),
519            snap: false,
520        },
521    }
522}
523
524/// Small icon-only action button (stage, unstage, delete, etc.)
525pub fn icon_button(theme: &iced::Theme, status: button::Status) -> button::Style {
526    let c = ThemeColors::from_theme(theme);
527    match status {
528        button::Status::Active => button::Style {
529            background: None,
530            text_color: c.text_secondary,
531            border: iced::Border::default(),
532            shadow: iced::Shadow::default(),
533            snap: false,
534        },
535        button::Status::Hovered => button::Style {
536            background: Some(Background::Color(c.surface_highlight)),
537            text_color: c.text_primary,
538            border: iced::Border {
539                radius: 3.0.into(),
540                ..Default::default()
541            },
542            shadow: iced::Shadow::default(),
543            snap: false,
544        },
545        button::Status::Pressed => button::Style {
546            background: Some(Background::Color(c.border)),
547            text_color: c.text_primary,
548            border: iced::Border {
549                radius: 3.0.into(),
550                ..Default::default()
551            },
552            shadow: iced::Shadow::default(),
553            snap: false,
554        },
555        button::Status::Disabled => button::Style {
556            background: None,
557            text_color: c.muted,
558            border: iced::Border::default(),
559            shadow: iced::Shadow::default(),
560            snap: false,
561        },
562    }
563}
564
565// ── Semantic colour helpers ───────────────────────────────────────────────────
566
567/// Return the colour corresponding to a file-status badge.
568pub fn status_color(status: &gitkraft_core::FileStatus, c: &ThemeColors) -> Color {
569    match status {
570        gitkraft_core::FileStatus::New | gitkraft_core::FileStatus::Untracked => c.green,
571        gitkraft_core::FileStatus::Modified | gitkraft_core::FileStatus::Typechange => c.yellow,
572        gitkraft_core::FileStatus::Deleted => c.red,
573        gitkraft_core::FileStatus::Renamed | gitkraft_core::FileStatus::Copied => c.accent,
574    }
575}
576
577// ── Tests ─────────────────────────────────────────────────────────────────────
578
579#[cfg(test)]
580mod tests {
581    use super::*;
582
583    #[test]
584    fn from_core_dark_theme() {
585        let core = gitkraft_core::theme_by_index(0); // Default (dark)
586        let colors = ThemeColors::from_core(&core);
587        // Dark theme should have a dark background
588        assert!(colors.bg.r < 0.5);
589        // Accent, green, red should all be non-zero
590        assert!(colors.accent.r > 0.0 || colors.accent.g > 0.0 || colors.accent.b > 0.0);
591        assert!(colors.green.g > 0.0);
592        assert!(colors.red.r > 0.0);
593    }
594
595    #[test]
596    fn from_core_light_theme() {
597        let core = gitkraft_core::theme_by_index(11); // Solarized Light
598        let colors = ThemeColors::from_core(&core);
599        // Light theme should have a light background
600        assert!(colors.bg.r > 0.5);
601    }
602
603    #[test]
604    fn from_theme_fallback_still_works() {
605        let colors = ThemeColors::from_theme(&iced::Theme::Dark);
606        // Should resolve to the Default core theme (dark bg)
607        assert!(colors.bg.r < 0.5);
608    }
609
610    #[test]
611    fn status_color_variants() {
612        let core = gitkraft_core::theme_by_index(0);
613        let c = ThemeColors::from_core(&core);
614        // New / Untracked → green
615        assert_eq!(status_color(&gitkraft_core::FileStatus::New, &c), c.green);
616        assert_eq!(
617            status_color(&gitkraft_core::FileStatus::Untracked, &c),
618            c.green
619        );
620        // Modified → yellow
621        assert_eq!(
622            status_color(&gitkraft_core::FileStatus::Modified, &c),
623            c.yellow
624        );
625        // Deleted → red
626        assert_eq!(status_color(&gitkraft_core::FileStatus::Deleted, &c), c.red);
627        // Renamed → accent
628        assert_eq!(
629            status_color(&gitkraft_core::FileStatus::Renamed, &c),
630            c.accent
631        );
632    }
633
634    #[test]
635    fn clamp_stays_in_range() {
636        assert_eq!(clamp(-0.1), 0.0);
637        assert_eq!(clamp(1.5), 1.0);
638        assert!((clamp(0.5) - 0.5).abs() < f32::EPSILON);
639    }
640
641    #[test]
642    fn shift_and_scale_stay_in_range() {
643        let base = Color {
644            r: 0.9,
645            g: 0.1,
646            b: 0.5,
647            a: 1.0,
648        };
649        let shifted = shift(base, 0.2);
650        assert!(shifted.r <= 1.0 && shifted.g >= 0.0);
651
652        let scaled = scale(base, 2.0);
653        assert!(scaled.r <= 1.0);
654    }
655
656    #[test]
657    fn all_27_core_themes_produce_valid_colors() {
658        for i in 0..gitkraft_core::THEME_COUNT {
659            let core = gitkraft_core::theme_by_index(i);
660            let c = ThemeColors::from_core(&core);
661            // bg channels should be in [0, 1]
662            assert!(
663                c.bg.r >= 0.0 && c.bg.r <= 1.0,
664                "theme {i} bg.r out of range"
665            );
666            assert!(
667                c.bg.g >= 0.0 && c.bg.g <= 1.0,
668                "theme {i} bg.g out of range"
669            );
670            assert!(
671                c.bg.b >= 0.0 && c.bg.b <= 1.0,
672                "theme {i} bg.b out of range"
673            );
674        }
675    }
676
677    #[test]
678    fn graph_colors_populated_for_all_themes() {
679        for i in 0..gitkraft_core::THEME_COUNT {
680            let core = gitkraft_core::theme_by_index(i);
681            let c = ThemeColors::from_core(&core);
682            // All 8 graph lane colours should be valid (channels in [0, 1])
683            for (lane, color) in c.graph_colors.iter().enumerate() {
684                assert!(
685                    color.r >= 0.0 && color.r <= 1.0,
686                    "theme {i} graph_colors[{lane}].r out of range"
687                );
688                assert!(
689                    color.g >= 0.0 && color.g <= 1.0,
690                    "theme {i} graph_colors[{lane}].g out of range"
691                );
692                assert!(
693                    color.b >= 0.0 && color.b <= 1.0,
694                    "theme {i} graph_colors[{lane}].b out of range"
695                );
696            }
697        }
698    }
699
700    #[test]
701    fn graph_colors_are_not_all_identical() {
702        for i in 0..gitkraft_core::THEME_COUNT {
703            let core = gitkraft_core::theme_by_index(i);
704            let c = ThemeColors::from_core(&core);
705            // At least two distinct colours among the 8 lanes
706            let first = c.graph_colors[0];
707            let all_same = c.graph_colors.iter().all(|gc| {
708                (gc.r - first.r).abs() < f32::EPSILON
709                    && (gc.g - first.g).abs() < f32::EPSILON
710                    && (gc.b - first.b).abs() < f32::EPSILON
711            });
712            assert!(!all_same, "theme {i} has all identical graph lane colours");
713        }
714    }
715
716    #[test]
717    fn error_bg_differs_from_plain_bg() {
718        for i in 0..gitkraft_core::THEME_COUNT {
719            let core = gitkraft_core::theme_by_index(i);
720            let c = ThemeColors::from_core(&core);
721            // error_bg should be a tinted version of bg, not identical
722            let same = (c.error_bg.r - c.bg.r).abs() < f32::EPSILON
723                && (c.error_bg.g - c.bg.g).abs() < f32::EPSILON
724                && (c.error_bg.b - c.bg.b).abs() < f32::EPSILON;
725            assert!(
726                !same,
727                "theme {i} error_bg is identical to bg — tint not applied"
728            );
729        }
730    }
731
732    #[test]
733    fn selection_is_valid_color() {
734        for i in 0..gitkraft_core::THEME_COUNT {
735            let core = gitkraft_core::theme_by_index(i);
736            let c = ThemeColors::from_core(&core);
737            assert!(
738                c.selection.r >= 0.0 && c.selection.r <= 1.0,
739                "theme {i} selection.r out of range"
740            );
741            assert!(
742                c.selection.g >= 0.0 && c.selection.g <= 1.0,
743                "theme {i} selection.g out of range"
744            );
745            assert!(
746                c.selection.b >= 0.0 && c.selection.b <= 1.0,
747                "theme {i} selection.b out of range"
748            );
749        }
750    }
751
752    #[test]
753    fn selection_differs_from_bg() {
754        for i in 0..gitkraft_core::THEME_COUNT {
755            let core = gitkraft_core::theme_by_index(i);
756            let c = ThemeColors::from_core(&core);
757            let same = (c.selection.r - c.bg.r).abs() < f32::EPSILON
758                && (c.selection.g - c.bg.g).abs() < f32::EPSILON
759                && (c.selection.b - c.bg.b).abs() < f32::EPSILON;
760            assert!(
761                !same,
762                "theme {i} selection is identical to bg — should be distinguishable"
763            );
764        }
765    }
766}