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_context: Color,
38 pub diff_hunk_bg: Color,
39 pub error_bg: Color,
40 pub graph_colors: [Color; 8],
41}
42
43fn clamp(v: f32) -> f32 {
45 v.clamp(0.0, 1.0)
46}
47
48fn 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#[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
69fn rgb_to_iced(rgb: gitkraft_core::Rgb) -> Color {
71 Color::from_rgb8(rgb.r, rgb.g, rgb.b)
72}
73
74fn 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 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 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 let error_bg = mix(bg, error, tint_amount);
114
115 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 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
176pub 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
187pub 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
196pub 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
205pub 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
214pub 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
223pub 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
247pub 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
260pub 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
269pub fn highlight_row_style(theme: &iced::Theme) -> container::Style {
274 let c = ThemeColors::from_theme(theme);
275 let bg = mix(c.surface, c.selection, 0.30);
277 container::Style {
278 background: Some(Background::Color(bg)),
279 ..Default::default()
280 }
281}
282
283pub 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
292pub 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
301pub 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
310pub 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
348pub 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
388pub 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
441pub 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
500pub 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
540pub 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
581pub 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#[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); let colors = ThemeColors::from_core(&core);
603 assert!(colors.bg.r < 0.5);
605 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")); let colors = ThemeColors::from_core(&core);
616 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 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 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 assert_eq!(
639 status_color(&gitkraft_core::FileStatus::Modified, &c),
640 c.yellow
641 );
642 assert_eq!(status_color(&gitkraft_core::FileStatus::Deleted, &c), c.red);
644 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 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 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 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 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 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}