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 row being hovered by the mouse — subtle surface-level highlight.
284pub fn hover_row_style(theme: &iced::Theme) -> container::Style {
285    let c = ThemeColors::from_theme(theme);
286    let bg = mix(c.surface, c.surface_highlight, 0.5);
287    container::Style {
288        background: Some(Background::Color(bg)),
289        ..Default::default()
290    }
291}
292
293/// Resolve the background colour (if any) that should be painted behind a
294/// commit-log row for the given interaction state, mirroring
295/// [`selected_row_style`], [`highlight_row_style`] and [`hover_row_style`].
296///
297/// Returns `None` when the row is in its default (unhighlighted) state, in
298/// which case callers should let the panel's base `surface` colour show
299/// through instead of painting anything extra.
300///
301/// Used to keep the BRANCH/TAG, GRAPH and COMMIT MESSAGE columns visually in
302/// sync so row selection/hover spans the full width of the commit log.
303pub fn row_highlight_color(
304    is_selected: bool,
305    is_in_range: bool,
306    is_hovered: bool,
307    c: &ThemeColors,
308) -> Option<Color> {
309    if is_selected {
310        Some(c.surface_highlight)
311    } else if is_in_range {
312        Some(mix(c.surface, c.selection, 0.30))
313    } else if is_hovered {
314        Some(mix(c.surface, c.surface_highlight, 0.5))
315    } else {
316        None
317    }
318}
319
320/// Style for a diff addition line.
321pub fn diff_add_style(theme: &iced::Theme) -> container::Style {
322    let c = ThemeColors::from_theme(theme);
323    container::Style {
324        background: Some(Background::Color(c.diff_add_bg)),
325        ..Default::default()
326    }
327}
328
329/// Style for a diff deletion line.
330pub fn diff_del_style(theme: &iced::Theme) -> container::Style {
331    let c = ThemeColors::from_theme(theme);
332    container::Style {
333        background: Some(Background::Color(c.diff_del_bg)),
334        ..Default::default()
335    }
336}
337
338/// Style for a diff hunk header line.
339pub fn diff_hunk_style(theme: &iced::Theme) -> container::Style {
340    let c = ThemeColors::from_theme(theme);
341    container::Style {
342        background: Some(Background::Color(c.diff_hunk_bg)),
343        ..Default::default()
344    }
345}
346
347// ── Button styles ─────────────────────────────────────────────────────────────
348
349/// Completely transparent button — no background, no border.  Used for
350/// clickable rows in the commit log, branch list, staging area, etc.
351pub fn ghost_button(theme: &iced::Theme, status: button::Status) -> button::Style {
352    let c = ThemeColors::from_theme(theme);
353    match status {
354        button::Status::Active => button::Style {
355            background: None,
356            text_color: c.text_primary,
357            border: iced::Border::default(),
358            shadow: iced::Shadow::default(),
359            snap: false,
360        },
361        button::Status::Hovered => button::Style {
362            background: Some(Background::Color(c.surface_highlight)),
363            text_color: c.text_primary,
364            border: iced::Border::default(),
365            shadow: iced::Shadow::default(),
366            snap: false,
367        },
368        button::Status::Pressed => button::Style {
369            background: Some(Background::Color(c.border)),
370            text_color: c.text_primary,
371            border: iced::Border::default(),
372            shadow: iced::Shadow::default(),
373            snap: false,
374        },
375        button::Status::Disabled => button::Style {
376            background: None,
377            text_color: c.muted,
378            border: iced::Border::default(),
379            shadow: iced::Shadow::default(),
380            snap: false,
381        },
382    }
383}
384
385/// Like [`ghost_button`], but never paints a background of its own — even
386/// while hovered or pressed. Used for buttons embedded inside a larger row
387/// whose background is *already* driven by external state (e.g. the commit
388/// message row, whose full-row highlight is controlled by `row_style!` on
389/// the surrounding container based on hover/selection/range state). Without
390/// this, the button's own native hover background would compete with — and
391/// visually desync from — the row's state-driven highlight.
392pub fn ghost_button_flat(theme: &iced::Theme, _status: button::Status) -> button::Style {
393    let c = ThemeColors::from_theme(theme);
394    button::Style {
395        background: None,
396        text_color: c.text_primary,
397        border: iced::Border::default(),
398        shadow: iced::Shadow::default(),
399        snap: false,
400    }
401}
402
403/// Active tab button — has a visible bottom accent border to indicate selection.
404pub fn active_tab_button(theme: &iced::Theme, status: button::Status) -> button::Style {
405    let c = ThemeColors::from_theme(theme);
406    let active_border = iced::Border {
407        color: c.accent,
408        width: 0.0,
409        radius: 0.0.into(),
410    };
411    match status {
412        button::Status::Active => button::Style {
413            background: Some(Background::Color(c.surface)),
414            text_color: c.text_primary,
415            border: active_border,
416            shadow: iced::Shadow::default(),
417            snap: false,
418        },
419        button::Status::Hovered => button::Style {
420            background: Some(Background::Color(c.surface_highlight)),
421            text_color: c.text_primary,
422            border: active_border,
423            shadow: iced::Shadow::default(),
424            snap: false,
425        },
426        button::Status::Pressed => button::Style {
427            background: Some(Background::Color(c.border)),
428            text_color: c.text_primary,
429            border: active_border,
430            shadow: iced::Shadow::default(),
431            snap: false,
432        },
433        button::Status::Disabled => button::Style {
434            background: Some(Background::Color(c.surface)),
435            text_color: c.muted,
436            border: active_border,
437            shadow: iced::Shadow::default(),
438            snap: false,
439        },
440    }
441}
442
443/// Context menu item button — transparent at rest, accent-tinted on hover.
444pub fn context_menu_item(theme: &iced::Theme, status: button::Status) -> button::Style {
445    let c = ThemeColors::from_theme(theme);
446    match status {
447        button::Status::Active => button::Style {
448            background: None,
449            text_color: c.text_primary,
450            border: iced::Border::default(),
451            shadow: iced::Shadow::default(),
452            snap: false,
453        },
454        button::Status::Hovered => button::Style {
455            background: Some(Background::Color(iced::Color {
456                r: c.accent.r,
457                g: c.accent.g,
458                b: c.accent.b,
459                a: 0.15,
460            })),
461            text_color: c.text_primary,
462            border: iced::Border {
463                color: iced::Color::TRANSPARENT,
464                width: 0.0,
465                radius: 4.0.into(),
466            },
467            shadow: iced::Shadow::default(),
468            snap: false,
469        },
470        button::Status::Pressed => button::Style {
471            background: Some(Background::Color(iced::Color {
472                r: c.accent.r,
473                g: c.accent.g,
474                b: c.accent.b,
475                a: 0.28,
476            })),
477            text_color: c.text_primary,
478            border: iced::Border {
479                color: iced::Color::TRANSPARENT,
480                width: 0.0,
481                radius: 4.0.into(),
482            },
483            shadow: iced::Shadow::default(),
484            snap: false,
485        },
486        button::Status::Disabled => button::Style {
487            background: None,
488            text_color: c.muted,
489            border: iced::Border::default(),
490            shadow: iced::Shadow::default(),
491            snap: false,
492        },
493    }
494}
495
496/// Overlay scrollbar — invisible at rest, thin rounded thumb on hover/drag.
497///
498/// - **Active** / not hovered: completely invisible — the scrollbar takes no
499///   visual space and the content fills the full width.
500/// - **Hovered** (cursor anywhere over the scrollable, not just the rail):
501///   a thin, semi-transparent rounded thumb floats over the right edge.
502/// - **Dragged**: same thumb, slightly more opaque for feedback.
503///
504/// Apply with a 6 px `Direction::Vertical` width so there is a small grab
505/// target even though the rendered thumb is only 4 px wide.
506pub fn overlay_scrollbar(theme: &iced::Theme, status: scrollable::Status) -> scrollable::Style {
507    let c = ThemeColors::from_theme(theme);
508
509    let hidden = scrollable::Rail {
510        background: None,
511        border: iced::Border::default(),
512        scroller: scrollable::Scroller {
513            background: Background::Color(Color::TRANSPARENT),
514            border: iced::Border::default(),
515        },
516    };
517
518    let thumb = |alpha: f32| scrollable::Rail {
519        background: None,
520        border: iced::Border::default(),
521        scroller: scrollable::Scroller {
522            background: Background::Color(Color {
523                r: c.muted.r,
524                g: c.muted.g,
525                b: c.muted.b,
526                a: alpha,
527            }),
528            border: iced::Border {
529                radius: 3.0.into(),
530                ..Default::default()
531            },
532        },
533    };
534
535    let v_rail = match status {
536        scrollable::Status::Active { .. } => hidden,
537        scrollable::Status::Hovered { .. } => thumb(0.45),
538        scrollable::Status::Dragged { .. } => thumb(0.70),
539    };
540
541    scrollable::Style {
542        container: container::Style::default(),
543        vertical_rail: v_rail,
544        horizontal_rail: hidden,
545        gap: None,
546        auto_scroll: scrollable::AutoScroll {
547            background: Background::Color(Color::TRANSPARENT),
548            border: iced::Border::default(),
549            shadow: iced::Shadow::default(),
550            icon: Color::TRANSPARENT,
551        },
552    }
553}
554
555/// Subtle toolbar button — transparent at rest, light surface on hover.
556pub fn toolbar_button(theme: &iced::Theme, status: button::Status) -> button::Style {
557    let c = ThemeColors::from_theme(theme);
558    let border = iced::Border {
559        color: c.border,
560        width: 1.0,
561        radius: 4.0.into(),
562    };
563    match status {
564        button::Status::Active => button::Style {
565            background: Some(Background::Color(c.surface)),
566            text_color: c.text_primary,
567            border,
568            shadow: iced::Shadow::default(),
569            snap: false,
570        },
571        button::Status::Hovered => button::Style {
572            background: Some(Background::Color(c.surface_highlight)),
573            text_color: c.text_primary,
574            border,
575            shadow: iced::Shadow::default(),
576            snap: false,
577        },
578        button::Status::Pressed => button::Style {
579            background: Some(Background::Color(c.border)),
580            text_color: c.text_primary,
581            border,
582            shadow: iced::Shadow::default(),
583            snap: false,
584        },
585        button::Status::Disabled => button::Style {
586            background: Some(Background::Color(c.surface)),
587            text_color: c.muted,
588            border,
589            shadow: iced::Shadow::default(),
590            snap: false,
591        },
592    }
593}
594
595/// Small icon-only action button (stage, unstage, delete, etc.)
596pub fn icon_button(theme: &iced::Theme, status: button::Status) -> button::Style {
597    let c = ThemeColors::from_theme(theme);
598    match status {
599        button::Status::Active => button::Style {
600            background: None,
601            text_color: c.text_secondary,
602            border: iced::Border::default(),
603            shadow: iced::Shadow::default(),
604            snap: false,
605        },
606        button::Status::Hovered => button::Style {
607            background: Some(Background::Color(c.surface_highlight)),
608            text_color: c.text_primary,
609            border: iced::Border {
610                radius: 3.0.into(),
611                ..Default::default()
612            },
613            shadow: iced::Shadow::default(),
614            snap: false,
615        },
616        button::Status::Pressed => button::Style {
617            background: Some(Background::Color(c.border)),
618            text_color: c.text_primary,
619            border: iced::Border {
620                radius: 3.0.into(),
621                ..Default::default()
622            },
623            shadow: iced::Shadow::default(),
624            snap: false,
625        },
626        button::Status::Disabled => button::Style {
627            background: None,
628            text_color: c.muted,
629            border: iced::Border::default(),
630            shadow: iced::Shadow::default(),
631            snap: false,
632        },
633    }
634}
635
636// ── Semantic colour helpers ───────────────────────────────────────────────────
637
638/// Return the colour corresponding to a file-status badge.
639pub fn status_color(status: &gitkraft_core::FileStatus, c: &ThemeColors) -> Color {
640    match status {
641        gitkraft_core::FileStatus::New | gitkraft_core::FileStatus::Untracked => c.green,
642        gitkraft_core::FileStatus::Modified | gitkraft_core::FileStatus::Typechange => c.yellow,
643        gitkraft_core::FileStatus::Deleted => c.red,
644        gitkraft_core::FileStatus::Renamed | gitkraft_core::FileStatus::Copied => c.accent,
645    }
646}
647
648// ── Tests ─────────────────────────────────────────────────────────────────────
649
650#[cfg(test)]
651mod tests {
652    use super::*;
653
654    #[test]
655    fn from_core_dark_theme() {
656        let core = gitkraft_core::theme_by_index(0); // Default (dark)
657        let colors = ThemeColors::from_core(&core);
658        // Dark theme should have a dark background
659        assert!(colors.bg.r < 0.5);
660        // Accent, green, red should all be non-zero
661        assert!(colors.accent.r > 0.0 || colors.accent.g > 0.0 || colors.accent.b > 0.0);
662        assert!(colors.green.g > 0.0);
663        assert!(colors.red.r > 0.0);
664    }
665
666    #[test]
667    fn from_core_light_theme() {
668        let core =
669            gitkraft_core::theme_by_index(gitkraft_core::theme_index_by_name("Solarized Light")); // Solarized Light
670        let colors = ThemeColors::from_core(&core);
671        // Light theme should have a light background
672        assert!(colors.bg.r > 0.5);
673    }
674
675    #[test]
676    fn from_theme_fallback_still_works() {
677        let colors = ThemeColors::from_theme(&iced::Theme::Dark);
678        // Should resolve to the Default core theme (dark bg)
679        assert!(colors.bg.r < 0.5);
680    }
681
682    #[test]
683    fn status_color_variants() {
684        let core = gitkraft_core::theme_by_index(0);
685        let c = ThemeColors::from_core(&core);
686        // New / Untracked → green
687        assert_eq!(status_color(&gitkraft_core::FileStatus::New, &c), c.green);
688        assert_eq!(
689            status_color(&gitkraft_core::FileStatus::Untracked, &c),
690            c.green
691        );
692        // Modified → yellow
693        assert_eq!(
694            status_color(&gitkraft_core::FileStatus::Modified, &c),
695            c.yellow
696        );
697        // Deleted → red
698        assert_eq!(status_color(&gitkraft_core::FileStatus::Deleted, &c), c.red);
699        // Renamed → accent
700        assert_eq!(
701            status_color(&gitkraft_core::FileStatus::Renamed, &c),
702            c.accent
703        );
704    }
705
706    #[test]
707    fn status_color_all_variants() {
708        let core = gitkraft_core::theme_by_index(0);
709        let colors = ThemeColors::from_core(&core);
710
711        // Just verify they don't panic and return valid colors
712        let _ = status_color(&gitkraft_core::FileStatus::New, &colors);
713        let _ = status_color(&gitkraft_core::FileStatus::Modified, &colors);
714        let _ = status_color(&gitkraft_core::FileStatus::Deleted, &colors);
715        let _ = status_color(&gitkraft_core::FileStatus::Renamed, &colors);
716        let _ = status_color(&gitkraft_core::FileStatus::Copied, &colors);
717        let _ = status_color(&gitkraft_core::FileStatus::Typechange, &colors);
718        let _ = status_color(&gitkraft_core::FileStatus::Untracked, &colors);
719    }
720
721    #[test]
722    fn clamp_stays_in_range() {
723        assert_eq!(clamp(-0.1), 0.0);
724        assert_eq!(clamp(1.5), 1.0);
725        assert!((clamp(0.5) - 0.5).abs() < f32::EPSILON);
726    }
727
728    #[test]
729    fn shift_and_scale_stay_in_range() {
730        let base = Color {
731            r: 0.9,
732            g: 0.1,
733            b: 0.5,
734            a: 1.0,
735        };
736        let shifted = shift(base, 0.2);
737        assert!(shifted.r <= 1.0 && shifted.g >= 0.0);
738
739        let scaled = scale(base, 2.0);
740        assert!(scaled.r <= 1.0);
741    }
742
743    #[test]
744    fn all_27_core_themes_produce_valid_colors() {
745        for i in 0..gitkraft_core::THEME_COUNT {
746            let core = gitkraft_core::theme_by_index(i);
747            let c = ThemeColors::from_core(&core);
748            // bg channels should be in [0, 1]
749            assert!(
750                c.bg.r >= 0.0 && c.bg.r <= 1.0,
751                "theme {i} bg.r out of range"
752            );
753            assert!(
754                c.bg.g >= 0.0 && c.bg.g <= 1.0,
755                "theme {i} bg.g out of range"
756            );
757            assert!(
758                c.bg.b >= 0.0 && c.bg.b <= 1.0,
759                "theme {i} bg.b out of range"
760            );
761        }
762    }
763
764    #[test]
765    fn graph_colors_populated_for_all_themes() {
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            // All 8 graph lane colours should be valid (channels in [0, 1])
770            for (lane, color) in c.graph_colors.iter().enumerate() {
771                assert!(
772                    color.r >= 0.0 && color.r <= 1.0,
773                    "theme {i} graph_colors[{lane}].r out of range"
774                );
775                assert!(
776                    color.g >= 0.0 && color.g <= 1.0,
777                    "theme {i} graph_colors[{lane}].g out of range"
778                );
779                assert!(
780                    color.b >= 0.0 && color.b <= 1.0,
781                    "theme {i} graph_colors[{lane}].b out of range"
782                );
783            }
784        }
785    }
786
787    #[test]
788    fn graph_colors_are_not_all_identical() {
789        for i in 0..gitkraft_core::THEME_COUNT {
790            let core = gitkraft_core::theme_by_index(i);
791            let c = ThemeColors::from_core(&core);
792            // At least two distinct colours among the 8 lanes
793            let first = c.graph_colors[0];
794            let all_same = c.graph_colors.iter().all(|gc| {
795                (gc.r - first.r).abs() < f32::EPSILON
796                    && (gc.g - first.g).abs() < f32::EPSILON
797                    && (gc.b - first.b).abs() < f32::EPSILON
798            });
799            assert!(!all_same, "theme {i} has all identical graph lane colours");
800        }
801    }
802
803    #[test]
804    fn error_bg_differs_from_plain_bg() {
805        for i in 0..gitkraft_core::THEME_COUNT {
806            let core = gitkraft_core::theme_by_index(i);
807            let c = ThemeColors::from_core(&core);
808            // error_bg should be a tinted version of bg, not identical
809            let same = (c.error_bg.r - c.bg.r).abs() < f32::EPSILON
810                && (c.error_bg.g - c.bg.g).abs() < f32::EPSILON
811                && (c.error_bg.b - c.bg.b).abs() < f32::EPSILON;
812            assert!(
813                !same,
814                "theme {i} error_bg is identical to bg — tint not applied"
815            );
816        }
817    }
818
819    #[test]
820    fn selection_is_valid_color() {
821        for i in 0..gitkraft_core::THEME_COUNT {
822            let core = gitkraft_core::theme_by_index(i);
823            let c = ThemeColors::from_core(&core);
824            assert!(
825                c.selection.r >= 0.0 && c.selection.r <= 1.0,
826                "theme {i} selection.r out of range"
827            );
828            assert!(
829                c.selection.g >= 0.0 && c.selection.g <= 1.0,
830                "theme {i} selection.g out of range"
831            );
832            assert!(
833                c.selection.b >= 0.0 && c.selection.b <= 1.0,
834                "theme {i} selection.b out of range"
835            );
836        }
837    }
838
839    #[test]
840    fn selection_differs_from_bg() {
841        for i in 0..gitkraft_core::THEME_COUNT {
842            let core = gitkraft_core::theme_by_index(i);
843            let c = ThemeColors::from_core(&core);
844            let same = (c.selection.r - c.bg.r).abs() < f32::EPSILON
845                && (c.selection.g - c.bg.g).abs() < f32::EPSILON
846                && (c.selection.b - c.bg.b).abs() < f32::EPSILON;
847            assert!(
848                !same,
849                "theme {i} selection is identical to bg — should be distinguishable"
850            );
851        }
852    }
853
854    // ── row_highlight_color ──────────────────────────────────────────────
855
856    #[test]
857    fn row_highlight_color_default_is_none() {
858        let c = ThemeColors::from_theme(&iced::Theme::Dark);
859        assert_eq!(row_highlight_color(false, false, false, &c), None);
860    }
861
862    #[test]
863    fn row_highlight_color_selected_takes_priority() {
864        let c = ThemeColors::from_theme(&iced::Theme::Dark);
865        assert_eq!(
866            row_highlight_color(true, true, true, &c),
867            Some(c.surface_highlight)
868        );
869    }
870
871    #[test]
872    fn row_highlight_color_range_beats_hover() {
873        let c = ThemeColors::from_theme(&iced::Theme::Dark);
874        let range_color = row_highlight_color(false, true, true, &c);
875        let hover_color = row_highlight_color(false, false, true, &c);
876        assert!(range_color.is_some());
877        assert!(hover_color.is_some());
878        assert_ne!(range_color, hover_color);
879    }
880
881    #[test]
882    fn row_highlight_color_hover_only() {
883        let c = ThemeColors::from_theme(&iced::Theme::Dark);
884        let hover_color = row_highlight_color(false, false, true, &c).unwrap();
885        assert_ne!(hover_color, c.surface);
886        assert_ne!(hover_color, c.surface_highlight);
887    }
888
889    #[test]
890    fn row_highlight_color_consistent_across_themes() {
891        for i in 0..gitkraft_core::THEME_COUNT {
892            let core = gitkraft_core::theme_by_index(i);
893            let c = ThemeColors::from_core(&core);
894            assert_eq!(row_highlight_color(false, false, false, &c), None);
895            assert_eq!(
896                row_highlight_color(true, false, false, &c),
897                Some(c.surface_highlight)
898            );
899        }
900    }
901
902    // ── row_style! macro ─────────────────────────────────────────────────
903
904    #[test]
905    fn row_style_macro_picks_first_matching_condition() {
906        let style_fn = row_style!(
907            true => selected_row_style,
908            true => highlight_row_style,
909        );
910        let theme = iced::Theme::Dark;
911        assert_eq!(style_fn(&theme), selected_row_style(&theme));
912    }
913
914    #[test]
915    fn row_style_macro_falls_through_to_next_condition() {
916        let style_fn = row_style!(
917            false => selected_row_style,
918            true => highlight_row_style,
919        );
920        let theme = iced::Theme::Dark;
921        assert_eq!(style_fn(&theme), highlight_row_style(&theme));
922    }
923
924    #[test]
925    fn row_style_macro_defaults_to_surface_style() {
926        let style_fn = row_style!(
927            false => selected_row_style,
928            false => highlight_row_style,
929        );
930        let theme = iced::Theme::Dark;
931        assert_eq!(style_fn(&theme), surface_style(&theme));
932    }
933
934    #[test]
935    fn row_style_macro_supports_single_condition() {
936        let style_fn = row_style!(true => selected_row_style,);
937        let theme = iced::Theme::Dark;
938        assert_eq!(style_fn(&theme), selected_row_style(&theme));
939    }
940
941    // ── ghost_button_flat ────────────────────────────────────────────────
942    // Regression coverage for the "double highlight" bug: a button embedded
943    // in a row whose background is already state-driven (e.g. the commit
944    // message row) must NEVER paint its own hover/pressed background --
945    // otherwise it visually competes with the surrounding row highlight.
946
947    #[test]
948    fn ghost_button_flat_never_paints_background() {
949        let theme = iced::Theme::Dark;
950        for status in [
951            button::Status::Active,
952            button::Status::Hovered,
953            button::Status::Pressed,
954            button::Status::Disabled,
955        ] {
956            let style = ghost_button_flat(&theme, status);
957            assert!(
958                style.background.is_none(),
959                "ghost_button_flat must never paint a background (status: {status:?})"
960            );
961        }
962    }
963
964    #[test]
965    fn ghost_button_flat_differs_from_ghost_button_when_hovered() {
966        // Sanity check that the two styles are genuinely different for the
967        // Hovered status -- otherwise this fix would be a no-op.
968        let theme = iced::Theme::Dark;
969        let flat = ghost_button_flat(&theme, button::Status::Hovered);
970        let regular = ghost_button(&theme, button::Status::Hovered);
971        assert!(flat.background.is_none());
972        assert!(regular.background.is_some());
973    }
974}