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 diff_add_style(theme: &iced::Theme) -> container::Style {
269 let c = ThemeColors::from_theme(theme);
270 container::Style {
271 background: Some(Background::Color(c.diff_add_bg)),
272 ..Default::default()
273 }
274}
275
276pub fn diff_del_style(theme: &iced::Theme) -> container::Style {
278 let c = ThemeColors::from_theme(theme);
279 container::Style {
280 background: Some(Background::Color(c.diff_del_bg)),
281 ..Default::default()
282 }
283}
284
285pub fn diff_hunk_style(theme: &iced::Theme) -> container::Style {
287 let c = ThemeColors::from_theme(theme);
288 container::Style {
289 background: Some(Background::Color(c.diff_hunk_bg)),
290 ..Default::default()
291 }
292}
293
294pub fn ghost_button(theme: &iced::Theme, status: button::Status) -> button::Style {
299 let c = ThemeColors::from_theme(theme);
300 match status {
301 button::Status::Active => button::Style {
302 background: None,
303 text_color: c.text_primary,
304 border: iced::Border::default(),
305 shadow: iced::Shadow::default(),
306 snap: false,
307 },
308 button::Status::Hovered => button::Style {
309 background: Some(Background::Color(c.surface_highlight)),
310 text_color: c.text_primary,
311 border: iced::Border::default(),
312 shadow: iced::Shadow::default(),
313 snap: false,
314 },
315 button::Status::Pressed => button::Style {
316 background: Some(Background::Color(c.border)),
317 text_color: c.text_primary,
318 border: iced::Border::default(),
319 shadow: iced::Shadow::default(),
320 snap: false,
321 },
322 button::Status::Disabled => button::Style {
323 background: None,
324 text_color: c.muted,
325 border: iced::Border::default(),
326 shadow: iced::Shadow::default(),
327 snap: false,
328 },
329 }
330}
331
332pub fn active_tab_button(theme: &iced::Theme, status: button::Status) -> button::Style {
334 let c = ThemeColors::from_theme(theme);
335 let active_border = iced::Border {
336 color: c.accent,
337 width: 0.0,
338 radius: 0.0.into(),
339 };
340 match status {
341 button::Status::Active => button::Style {
342 background: Some(Background::Color(c.surface)),
343 text_color: c.text_primary,
344 border: active_border,
345 shadow: iced::Shadow::default(),
346 snap: false,
347 },
348 button::Status::Hovered => button::Style {
349 background: Some(Background::Color(c.surface_highlight)),
350 text_color: c.text_primary,
351 border: active_border,
352 shadow: iced::Shadow::default(),
353 snap: false,
354 },
355 button::Status::Pressed => button::Style {
356 background: Some(Background::Color(c.border)),
357 text_color: c.text_primary,
358 border: active_border,
359 shadow: iced::Shadow::default(),
360 snap: false,
361 },
362 button::Status::Disabled => button::Style {
363 background: Some(Background::Color(c.surface)),
364 text_color: c.muted,
365 border: active_border,
366 shadow: iced::Shadow::default(),
367 snap: false,
368 },
369 }
370}
371
372pub fn context_menu_item(theme: &iced::Theme, status: button::Status) -> button::Style {
374 let c = ThemeColors::from_theme(theme);
375 match status {
376 button::Status::Active => button::Style {
377 background: None,
378 text_color: c.text_primary,
379 border: iced::Border::default(),
380 shadow: iced::Shadow::default(),
381 snap: false,
382 },
383 button::Status::Hovered => button::Style {
384 background: Some(Background::Color(iced::Color {
385 r: c.accent.r,
386 g: c.accent.g,
387 b: c.accent.b,
388 a: 0.15,
389 })),
390 text_color: c.text_primary,
391 border: iced::Border {
392 color: iced::Color::TRANSPARENT,
393 width: 0.0,
394 radius: 4.0.into(),
395 },
396 shadow: iced::Shadow::default(),
397 snap: false,
398 },
399 button::Status::Pressed => 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.28,
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::Disabled => button::Style {
416 background: None,
417 text_color: c.muted,
418 border: iced::Border::default(),
419 shadow: iced::Shadow::default(),
420 snap: false,
421 },
422 }
423}
424
425pub fn overlay_scrollbar(theme: &iced::Theme, status: scrollable::Status) -> scrollable::Style {
436 let c = ThemeColors::from_theme(theme);
437
438 let hidden = scrollable::Rail {
439 background: None,
440 border: iced::Border::default(),
441 scroller: scrollable::Scroller {
442 background: Background::Color(Color::TRANSPARENT),
443 border: iced::Border::default(),
444 },
445 };
446
447 let thumb = |alpha: f32| scrollable::Rail {
448 background: None,
449 border: iced::Border::default(),
450 scroller: scrollable::Scroller {
451 background: Background::Color(Color {
452 r: c.muted.r,
453 g: c.muted.g,
454 b: c.muted.b,
455 a: alpha,
456 }),
457 border: iced::Border {
458 radius: 3.0.into(),
459 ..Default::default()
460 },
461 },
462 };
463
464 let v_rail = match status {
465 scrollable::Status::Active { .. } => hidden,
466 scrollable::Status::Hovered { .. } => thumb(0.45),
467 scrollable::Status::Dragged { .. } => thumb(0.70),
468 };
469
470 scrollable::Style {
471 container: container::Style::default(),
472 vertical_rail: v_rail,
473 horizontal_rail: hidden,
474 gap: None,
475 auto_scroll: scrollable::AutoScroll {
476 background: Background::Color(Color::TRANSPARENT),
477 border: iced::Border::default(),
478 shadow: iced::Shadow::default(),
479 icon: Color::TRANSPARENT,
480 },
481 }
482}
483
484pub fn toolbar_button(theme: &iced::Theme, status: button::Status) -> button::Style {
486 let c = ThemeColors::from_theme(theme);
487 let border = iced::Border {
488 color: c.border,
489 width: 1.0,
490 radius: 4.0.into(),
491 };
492 match status {
493 button::Status::Active => button::Style {
494 background: Some(Background::Color(c.surface)),
495 text_color: c.text_primary,
496 border,
497 shadow: iced::Shadow::default(),
498 snap: false,
499 },
500 button::Status::Hovered => button::Style {
501 background: Some(Background::Color(c.surface_highlight)),
502 text_color: c.text_primary,
503 border,
504 shadow: iced::Shadow::default(),
505 snap: false,
506 },
507 button::Status::Pressed => button::Style {
508 background: Some(Background::Color(c.border)),
509 text_color: c.text_primary,
510 border,
511 shadow: iced::Shadow::default(),
512 snap: false,
513 },
514 button::Status::Disabled => button::Style {
515 background: Some(Background::Color(c.surface)),
516 text_color: c.muted,
517 border,
518 shadow: iced::Shadow::default(),
519 snap: false,
520 },
521 }
522}
523
524pub fn icon_button(theme: &iced::Theme, status: button::Status) -> button::Style {
526 let c = ThemeColors::from_theme(theme);
527 match status {
528 button::Status::Active => button::Style {
529 background: None,
530 text_color: c.text_secondary,
531 border: iced::Border::default(),
532 shadow: iced::Shadow::default(),
533 snap: false,
534 },
535 button::Status::Hovered => button::Style {
536 background: Some(Background::Color(c.surface_highlight)),
537 text_color: c.text_primary,
538 border: iced::Border {
539 radius: 3.0.into(),
540 ..Default::default()
541 },
542 shadow: iced::Shadow::default(),
543 snap: false,
544 },
545 button::Status::Pressed => button::Style {
546 background: Some(Background::Color(c.border)),
547 text_color: c.text_primary,
548 border: iced::Border {
549 radius: 3.0.into(),
550 ..Default::default()
551 },
552 shadow: iced::Shadow::default(),
553 snap: false,
554 },
555 button::Status::Disabled => button::Style {
556 background: None,
557 text_color: c.muted,
558 border: iced::Border::default(),
559 shadow: iced::Shadow::default(),
560 snap: false,
561 },
562 }
563}
564
565pub fn status_color(status: &gitkraft_core::FileStatus, c: &ThemeColors) -> Color {
569 match status {
570 gitkraft_core::FileStatus::New | gitkraft_core::FileStatus::Untracked => c.green,
571 gitkraft_core::FileStatus::Modified | gitkraft_core::FileStatus::Typechange => c.yellow,
572 gitkraft_core::FileStatus::Deleted => c.red,
573 gitkraft_core::FileStatus::Renamed | gitkraft_core::FileStatus::Copied => c.accent,
574 }
575}
576
577#[cfg(test)]
580mod tests {
581 use super::*;
582
583 #[test]
584 fn from_core_dark_theme() {
585 let core = gitkraft_core::theme_by_index(0); let colors = ThemeColors::from_core(&core);
587 assert!(colors.bg.r < 0.5);
589 assert!(colors.accent.r > 0.0 || colors.accent.g > 0.0 || colors.accent.b > 0.0);
591 assert!(colors.green.g > 0.0);
592 assert!(colors.red.r > 0.0);
593 }
594
595 #[test]
596 fn from_core_light_theme() {
597 let core = gitkraft_core::theme_by_index(11); let colors = ThemeColors::from_core(&core);
599 assert!(colors.bg.r > 0.5);
601 }
602
603 #[test]
604 fn from_theme_fallback_still_works() {
605 let colors = ThemeColors::from_theme(&iced::Theme::Dark);
606 assert!(colors.bg.r < 0.5);
608 }
609
610 #[test]
611 fn status_color_variants() {
612 let core = gitkraft_core::theme_by_index(0);
613 let c = ThemeColors::from_core(&core);
614 assert_eq!(status_color(&gitkraft_core::FileStatus::New, &c), c.green);
616 assert_eq!(
617 status_color(&gitkraft_core::FileStatus::Untracked, &c),
618 c.green
619 );
620 assert_eq!(
622 status_color(&gitkraft_core::FileStatus::Modified, &c),
623 c.yellow
624 );
625 assert_eq!(status_color(&gitkraft_core::FileStatus::Deleted, &c), c.red);
627 assert_eq!(
629 status_color(&gitkraft_core::FileStatus::Renamed, &c),
630 c.accent
631 );
632 }
633
634 #[test]
635 fn clamp_stays_in_range() {
636 assert_eq!(clamp(-0.1), 0.0);
637 assert_eq!(clamp(1.5), 1.0);
638 assert!((clamp(0.5) - 0.5).abs() < f32::EPSILON);
639 }
640
641 #[test]
642 fn shift_and_scale_stay_in_range() {
643 let base = Color {
644 r: 0.9,
645 g: 0.1,
646 b: 0.5,
647 a: 1.0,
648 };
649 let shifted = shift(base, 0.2);
650 assert!(shifted.r <= 1.0 && shifted.g >= 0.0);
651
652 let scaled = scale(base, 2.0);
653 assert!(scaled.r <= 1.0);
654 }
655
656 #[test]
657 fn all_27_core_themes_produce_valid_colors() {
658 for i in 0..gitkraft_core::THEME_COUNT {
659 let core = gitkraft_core::theme_by_index(i);
660 let c = ThemeColors::from_core(&core);
661 assert!(
663 c.bg.r >= 0.0 && c.bg.r <= 1.0,
664 "theme {i} bg.r out of range"
665 );
666 assert!(
667 c.bg.g >= 0.0 && c.bg.g <= 1.0,
668 "theme {i} bg.g out of range"
669 );
670 assert!(
671 c.bg.b >= 0.0 && c.bg.b <= 1.0,
672 "theme {i} bg.b out of range"
673 );
674 }
675 }
676
677 #[test]
678 fn graph_colors_populated_for_all_themes() {
679 for i in 0..gitkraft_core::THEME_COUNT {
680 let core = gitkraft_core::theme_by_index(i);
681 let c = ThemeColors::from_core(&core);
682 for (lane, color) in c.graph_colors.iter().enumerate() {
684 assert!(
685 color.r >= 0.0 && color.r <= 1.0,
686 "theme {i} graph_colors[{lane}].r out of range"
687 );
688 assert!(
689 color.g >= 0.0 && color.g <= 1.0,
690 "theme {i} graph_colors[{lane}].g out of range"
691 );
692 assert!(
693 color.b >= 0.0 && color.b <= 1.0,
694 "theme {i} graph_colors[{lane}].b out of range"
695 );
696 }
697 }
698 }
699
700 #[test]
701 fn graph_colors_are_not_all_identical() {
702 for i in 0..gitkraft_core::THEME_COUNT {
703 let core = gitkraft_core::theme_by_index(i);
704 let c = ThemeColors::from_core(&core);
705 let first = c.graph_colors[0];
707 let all_same = c.graph_colors.iter().all(|gc| {
708 (gc.r - first.r).abs() < f32::EPSILON
709 && (gc.g - first.g).abs() < f32::EPSILON
710 && (gc.b - first.b).abs() < f32::EPSILON
711 });
712 assert!(!all_same, "theme {i} has all identical graph lane colours");
713 }
714 }
715
716 #[test]
717 fn error_bg_differs_from_plain_bg() {
718 for i in 0..gitkraft_core::THEME_COUNT {
719 let core = gitkraft_core::theme_by_index(i);
720 let c = ThemeColors::from_core(&core);
721 let same = (c.error_bg.r - c.bg.r).abs() < f32::EPSILON
723 && (c.error_bg.g - c.bg.g).abs() < f32::EPSILON
724 && (c.error_bg.b - c.bg.b).abs() < f32::EPSILON;
725 assert!(
726 !same,
727 "theme {i} error_bg is identical to bg — tint not applied"
728 );
729 }
730 }
731
732 #[test]
733 fn selection_is_valid_color() {
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 assert!(
738 c.selection.r >= 0.0 && c.selection.r <= 1.0,
739 "theme {i} selection.r out of range"
740 );
741 assert!(
742 c.selection.g >= 0.0 && c.selection.g <= 1.0,
743 "theme {i} selection.g out of range"
744 );
745 assert!(
746 c.selection.b >= 0.0 && c.selection.b <= 1.0,
747 "theme {i} selection.b out of range"
748 );
749 }
750 }
751
752 #[test]
753 fn selection_differs_from_bg() {
754 for i in 0..gitkraft_core::THEME_COUNT {
755 let core = gitkraft_core::theme_by_index(i);
756 let c = ThemeColors::from_core(&core);
757 let same = (c.selection.r - c.bg.r).abs() < f32::EPSILON
758 && (c.selection.g - c.bg.g).abs() < f32::EPSILON
759 && (c.selection.b - c.bg.b).abs() < f32::EPSILON;
760 assert!(
761 !same,
762 "theme {i} selection is identical to bg — should be distinguishable"
763 );
764 }
765 }
766}