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