1use iced::widget::{button, container, scrollable};
9use iced::{Background, Color};
10use std::cell::RefCell;
11
12#[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
42fn clamp(v: f32) -> f32 {
44 v.clamp(0.0, 1.0)
45}
46
47fn 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#[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
68fn rgb_to_iced(rgb: gitkraft_core::Rgb) -> Color {
70 Color::from_rgb8(rgb.r, rgb.g, rgb.b)
71}
72
73fn 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 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 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 let error_bg = mix(bg, error, tint_amount);
113
114 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 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
174pub 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
185pub 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
194pub 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
203pub 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
212pub 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
221pub 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
245pub 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
258pub 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
267pub fn highlight_row_style(theme: &iced::Theme) -> container::Style {
272 let c = ThemeColors::from_theme(theme);
273 let bg = mix(c.surface, c.selection, 0.30);
275 container::Style {
276 background: Some(Background::Color(bg)),
277 ..Default::default()
278 }
279}
280
281pub 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
290pub 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
299pub 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
308pub 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
346pub 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
386pub 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
439pub 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
498pub 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
538pub 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
579pub 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#[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); let colors = ThemeColors::from_core(&core);
601 assert!(colors.bg.r < 0.5);
603 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); let colors = ThemeColors::from_core(&core);
613 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 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 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 assert_eq!(
636 status_color(&gitkraft_core::FileStatus::Modified, &c),
637 c.yellow
638 );
639 assert_eq!(status_color(&gitkraft_core::FileStatus::Deleted, &c), c.red);
641 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 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 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 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 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 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}