1use ashpd::desktop::Color;
10use ashpd::desktop::settings::{ColorScheme, Contrast, ReducedMotion};
11
12use crate::model::{DialogButtonOrder, FontSpec};
13
14const WEIGHT_MODIFIERS: &[(&str, u16)] = &[
16 ("Ultra-Bold", 800),
17 ("UltraBold", 800),
18 ("Extra-Bold", 800),
19 ("ExtraBold", 800),
20 ("Semi-Bold", 600),
21 ("SemiBold", 600),
22 ("Demi-Bold", 600),
23 ("DemiBold", 600),
24 ("Ultra-Light", 200),
25 ("UltraLight", 200),
26 ("Extra-Light", 200),
27 ("ExtraLight", 200),
28 ("Thin", 100),
29 ("Light", 300),
30 ("Regular", 400),
31 ("Normal", 400),
32 ("Medium", 500),
33 ("Bold", 700),
34 ("Black", 900),
35 ("Heavy", 900),
36];
37
38#[cfg(any(feature = "kde", test))]
42pub(crate) fn portal_color_to_rgba(color: &Color) -> Option<crate::Rgba> {
43 let r = color.red();
44 let g = color.green();
45 let b = color.blue();
46
47 if !(0.0..=1.0).contains(&r) || !(0.0..=1.0).contains(&g) || !(0.0..=1.0).contains(&b) {
49 return None;
50 }
51
52 Some(crate::Rgba::from_f32(r as f32, g as f32, b as f32, 1.0))
53}
54
55fn apply_accent(variant: &mut crate::ThemeMode, accent: &crate::Rgba) {
57 variant.defaults.accent_color = Some(*accent);
58 variant.defaults.selection_background = Some(*accent);
59 variant.defaults.focus_ring_color = Some(*accent);
60 variant.tab.active_indicator_color = Some(*accent);
62}
63
64pub(crate) fn parse_gnome_font_to_fontspec(s: &str) -> Option<FontSpec> {
77 let trimmed = s.trim().trim_matches('\'');
78 if trimmed.is_empty() {
79 return None;
80 }
81
82 let (before_size, size_str) = trimmed.rsplit_once(' ')?;
84 let before_size = before_size.trim();
85 let size: f32 = size_str.parse().ok()?;
86 if before_size.is_empty() || size <= 0.0 {
87 return None;
88 }
89
90 let (family, weight) = extract_weight_from_family(before_size);
92
93 if family.is_empty() {
94 return None;
95 }
96
97 Some(FontSpec {
98 family: Some(family.into()),
99 size: Some(crate::model::font::FontSize::Pt(size)),
100 weight: Some(weight),
101 ..Default::default()
102 })
103}
104
105fn extract_weight_from_family(s: &str) -> (&str, u16) {
110 for &(modifier, weight) in WEIGHT_MODIFIERS {
111 if let Some(prefix) = s.strip_suffix(modifier) {
112 let family = prefix.trim_end();
113 if !family.is_empty() {
114 return (family, weight);
115 }
116 }
117 }
118 (s, 400)
120}
121
122fn read_gsetting(schema: &str, key: &str) -> Option<String> {
128 let raw = crate::detect::gsettings_get(schema, key)?;
129 let trimmed = raw.trim_matches('\'').to_string();
130 if trimmed.is_empty() {
131 None
132 } else {
133 Some(trimmed)
134 }
135}
136
137fn detect_font_dpi() -> f32 {
144 if let Some(dpi) = crate::detect::xft_dpi() {
145 return dpi;
146 }
147 if let Some(dpi) = crate::detect::physical_dpi() {
149 return dpi;
150 }
151 96.0
153}
154
155pub struct GnomePortalData {
161 pub is_dark: bool,
163 pub accent_rgb: Option<(f64, f64, f64)>,
166 pub high_contrast: bool,
168 pub reduce_motion: Option<bool>,
170 pub font_name: Option<String>,
172 pub monospace_font_name: Option<String>,
174 pub titlebar_font: Option<String>,
176 pub text_scaling_factor: Option<f32>,
178 pub font_dpi: f32,
180 pub overlay_scrolling: Option<bool>,
182 pub icon_theme: Option<String>,
184 pub gsettings_high_contrast: Option<bool>,
187 pub gsettings_enable_animations: Option<bool>,
190}
191
192fn accessibility_from_gnome_data(data: &GnomePortalData) -> crate::AccessibilityPreferences {
198 let mut acc = crate::AccessibilityPreferences::default();
199 let effective_high_contrast = data.high_contrast || data.gsettings_high_contrast == Some(true);
200 let effective_reduce_motion = data
201 .reduce_motion
202 .or_else(|| data.gsettings_enable_animations.map(|anim| !anim));
203 if let Some(tsf) = data.text_scaling_factor {
204 acc.text_scaling_factor = tsf;
205 }
206 if let Some(rm) = effective_reduce_motion {
207 acc.reduce_motion = rm;
208 }
209 acc.high_contrast = effective_high_contrast;
210 acc
212}
213
214fn build_gnome_variant_pure(data: &GnomePortalData) -> crate::ThemeMode {
217 let mut variant = crate::ThemeMode::default();
218
219 if let Some((r, g, b)) = data.accent_rgb
221 && (0.0..=1.0).contains(&r)
222 && (0.0..=1.0).contains(&g)
223 && (0.0..=1.0).contains(&b)
224 {
225 let rgba = crate::Rgba::from_f32(r as f32, g as f32, b as f32, 1.0);
226 apply_accent(&mut variant, &rgba);
227 }
228
229 if let Some(ref font_str) = data.font_name
236 && let Some(fs) = parse_gnome_font_to_fontspec(font_str)
237 {
238 variant.defaults.font = fs;
239 }
240
241 if let Some(ref mono_str) = data.monospace_font_name
243 && let Some(fs) = parse_gnome_font_to_fontspec(mono_str)
244 {
245 variant.defaults.mono_font = fs;
246 }
247
248 if let Some(ref tb_str) = data.titlebar_font
250 && let Some(fs) = parse_gnome_font_to_fontspec(tb_str)
251 {
252 variant.window.title_bar_font = Some(fs);
253 }
254
255 if let Some(overlay) = data.overlay_scrolling {
261 variant.scrollbar.overlay_mode = Some(overlay);
262 }
263
264 variant.dialog.button_order = Some(DialogButtonOrder::PrimaryRight);
269
270 variant
271}
272
273#[cfg(test)]
279fn build_gnome_spec_pure(data: &GnomePortalData) -> crate::Result<crate::ReaderResult> {
280 let base = crate::Theme::preset("adwaita")?;
281 let is_dark = data.is_dark;
282
283 let mut variant = if is_dark {
284 base.dark.unwrap_or_default()
285 } else {
286 base.light.unwrap_or_default()
287 };
288
289 let os_variant = build_gnome_variant_pure(data);
290 variant.merge(&os_variant);
291
292 let acc = accessibility_from_gnome_data(data);
293
294 variant.defaults.icon_theme = data.icon_theme.clone().map(std::borrow::Cow::Owned);
296
297 let output = crate::ReaderOutput::Single {
298 mode: Box::new(variant),
299 is_dark,
300 };
301
302 Ok(crate::ReaderResult {
303 output,
304 name: "GNOME".into(),
305 icon_set: None,
306 layout: crate::LayoutTheme::default(),
307 font_dpi: Some(data.font_dpi),
308 accessibility: acc,
309 })
310}
311
312pub(crate) fn build_gnome_variant(
320 scheme: ColorScheme,
321 accent: Option<Color>,
322 contrast: Contrast,
323 reduced_motion: Option<ReducedMotion>,
324) -> (crate::ThemeMode, crate::AccessibilityPreferences, f32) {
325 let accent_rgb = accent.as_ref().map(|c| {
327 let (r, g, b) = (c.red(), c.green(), c.blue());
328 (r, g, b)
329 });
330
331 let high_contrast = matches!(contrast, Contrast::High);
332
333 let reduce_motion = reduced_motion.map(|rm| match rm {
334 ReducedMotion::ReducedMotion => true,
335 ReducedMotion::NoPreference => false,
336 });
337
338 let font_name = read_gsetting("org.gnome.desktop.interface", "font-name");
340 let monospace_font_name = read_gsetting("org.gnome.desktop.interface", "monospace-font-name");
341 let titlebar_font = read_gsetting("org.gnome.desktop.wm.preferences", "titlebar-font");
342
343 let text_scaling_factor = read_gsetting("org.gnome.desktop.interface", "text-scaling-factor")
344 .and_then(|s| s.parse::<f32>().ok());
345
346 let font_dpi = detect_font_dpi();
347
348 let gsettings_high_contrast =
349 read_gsetting("org.gnome.desktop.a11y.interface", "high-contrast").and_then(|s| {
350 match s.as_str() {
351 "true" => Some(true),
352 "false" => Some(false),
353 _ => None,
354 }
355 });
356
357 let gsettings_enable_animations =
358 read_gsetting("org.gnome.desktop.interface", "enable-animations").and_then(|s| {
359 match s.as_str() {
360 "true" => Some(true),
361 "false" => Some(false),
362 _ => None,
363 }
364 });
365
366 let overlay_scrolling = read_gsetting("org.gnome.desktop.interface", "overlay-scrolling")
367 .and_then(|s| match s.as_str() {
368 "true" => Some(true),
369 "false" => Some(false),
370 _ => None,
371 });
372
373 let icon_theme = read_gsetting("org.gnome.desktop.interface", "icon-theme");
374
375 let data = GnomePortalData {
376 is_dark: false, accent_rgb,
378 high_contrast,
379 reduce_motion,
380 font_name,
381 monospace_font_name,
382 titlebar_font,
383 text_scaling_factor,
384 font_dpi,
385 overlay_scrolling,
386 icon_theme,
387 gsettings_high_contrast,
388 gsettings_enable_animations,
389 };
390
391 let _ = scheme; let acc = accessibility_from_gnome_data(&data);
394 let dpi = data.font_dpi;
395 (build_gnome_variant_pure(&data), acc, dpi)
396}
397
398pub(crate) fn build_theme(
404 base: crate::Theme,
405 scheme: ColorScheme,
406 accent: Option<Color>,
407 contrast: Contrast,
408 reduced_motion: Option<ReducedMotion>,
409) -> crate::Result<crate::ReaderResult> {
410 let is_dark = matches!(scheme, ColorScheme::PreferDark);
411
412 let mut variant = if is_dark {
416 base.dark.unwrap_or_default()
417 } else {
418 base.light.unwrap_or_default()
419 };
420
421 let (os_variant, acc, font_dpi) = build_gnome_variant(scheme, accent, contrast, reduced_motion);
423 variant.merge(&os_variant);
424
425 variant.defaults.icon_theme =
427 read_gsetting("org.gnome.desktop.interface", "icon-theme").map(std::borrow::Cow::Owned);
428
429 let output = crate::ReaderOutput::Single {
430 mode: Box::new(variant),
431 is_dark,
432 };
433
434 Ok(crate::ReaderResult {
435 output,
436 name: "GNOME".into(),
437 icon_set: None,
438 layout: crate::LayoutTheme::default(),
439 font_dpi: Some(font_dpi),
440 accessibility: acc,
441 })
442}
443
444pub(crate) struct GnomeReader;
456
457#[async_trait::async_trait]
458impl crate::reader::ThemeReader for GnomeReader {
459 async fn read(&self) -> crate::Result<crate::ReaderResult> {
460 let base = crate::Theme::preset("adwaita")?;
461
462 let settings = match ashpd::desktop::settings::Settings::new().await {
464 Ok(s) => s,
465 Err(_) => {
466 return build_theme(
467 base,
468 ColorScheme::NoPreference,
469 None,
470 Contrast::NoPreference,
471 None,
472 );
473 }
474 };
475
476 let scheme = settings.color_scheme().await.unwrap_or_default();
478 let accent = settings.accent_color().await.ok();
479 let contrast = settings.contrast().await.unwrap_or_default();
480 let reduced_motion = settings.reduced_motion().await.ok();
481
482 build_theme(base, scheme, accent, contrast, reduced_motion)
483 }
484}
485
486#[cfg(feature = "kde")]
503pub(crate) struct GnomePortalKdeReader;
504
505#[cfg(feature = "kde")]
506#[async_trait::async_trait]
507impl crate::reader::ThemeReader for GnomePortalKdeReader {
508 async fn read(&self) -> crate::Result<crate::ReaderResult> {
509 let mut result =
513 <crate::kde::KdeReader as crate::reader::ThemeReader>::read(&crate::kde::KdeReader)
514 .await?;
515
516 let settings = match ashpd::desktop::settings::Settings::new().await {
518 Ok(s) => s,
519 Err(_) => return Ok(result),
520 };
521
522 let accent = match settings.accent_color().await {
523 Ok(color) => color,
524 Err(_) => return Ok(result),
525 };
526
527 let rgba = match portal_color_to_rgba(&accent) {
528 Some(r) => r,
529 None => return Ok(result),
530 };
531
532 match &mut result.output {
534 crate::ReaderOutput::Single { mode, .. } => {
535 apply_accent(mode, &rgba);
536 }
537 crate::ReaderOutput::Dual { light, dark } => {
538 apply_accent(light, &rgba);
539 apply_accent(dark, &rgba);
540 }
541 }
542
543 Ok(result)
544 }
545}
546
547pub(crate) async fn detect_portal_backend() -> Option<super::LinuxDesktop> {
556 let connection = ashpd::zbus::Connection::session().await.ok()?;
557 let proxy = ashpd::zbus::fdo::DBusProxy::new(&connection).await.ok()?;
558 let names = proxy.list_activatable_names().await.ok()?;
559
560 for name in &names {
561 let name_str = name.as_str();
562 if name_str.contains("portal.desktop.kde") {
563 return Some(super::LinuxDesktop::Kde);
564 }
565 if name_str.contains("portal.desktop.gnome") {
566 return Some(super::LinuxDesktop::Gnome);
567 }
568 }
569
570 None
571}
572
573#[cfg(test)]
574#[allow(clippy::unwrap_used, clippy::expect_used)]
575mod tests {
576 use super::*;
577 use crate::model::font::FontSize;
578
579 #[test]
582 fn fontspec_bold_weight() {
583 let fs = parse_gnome_font_to_fontspec("Cantarell Bold 11").unwrap();
584 assert_eq!(fs.family.as_deref(), Some("Cantarell"));
585 assert_eq!(fs.size, Some(FontSize::Pt(11.0)));
586 assert_eq!(fs.weight, Some(700));
587 }
588
589 #[test]
590 fn fontspec_light_weight() {
591 let fs = parse_gnome_font_to_fontspec("Inter Light 10").unwrap();
592 assert_eq!(fs.family.as_deref(), Some("Inter"));
593 assert_eq!(fs.size, Some(FontSize::Pt(10.0)));
594 assert_eq!(fs.weight, Some(300));
595 }
596
597 #[test]
598 fn fontspec_semi_bold_weight() {
599 let fs = parse_gnome_font_to_fontspec("Noto Sans Semi-Bold 12").unwrap();
600 assert_eq!(fs.family.as_deref(), Some("Noto Sans"));
601 assert_eq!(fs.size, Some(FontSize::Pt(12.0)));
602 assert_eq!(fs.weight, Some(600));
603 }
604
605 #[test]
606 fn fontspec_no_modifier_defaults_to_regular() {
607 let fs = parse_gnome_font_to_fontspec("Cantarell 11").unwrap();
608 assert_eq!(fs.family.as_deref(), Some("Cantarell"));
609 assert_eq!(fs.size, Some(FontSize::Pt(11.0)));
610 assert_eq!(fs.weight, Some(400));
611 }
612
613 #[test]
614 fn fontspec_medium_weight() {
615 let fs = parse_gnome_font_to_fontspec("Inter Medium 10.5").unwrap();
616 assert_eq!(fs.family.as_deref(), Some("Inter"));
617 assert_eq!(fs.size, Some(FontSize::Pt(10.5)));
618 assert_eq!(fs.weight, Some(500));
619 }
620
621 #[test]
622 fn fontspec_heavy_weight() {
623 let fs = parse_gnome_font_to_fontspec("'Fira Sans Heavy 14'").unwrap();
624 assert_eq!(fs.family.as_deref(), Some("Fira Sans"));
625 assert_eq!(fs.size, Some(FontSize::Pt(14.0)));
626 assert_eq!(fs.weight, Some(900));
627 }
628
629 #[test]
630 fn fontspec_thin_weight() {
631 let fs = parse_gnome_font_to_fontspec("Roboto Thin 12").unwrap();
632 assert_eq!(fs.family.as_deref(), Some("Roboto"));
633 assert_eq!(fs.size, Some(FontSize::Pt(12.0)));
634 assert_eq!(fs.weight, Some(100));
635 }
636
637 #[test]
638 fn fontspec_extra_bold_weight() {
639 let fs = parse_gnome_font_to_fontspec("Source Sans Extra-Bold 11").unwrap();
640 assert_eq!(fs.family.as_deref(), Some("Source Sans"));
641 assert_eq!(fs.size, Some(FontSize::Pt(11.0)));
642 assert_eq!(fs.weight, Some(800));
643 }
644
645 #[test]
646 fn fontspec_ultra_light_weight() {
647 let fs = parse_gnome_font_to_fontspec("Noto Sans Ultra-Light 10").unwrap();
648 assert_eq!(fs.family.as_deref(), Some("Noto Sans"));
649 assert_eq!(fs.size, Some(FontSize::Pt(10.0)));
650 assert_eq!(fs.weight, Some(200));
651 }
652
653 #[test]
654 fn fontspec_empty_returns_none() {
655 assert!(parse_gnome_font_to_fontspec("").is_none());
656 }
657
658 #[test]
659 fn fontspec_with_quotes() {
660 let fs = parse_gnome_font_to_fontspec("'Cantarell Bold 11'").unwrap();
661 assert_eq!(fs.family.as_deref(), Some("Cantarell"));
662 assert_eq!(fs.weight, Some(700));
663 }
664
665 #[test]
668 fn portal_color_valid_converts_to_rgba() {
669 let color = Color::new(0.2, 0.4, 0.6);
670 let result = portal_color_to_rgba(&color);
671 assert!(result.is_some());
672 let rgba = result.unwrap();
673 assert_eq!(rgba, crate::Rgba::from_f32(0.2, 0.4, 0.6, 1.0));
674 }
675
676 #[test]
677 fn portal_color_out_of_range_high_returns_none() {
678 let color = Color::new(1.5, 0.0, 0.0);
679 assert!(portal_color_to_rgba(&color).is_none());
680 }
681
682 #[test]
683 fn portal_color_out_of_range_negative_returns_none() {
684 let color = Color::new(-0.1, 0.5, 0.5);
685 assert!(portal_color_to_rgba(&color).is_none());
686 }
687
688 #[test]
689 fn portal_color_to_rgba_boundary_zero() {
690 let color = Color::new(0.0, 0.0, 0.0);
691 let result = portal_color_to_rgba(&color);
692 assert!(result.is_some());
693 assert_eq!(result.unwrap(), crate::Rgba::from_f32(0.0, 0.0, 0.0, 1.0));
694 }
695
696 #[test]
697 fn portal_color_to_rgba_boundary_one() {
698 let color = Color::new(1.0, 1.0, 1.0);
699 let result = portal_color_to_rgba(&color);
700 assert!(result.is_some());
701 assert_eq!(result.unwrap(), crate::Rgba::from_f32(1.0, 1.0, 1.0, 1.0));
702 }
703
704 #[test]
707 fn build_gnome_variant_default_has_dialog_button_order() {
708 let (v, _acc, _dpi) = build_gnome_variant(
709 ColorScheme::NoPreference,
710 None,
711 Contrast::NoPreference,
712 None,
713 );
714 assert_eq!(v.dialog.button_order, Some(DialogButtonOrder::PrimaryRight),);
715 }
716
717 #[test]
718 fn build_gnome_variant_high_contrast_sets_flag() {
719 let (_v, _acc, _dpi) =
720 build_gnome_variant(ColorScheme::NoPreference, None, Contrast::High, None);
721 }
723
724 #[test]
725 fn build_gnome_variant_normal_contrast_no_flag() {
726 let (_v, _acc, _dpi) = build_gnome_variant(
727 ColorScheme::NoPreference,
728 None,
729 Contrast::NoPreference,
730 None,
731 );
732 }
734
735 #[test]
736 fn build_gnome_variant_accent_sets_defaults() {
737 let accent = Color::new(0.2, 0.4, 0.8);
738 let (v, _acc, _dpi) = build_gnome_variant(
739 ColorScheme::NoPreference,
740 Some(accent),
741 Contrast::NoPreference,
742 None,
743 );
744 let expected = crate::Rgba::from_f32(0.2, 0.4, 0.8, 1.0);
745 assert_eq!(v.defaults.accent_color, Some(expected));
746 assert_eq!(v.defaults.selection_background, Some(expected));
747 assert_eq!(v.defaults.focus_ring_color, Some(expected));
748 }
749
750 #[test]
751 fn build_gnome_variant_invalid_accent_stays_none() {
752 let accent = Color::new(1.5, 0.0, 0.0); let (v, _acc, _dpi) = build_gnome_variant(
754 ColorScheme::NoPreference,
755 Some(accent),
756 Contrast::NoPreference,
757 None,
758 );
759 assert!(v.defaults.accent_color.is_none());
760 }
761
762 fn adwaita_base() -> crate::Theme {
765 crate::Theme::preset("adwaita").unwrap()
766 }
767
768 fn reader_mode(result: &crate::ReaderResult) -> &crate::ThemeMode {
770 match &result.output {
771 crate::ReaderOutput::Single { mode, .. } => mode,
772 crate::ReaderOutput::Dual { light, .. } => light,
773 }
774 }
775
776 #[test]
777 fn dark_scheme_produces_dark_variant_only() {
778 let result = build_theme(
779 adwaita_base(),
780 ColorScheme::PreferDark,
781 None,
782 Contrast::NoPreference,
783 None,
784 )
785 .unwrap();
786 assert_eq!(result.name, "GNOME");
787 assert!(matches!(
788 result.output,
789 crate::ReaderOutput::Single { is_dark: true, .. }
790 ));
791 }
792
793 #[test]
794 fn light_scheme_produces_light_variant_only() {
795 let result = build_theme(
796 adwaita_base(),
797 ColorScheme::PreferLight,
798 None,
799 Contrast::NoPreference,
800 None,
801 )
802 .unwrap();
803 assert_eq!(result.name, "GNOME");
804 assert!(matches!(
805 result.output,
806 crate::ReaderOutput::Single { is_dark: false, .. }
807 ));
808 }
809
810 #[test]
811 fn no_preference_defaults_to_light() {
812 let result = build_theme(
813 adwaita_base(),
814 ColorScheme::NoPreference,
815 None,
816 Contrast::NoPreference,
817 None,
818 )
819 .unwrap();
820 assert_eq!(result.name, "GNOME");
821 assert!(matches!(
822 result.output,
823 crate::ReaderOutput::Single { is_dark: false, .. }
824 ));
825 }
826
827 #[test]
830 fn valid_accent_propagates_to_three_fields() {
831 let accent = Color::new(0.2, 0.4, 0.8);
832 let result = build_theme(
833 adwaita_base(),
834 ColorScheme::NoPreference,
835 Some(accent),
836 Contrast::NoPreference,
837 None,
838 )
839 .unwrap();
840
841 let variant = reader_mode(&result);
842 let expected = crate::Rgba::from_f32(0.2, 0.4, 0.8, 1.0);
843
844 assert_eq!(variant.defaults.accent_color, Some(expected));
845 assert_eq!(variant.defaults.selection_background, Some(expected));
846 assert_eq!(variant.defaults.focus_ring_color, Some(expected));
847 }
848
849 #[test]
852 fn high_contrast_sets_flag_on_theme_variant() {
853 let result = build_theme(
854 adwaita_base(),
855 ColorScheme::NoPreference,
856 None,
857 Contrast::High,
858 None,
859 )
860 .unwrap();
861
862 let _variant = reader_mode(&result);
863 }
865
866 #[test]
867 fn normal_contrast_preserves_adwaita_default() {
868 let result = build_theme(
869 adwaita_base(),
870 ColorScheme::NoPreference,
871 None,
872 Contrast::NoPreference,
873 None,
874 )
875 .unwrap();
876
877 let _variant = reader_mode(&result);
878 }
881
882 #[test]
885 fn build_theme_preserves_adwaita_colors_when_no_overrides() {
886 let base = adwaita_base();
887 let base_light = base.light.as_ref().unwrap().clone();
888
889 let result = build_theme(
890 adwaita_base(),
891 ColorScheme::NoPreference,
892 None,
893 Contrast::NoPreference,
894 None,
895 )
896 .unwrap();
897
898 let variant = reader_mode(&result);
899 assert_eq!(
901 variant.defaults.background_color,
902 base_light.defaults.background_color
903 );
904 assert_eq!(variant.defaults.text_color, base_light.defaults.text_color);
905 }
906
907 #[test]
908 fn build_theme_dialog_order_set() {
909 let result = build_theme(
910 adwaita_base(),
911 ColorScheme::NoPreference,
912 None,
913 Contrast::NoPreference,
914 None,
915 )
916 .unwrap();
917
918 let variant = reader_mode(&result);
919 assert_eq!(
920 variant.dialog.button_order,
921 Some(DialogButtonOrder::PrimaryRight),
922 );
923 }
924
925 #[test]
928 fn extract_weight_bold() {
929 let (family, weight) = extract_weight_from_family("Cantarell Bold");
930 assert_eq!(family, "Cantarell");
931 assert_eq!(weight, 700);
932 }
933
934 #[test]
935 fn extract_weight_no_modifier() {
936 let (family, weight) = extract_weight_from_family("Cantarell");
937 assert_eq!(family, "Cantarell");
938 assert_eq!(weight, 400);
939 }
940
941 #[test]
942 fn extract_weight_demi_bold() {
943 let (family, weight) = extract_weight_from_family("Source Sans Demi-Bold");
944 assert_eq!(family, "Source Sans");
945 assert_eq!(weight, 600);
946 }
947
948 #[test]
949 fn extract_weight_black() {
950 let (family, weight) = extract_weight_from_family("Inter Black");
951 assert_eq!(family, "Inter");
952 assert_eq!(weight, 900);
953 }
954
955 fn default_gnome_data() -> GnomePortalData {
959 GnomePortalData {
960 is_dark: false,
961 accent_rgb: None,
962 high_contrast: false,
963 reduce_motion: None,
964 font_name: Some("Cantarell 11".to_string()),
965 monospace_font_name: Some("Source Code Pro 10".to_string()),
966 titlebar_font: Some("Cantarell Bold 11".to_string()),
967 text_scaling_factor: Some(1.0),
968 font_dpi: 96.0,
969 overlay_scrolling: Some(true),
970 icon_theme: Some("Adwaita".to_string()),
971 gsettings_high_contrast: None,
972 gsettings_enable_animations: None,
973 }
974 }
975
976 #[test]
977 fn pure_light_scheme_produces_light_variant() {
978 let data = default_gnome_data();
979 let result = build_gnome_spec_pure(&data).unwrap();
980 assert!(matches!(
981 result.output,
982 crate::ReaderOutput::Single { is_dark: false, .. }
983 ));
984 assert_eq!(result.name, "GNOME");
985 }
986
987 #[test]
988 fn pure_dark_scheme_produces_dark_variant() {
989 let mut data = default_gnome_data();
990 data.is_dark = true;
991 let result = build_gnome_spec_pure(&data).unwrap();
992 assert!(matches!(
993 result.output,
994 crate::ReaderOutput::Single { is_dark: true, .. }
995 ));
996 assert_eq!(result.name, "GNOME");
997 }
998
999 #[test]
1000 fn pure_accent_color_propagates_to_three_fields() {
1001 let mut data = default_gnome_data();
1002 data.accent_rgb = Some((0.2, 0.4, 0.8));
1003 let result = build_gnome_spec_pure(&data).unwrap();
1004 let variant = reader_mode(&result);
1005 let expected = crate::Rgba::from_f32(0.2, 0.4, 0.8, 1.0);
1006 assert_eq!(variant.defaults.accent_color, Some(expected));
1007 assert_eq!(variant.defaults.selection_background, Some(expected));
1008 assert_eq!(variant.defaults.focus_ring_color, Some(expected));
1009 }
1010
1011 #[test]
1012 fn pure_high_contrast_sets_flag() {
1013 let mut data = default_gnome_data();
1014 data.high_contrast = true;
1015 let result = build_gnome_spec_pure(&data).unwrap();
1016 let _variant = reader_mode(&result);
1017 }
1019
1020 #[test]
1021 fn pure_fonts_parsed_correctly() {
1022 let mut data = default_gnome_data();
1023 data.font_name = Some("Inter Bold 12".to_string());
1024 let result = build_gnome_spec_pure(&data).unwrap();
1025 let variant = reader_mode(&result);
1026 assert_eq!(variant.defaults.font.family.as_deref(), Some("Inter"));
1027 assert_eq!(variant.defaults.font.weight, Some(700));
1028 assert_eq!(variant.defaults.font.size, Some(FontSize::Pt(12.0)));
1029 }
1030
1031 #[test]
1032 fn pure_out_of_range_accent_ignored() {
1033 let baseline_data = default_gnome_data();
1035 let baseline_result = build_gnome_spec_pure(&baseline_data).unwrap();
1036 let baseline_variant = reader_mode(&baseline_result);
1037 let baseline_accent = baseline_variant.defaults.accent_color;
1038
1039 let mut data = default_gnome_data();
1041 data.accent_rgb = Some((1.5, 0.0, 0.0));
1042 let result = build_gnome_spec_pure(&data).unwrap();
1043 let variant = reader_mode(&result);
1044 assert_eq!(variant.defaults.accent_color, baseline_accent);
1046 }
1047
1048 #[test]
1049 fn pure_gsettings_high_contrast_fallback() {
1050 let mut data = default_gnome_data();
1051 data.high_contrast = false;
1052 data.gsettings_high_contrast = Some(true);
1053 let result = build_gnome_spec_pure(&data).unwrap();
1054 let _variant = reader_mode(&result);
1055 }
1057
1058 #[test]
1059 fn pure_reduce_motion_from_portal() {
1060 let mut data = default_gnome_data();
1061 data.reduce_motion = Some(true);
1062 let result = build_gnome_spec_pure(&data).unwrap();
1063 let _variant = reader_mode(&result);
1064 }
1066
1067 #[test]
1068 fn pure_reduce_motion_gsettings_fallback() {
1069 let mut data = default_gnome_data();
1070 data.reduce_motion = None;
1071 data.gsettings_enable_animations = Some(false);
1072 let result = build_gnome_spec_pure(&data).unwrap();
1073 let _variant = reader_mode(&result);
1074 }
1077
1078 #[test]
1079 fn pure_overlay_scrolling() {
1080 let mut data = default_gnome_data();
1081 data.overlay_scrolling = Some(false);
1082 let result = build_gnome_spec_pure(&data).unwrap();
1083 let variant = reader_mode(&result);
1084 assert_eq!(variant.scrollbar.overlay_mode, Some(false));
1085 }
1086
1087 #[test]
1095 fn gnome_reader_exists() {
1096 let r: Box<dyn crate::reader::ThemeReader> = Box::new(super::GnomeReader);
1101 let _ = &r;
1102
1103 #[cfg(feature = "kde")]
1104 {
1105 let rp: Box<dyn crate::reader::ThemeReader> = Box::new(super::GnomePortalKdeReader);
1106 let _ = &rp;
1107 }
1108 }
1109}