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 },
307 button::Status::Hovered => button::Style {
308 background: Some(Background::Color(c.surface_highlight)),
309 text_color: c.text_primary,
310 border: iced::Border::default(),
311 shadow: iced::Shadow::default(),
312 },
313 button::Status::Pressed => button::Style {
314 background: Some(Background::Color(c.border)),
315 text_color: c.text_primary,
316 border: iced::Border::default(),
317 shadow: iced::Shadow::default(),
318 },
319 button::Status::Disabled => button::Style {
320 background: None,
321 text_color: c.muted,
322 border: iced::Border::default(),
323 shadow: iced::Shadow::default(),
324 },
325 }
326}
327
328pub fn active_tab_button(theme: &iced::Theme, status: button::Status) -> button::Style {
330 let c = ThemeColors::from_theme(theme);
331 let active_border = iced::Border {
332 color: c.accent,
333 width: 0.0,
334 radius: 0.0.into(),
335 };
336 match status {
337 button::Status::Active => button::Style {
338 background: Some(Background::Color(c.surface)),
339 text_color: c.text_primary,
340 border: active_border,
341 shadow: iced::Shadow::default(),
342 },
343 button::Status::Hovered => button::Style {
344 background: Some(Background::Color(c.surface_highlight)),
345 text_color: c.text_primary,
346 border: active_border,
347 shadow: iced::Shadow::default(),
348 },
349 button::Status::Pressed => button::Style {
350 background: Some(Background::Color(c.border)),
351 text_color: c.text_primary,
352 border: active_border,
353 shadow: iced::Shadow::default(),
354 },
355 button::Status::Disabled => button::Style {
356 background: Some(Background::Color(c.surface)),
357 text_color: c.muted,
358 border: active_border,
359 shadow: iced::Shadow::default(),
360 },
361 }
362}
363
364pub fn context_menu_item(theme: &iced::Theme, status: button::Status) -> button::Style {
366 let c = ThemeColors::from_theme(theme);
367 match status {
368 button::Status::Active => button::Style {
369 background: None,
370 text_color: c.text_primary,
371 border: iced::Border::default(),
372 shadow: iced::Shadow::default(),
373 },
374 button::Status::Hovered => button::Style {
375 background: Some(Background::Color(iced::Color {
376 r: c.accent.r,
377 g: c.accent.g,
378 b: c.accent.b,
379 a: 0.15,
380 })),
381 text_color: c.text_primary,
382 border: iced::Border {
383 color: iced::Color::TRANSPARENT,
384 width: 0.0,
385 radius: 4.0.into(),
386 },
387 shadow: iced::Shadow::default(),
388 },
389 button::Status::Pressed => button::Style {
390 background: Some(Background::Color(iced::Color {
391 r: c.accent.r,
392 g: c.accent.g,
393 b: c.accent.b,
394 a: 0.28,
395 })),
396 text_color: c.text_primary,
397 border: iced::Border {
398 color: iced::Color::TRANSPARENT,
399 width: 0.0,
400 radius: 4.0.into(),
401 },
402 shadow: iced::Shadow::default(),
403 },
404 button::Status::Disabled => button::Style {
405 background: None,
406 text_color: c.muted,
407 border: iced::Border::default(),
408 shadow: iced::Shadow::default(),
409 },
410 }
411}
412
413pub fn overlay_scrollbar(theme: &iced::Theme, status: scrollable::Status) -> scrollable::Style {
424 let c = ThemeColors::from_theme(theme);
425
426 let hidden = scrollable::Rail {
427 background: None,
428 border: iced::Border::default(),
429 scroller: scrollable::Scroller {
430 color: Color::TRANSPARENT,
431 border: iced::Border::default(),
432 },
433 };
434
435 let thumb = |alpha: f32| scrollable::Rail {
436 background: None,
437 border: iced::Border::default(),
438 scroller: scrollable::Scroller {
439 color: Color {
440 r: c.muted.r,
441 g: c.muted.g,
442 b: c.muted.b,
443 a: alpha,
444 },
445 border: iced::Border {
446 radius: 3.0.into(),
447 ..Default::default()
448 },
449 },
450 };
451
452 let v_rail = match status {
453 scrollable::Status::Active => hidden,
454 scrollable::Status::Hovered { .. } => thumb(0.45),
455 scrollable::Status::Dragged { .. } => thumb(0.70),
456 };
457
458 scrollable::Style {
459 container: container::Style::default(),
460 vertical_rail: v_rail,
461 horizontal_rail: hidden,
462 gap: None,
463 }
464}
465
466pub fn toolbar_button(theme: &iced::Theme, status: button::Status) -> button::Style {
468 let c = ThemeColors::from_theme(theme);
469 let border = iced::Border {
470 color: c.border,
471 width: 1.0,
472 radius: 4.0.into(),
473 };
474 match status {
475 button::Status::Active => button::Style {
476 background: Some(Background::Color(c.surface)),
477 text_color: c.text_primary,
478 border,
479 shadow: iced::Shadow::default(),
480 },
481 button::Status::Hovered => button::Style {
482 background: Some(Background::Color(c.surface_highlight)),
483 text_color: c.text_primary,
484 border,
485 shadow: iced::Shadow::default(),
486 },
487 button::Status::Pressed => button::Style {
488 background: Some(Background::Color(c.border)),
489 text_color: c.text_primary,
490 border,
491 shadow: iced::Shadow::default(),
492 },
493 button::Status::Disabled => button::Style {
494 background: Some(Background::Color(c.surface)),
495 text_color: c.muted,
496 border,
497 shadow: iced::Shadow::default(),
498 },
499 }
500}
501
502pub fn icon_button(theme: &iced::Theme, status: button::Status) -> button::Style {
504 let c = ThemeColors::from_theme(theme);
505 match status {
506 button::Status::Active => button::Style {
507 background: None,
508 text_color: c.text_secondary,
509 border: iced::Border::default(),
510 shadow: iced::Shadow::default(),
511 },
512 button::Status::Hovered => button::Style {
513 background: Some(Background::Color(c.surface_highlight)),
514 text_color: c.text_primary,
515 border: iced::Border {
516 radius: 3.0.into(),
517 ..Default::default()
518 },
519 shadow: iced::Shadow::default(),
520 },
521 button::Status::Pressed => button::Style {
522 background: Some(Background::Color(c.border)),
523 text_color: c.text_primary,
524 border: iced::Border {
525 radius: 3.0.into(),
526 ..Default::default()
527 },
528 shadow: iced::Shadow::default(),
529 },
530 button::Status::Disabled => button::Style {
531 background: None,
532 text_color: c.muted,
533 border: iced::Border::default(),
534 shadow: iced::Shadow::default(),
535 },
536 }
537}
538
539pub fn status_color(status: &gitkraft_core::FileStatus, c: &ThemeColors) -> Color {
543 match status {
544 gitkraft_core::FileStatus::New | gitkraft_core::FileStatus::Untracked => c.green,
545 gitkraft_core::FileStatus::Modified | gitkraft_core::FileStatus::Typechange => c.yellow,
546 gitkraft_core::FileStatus::Deleted => c.red,
547 gitkraft_core::FileStatus::Renamed | gitkraft_core::FileStatus::Copied => c.accent,
548 }
549}
550
551#[cfg(test)]
554mod tests {
555 use super::*;
556
557 #[test]
558 fn from_core_dark_theme() {
559 let core = gitkraft_core::theme_by_index(0); let colors = ThemeColors::from_core(&core);
561 assert!(colors.bg.r < 0.5);
563 assert!(colors.accent.r > 0.0 || colors.accent.g > 0.0 || colors.accent.b > 0.0);
565 assert!(colors.green.g > 0.0);
566 assert!(colors.red.r > 0.0);
567 }
568
569 #[test]
570 fn from_core_light_theme() {
571 let core = gitkraft_core::theme_by_index(11); let colors = ThemeColors::from_core(&core);
573 assert!(colors.bg.r > 0.5);
575 }
576
577 #[test]
578 fn from_theme_fallback_still_works() {
579 let colors = ThemeColors::from_theme(&iced::Theme::Dark);
580 assert!(colors.bg.r < 0.5);
582 }
583
584 #[test]
585 fn status_color_variants() {
586 let core = gitkraft_core::theme_by_index(0);
587 let c = ThemeColors::from_core(&core);
588 assert_eq!(status_color(&gitkraft_core::FileStatus::New, &c), c.green);
590 assert_eq!(
591 status_color(&gitkraft_core::FileStatus::Untracked, &c),
592 c.green
593 );
594 assert_eq!(
596 status_color(&gitkraft_core::FileStatus::Modified, &c),
597 c.yellow
598 );
599 assert_eq!(status_color(&gitkraft_core::FileStatus::Deleted, &c), c.red);
601 assert_eq!(
603 status_color(&gitkraft_core::FileStatus::Renamed, &c),
604 c.accent
605 );
606 }
607
608 #[test]
609 fn clamp_stays_in_range() {
610 assert_eq!(clamp(-0.1), 0.0);
611 assert_eq!(clamp(1.5), 1.0);
612 assert!((clamp(0.5) - 0.5).abs() < f32::EPSILON);
613 }
614
615 #[test]
616 fn shift_and_scale_stay_in_range() {
617 let base = Color {
618 r: 0.9,
619 g: 0.1,
620 b: 0.5,
621 a: 1.0,
622 };
623 let shifted = shift(base, 0.2);
624 assert!(shifted.r <= 1.0 && shifted.g >= 0.0);
625
626 let scaled = scale(base, 2.0);
627 assert!(scaled.r <= 1.0);
628 }
629
630 #[test]
631 fn all_27_core_themes_produce_valid_colors() {
632 for i in 0..gitkraft_core::THEME_COUNT {
633 let core = gitkraft_core::theme_by_index(i);
634 let c = ThemeColors::from_core(&core);
635 assert!(
637 c.bg.r >= 0.0 && c.bg.r <= 1.0,
638 "theme {i} bg.r out of range"
639 );
640 assert!(
641 c.bg.g >= 0.0 && c.bg.g <= 1.0,
642 "theme {i} bg.g out of range"
643 );
644 assert!(
645 c.bg.b >= 0.0 && c.bg.b <= 1.0,
646 "theme {i} bg.b out of range"
647 );
648 }
649 }
650
651 #[test]
652 fn graph_colors_populated_for_all_themes() {
653 for i in 0..gitkraft_core::THEME_COUNT {
654 let core = gitkraft_core::theme_by_index(i);
655 let c = ThemeColors::from_core(&core);
656 for (lane, color) in c.graph_colors.iter().enumerate() {
658 assert!(
659 color.r >= 0.0 && color.r <= 1.0,
660 "theme {i} graph_colors[{lane}].r out of range"
661 );
662 assert!(
663 color.g >= 0.0 && color.g <= 1.0,
664 "theme {i} graph_colors[{lane}].g out of range"
665 );
666 assert!(
667 color.b >= 0.0 && color.b <= 1.0,
668 "theme {i} graph_colors[{lane}].b out of range"
669 );
670 }
671 }
672 }
673
674 #[test]
675 fn graph_colors_are_not_all_identical() {
676 for i in 0..gitkraft_core::THEME_COUNT {
677 let core = gitkraft_core::theme_by_index(i);
678 let c = ThemeColors::from_core(&core);
679 let first = c.graph_colors[0];
681 let all_same = c.graph_colors.iter().all(|gc| {
682 (gc.r - first.r).abs() < f32::EPSILON
683 && (gc.g - first.g).abs() < f32::EPSILON
684 && (gc.b - first.b).abs() < f32::EPSILON
685 });
686 assert!(!all_same, "theme {i} has all identical graph lane colours");
687 }
688 }
689
690 #[test]
691 fn error_bg_differs_from_plain_bg() {
692 for i in 0..gitkraft_core::THEME_COUNT {
693 let core = gitkraft_core::theme_by_index(i);
694 let c = ThemeColors::from_core(&core);
695 let same = (c.error_bg.r - c.bg.r).abs() < f32::EPSILON
697 && (c.error_bg.g - c.bg.g).abs() < f32::EPSILON
698 && (c.error_bg.b - c.bg.b).abs() < f32::EPSILON;
699 assert!(
700 !same,
701 "theme {i} error_bg is identical to bg — tint not applied"
702 );
703 }
704 }
705
706 #[test]
707 fn selection_is_valid_color() {
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 assert!(
712 c.selection.r >= 0.0 && c.selection.r <= 1.0,
713 "theme {i} selection.r out of range"
714 );
715 assert!(
716 c.selection.g >= 0.0 && c.selection.g <= 1.0,
717 "theme {i} selection.g out of range"
718 );
719 assert!(
720 c.selection.b >= 0.0 && c.selection.b <= 1.0,
721 "theme {i} selection.b out of range"
722 );
723 }
724 }
725
726 #[test]
727 fn selection_differs_from_bg() {
728 for i in 0..gitkraft_core::THEME_COUNT {
729 let core = gitkraft_core::theme_by_index(i);
730 let c = ThemeColors::from_core(&core);
731 let same = (c.selection.r - c.bg.r).abs() < f32::EPSILON
732 && (c.selection.g - c.bg.g).abs() < f32::EPSILON
733 && (c.selection.b - c.bg.b).abs() < f32::EPSILON;
734 assert!(
735 !same,
736 "theme {i} selection is identical to bg — should be distinguishable"
737 );
738 }
739 }
740}