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 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
293pub 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
320pub 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
329pub 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
338pub 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
347pub 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
385pub 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
403pub 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
443pub 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
496pub 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
555pub 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
595pub 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
636pub 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#[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); let colors = ThemeColors::from_core(&core);
658 assert!(colors.bg.r < 0.5);
660 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")); let colors = ThemeColors::from_core(&core);
671 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 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 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 assert_eq!(
694 status_color(&gitkraft_core::FileStatus::Modified, &c),
695 c.yellow
696 );
697 assert_eq!(status_color(&gitkraft_core::FileStatus::Deleted, &c), c.red);
699 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 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 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 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 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 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 #[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 #[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 #[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 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}