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