Skip to main content

native_theme/gnome/
mod.rs

1//! GNOME portal reader: reads accent color, color scheme, contrast,
2//! fonts, text scale, accessibility flags, and icon_set from the
3//! XDG Desktop Portal Settings interface (via ashpd) and gsettings.
4//!
5//! Uses the bundled Adwaita preset as base, then overlays portal-provided
6//! accent color, color scheme (light/dark), and contrast preference,
7//! along with OS-readable font, accessibility, and icon settings.
8
9use ashpd::desktop::Color;
10use ashpd::desktop::settings::{ColorScheme, Contrast, ReducedMotion};
11
12use crate::model::{DialogButtonOrder, FontSpec};
13
14/// Known GNOME/Pango font weight modifiers mapped to CSS weight values.
15const 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/// Convert an ashpd portal Color to an Rgba, returning None if any
39/// component is outside the [0.0, 1.0] range (per XDG spec: out-of-range
40/// means "unset"). Used by the KDE-with-portal reader.
41#[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    // Per XDG spec: out-of-range means "accent color not set"
48    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
55/// Apply a portal accent color across multiple semantic color roles.
56fn 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    // A checked notebook tab's line is `--accent-bg-color` (docs/platform-facts.md §2.11).
61    variant.tab.active_indicator_color = Some(*accent);
62}
63
64/// Parse a GNOME/Pango font string into a full FontSpec with weight extraction.
65///
66/// GNOME font strings can have weight words between the family name and size:
67/// `'Cantarell Bold 11'`, `'Inter Light 10.5'`, `'Noto Sans Semi-Bold 12'`.
68///
69/// The parser:
70/// 1. Strips quotes, parses the size number from the end
71/// 2. Checks the remaining text for known weight modifiers
72/// 3. Family = everything before the weight modifier (or before the size if none)
73/// 4. Default weight = 400 (Regular) if no modifier found
74///
75/// Returns `None` if the string is empty or cannot be parsed.
76pub(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    // Parse size from the end: split on the last space without manual slicing.
83    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    // Check for weight modifiers at the end of the family string
91    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
105/// Extract a weight modifier from the end of a family string.
106///
107/// Returns (family_without_modifier, css_weight). If no modifier is found,
108/// returns the full string as family with weight 400.
109fn 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    // No recognized modifier -- treat as Regular
119    (s, 400)
120}
121
122/// Read a single gsettings value, returning None if gsettings is unavailable or fails.
123///
124/// Uses [`crate::detect::gsettings_get()`] to enforce a 2-second deadline,
125/// preventing indefinite blocking when D-Bus is unresponsive.  Single-quote
126/// wrapping (gsettings string output format) is stripped from the result.
127fn 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
137/// Detect font DPI from X resources and display hardware.
138///
139/// Detection chain (first positive value wins):
140/// 1. `Xft.dpi` from X resources (via `xrdb -query`)
141/// 2. Physical DPI from display hardware (via `xrandr`)
142/// 3. Fallback: 96.0
143fn detect_font_dpi() -> f32 {
144    if let Some(dpi) = crate::detect::xft_dpi() {
145        return dpi;
146    }
147    // Physical DPI from display hardware (xrandr)
148    if let Some(dpi) = crate::detect::physical_dpi() {
149        return dpi;
150    }
151    // X11-standard fallback
152    96.0
153}
154
155/// Portal + gsettings data using primitive types only.
156///
157/// Constructed from ashpd portal reads + gsettings in production,
158/// or directly in tests for deterministic verification.
159/// No ashpd types, no D-Bus access, no gsettings subprocess calls.
160pub struct GnomePortalData {
161    /// Whether the user prefers a dark color scheme.
162    pub is_dark: bool,
163    /// Accent color as (R, G, B) with components in 0.0..=1.0 (XDG portal spec).
164    /// None means no accent color set.
165    pub accent_rgb: Option<(f64, f64, f64)>,
166    /// Whether high contrast is requested (from portal Contrast::High).
167    pub high_contrast: bool,
168    /// Whether reduced motion is requested. None = not provided by portal.
169    pub reduce_motion: Option<bool>,
170    /// Primary UI font string in GNOME/Pango format (e.g. "Cantarell 11").
171    pub font_name: Option<String>,
172    /// Monospace font string (e.g. "Source Code Pro 10").
173    pub monospace_font_name: Option<String>,
174    /// Window titlebar font string.
175    pub titlebar_font: Option<String>,
176    /// Text scaling factor (e.g. 1.0, 1.25).
177    pub text_scaling_factor: Option<f32>,
178    /// Font DPI from X resources or display hardware.
179    pub font_dpi: f32,
180    /// Whether overlay scrollbars are enabled. None = not provided.
181    pub overlay_scrolling: Option<bool>,
182    /// Icon theme name (e.g. "Adwaita", "Papirus").
183    pub icon_theme: Option<String>,
184    /// High contrast from gsettings fallback (for GNOME < 44).
185    /// Only used when `high_contrast` is false (portal didn't report high contrast).
186    pub gsettings_high_contrast: Option<bool>,
187    /// Reduced motion from gsettings enable-animations fallback.
188    /// Only used when `reduce_motion` is None (portal didn't report).
189    pub gsettings_enable_animations: Option<bool>,
190}
191
192/// Extract AccessibilityPreferences from GNOME portal + gsettings data.
193///
194/// Merges portal and gsettings sources: portal `high_contrast` and
195/// `reduce_motion` take priority, gsettings values are fallback.
196/// GNOME does not expose `reduce_transparency`, so it keeps the default (false).
197fn 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    // reduce_transparency keeps default -- GNOME does not expose this setting
211    acc
212}
213
214/// Build a sparse ThemeMode from pre-read portal + gsettings data.
215/// Zero I/O -- all values are pre-read by the caller.
216fn build_gnome_variant_pure(data: &GnomePortalData) -> crate::ThemeMode {
217    let mut variant = crate::ThemeMode::default();
218
219    // Apply accent color with range validation
220    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    // NOTE: high_contrast, reduce_motion, text_scaling_factor, and font_dpi
230    // are no longer on ThemeDefaults. They live on AccessibilityPreferences,
231    // constructed by the pipeline from GnomePortalData fields.
232
233    // ── Fonts (GNOME-01) ────────────────────────────────────────────────
234    // Primary UI font
235    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    // Monospace font
242    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    // Titlebar font (GNOME-01 extension)
249    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    // ── Accessibility (GNOME-03 / GNOME-05) ─────────────────────────────
256    // text_scaling_factor, font_dpi, reduce_motion are extracted from
257    // GnomePortalData by the pipeline caller, not set on ThemeDefaults.
258
259    // overlay-scrolling -> scrollbar.overlay_mode (GNOME-03)
260    if let Some(overlay) = data.overlay_scrolling {
261        variant.scrollbar.overlay_mode = Some(overlay);
262    }
263
264    // ── Icon theme (GNOME-04) ────────────────────────────────────────────
265    // icon_theme is set on variant.defaults in build_gnome_spec_pure()
266
267    // ── Dialog button order (project decision) ──────────────────────────
268    variant.dialog.button_order = Some(DialogButtonOrder::PrimaryRight);
269
270    variant
271}
272
273/// Build a GNOME ReaderResult from pre-read portal + gsettings data.
274///
275/// This is the fully testable entry point: no D-Bus, no gsettings,
276/// no xrdb/xrandr. Loads the Adwaita preset as base, builds a sparse
277/// OS variant from the provided data, and merges it onto the base.
278#[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    // Icon theme on variant defaults (per-variant)
295    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
312/// Build a sparse ThemeMode populated only with OS-readable fields.
313///
314/// This function does NOT embed any Adwaita preset data -- it only sets
315/// fields that the GNOME desktop provides via gsettings and portal data.
316/// The caller merges this sparse variant onto an Adwaita base.
317///
318/// Converts ashpd types to primitives and delegates to [`build_gnome_variant_pure`].
319pub(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    // Convert ashpd types to primitives
326    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    // Read gsettings values (I/O stays here, not in pure function)
339    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, // not used by build_gnome_variant_pure
377        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; // consumed by caller for light/dark selection
392
393    let acc = accessibility_from_gnome_data(&data);
394    let dpi = data.font_dpi;
395    (build_gnome_variant_pure(&data), acc, dpi)
396}
397
398/// Build a ReaderResult from an Adwaita base, applying portal-provided
399/// color scheme, accent color, contrast, and reduced-motion settings
400/// plus OS-readable fields.
401///
402/// This is the testable core -- no D-Bus required.
403pub(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    // Pick the appropriate variant from the Adwaita base.
413    // unwrap_or_default() provides an empty ThemeMode when the base
414    // preset lacks the requested variant, allowing the merge to proceed.
415    let mut variant = if is_dark {
416        base.dark.unwrap_or_default()
417    } else {
418        base.light.unwrap_or_default()
419    };
420
421    // Build sparse OS variant and merge onto Adwaita base
422    let (os_variant, acc, font_dpi) = build_gnome_variant(scheme, accent, contrast, reduced_motion);
423    variant.merge(&os_variant);
424
425    // Read icon_theme from gsettings (per-variant)
426    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
444/// Read the current GNOME theme from the XDG Desktop Portal.
445///
446/// Reads color scheme (light/dark), accent color, and contrast preference
447/// from the `org.freedesktop.appearance` portal namespace via ashpd.
448///
449/// Falls back to bundled Adwaita defaults when the portal is unavailable
450/// (no D-Bus session, sandboxed environment, or old portal version).
451///
452/// Internal reader dispatched through the [`crate::reader::ThemeReader`] trait
453/// by [`pipeline::select_reader`]; external consumers should use
454/// [`SystemTheme::from_system_async()`](crate::SystemTheme::from_system_async).
455pub(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        // Try to connect to the portal. If unavailable, return Adwaita defaults.
463        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        // Read the four appearance settings. Each can fail independently.
477        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/// Read KDE theme from kdeglobals, then overlay portal accent color if available.
487///
488/// Reads the KDE kdeglobals file as the base theme via
489/// [`crate::kde::KdeReader`], then attempts to read the accent color from the
490/// XDG Desktop Portal. If the portal provides a valid accent color, it is
491/// applied to accent, selection, and focus_ring_color fields on the reader's
492/// variant.
493///
494/// Falls back to the KDE-only base if the portal is unavailable or provides
495/// no accent color.
496///
497/// Requires both `kde` and `portal` features.
498///
499/// Internal reader dispatched through the [`crate::reader::ThemeReader`] trait
500/// by [`pipeline::select_reader`]; external consumers should use
501/// [`SystemTheme::from_system_async()`](crate::SystemTheme::from_system_async).
502#[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        // Base: invoke the KdeReader through the same trait surface so that
510        // any future KDE-side migration (e.g. extra I/O, extra parsing) is
511        // picked up here automatically without re-wiring the composite.
512        let mut result =
513            <crate::kde::KdeReader as crate::reader::ThemeReader>::read(&crate::kde::KdeReader)
514                .await?;
515
516        // Try to get accent color from portal
517        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        // Apply accent to the reader's variant
533        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
547/// Detect which desktop portal backend is running via D-Bus activatable names.
548///
549/// Checks the session bus for activatable service names containing
550/// `portal.desktop.kde` or `portal.desktop.gnome` to infer the desktop
551/// environment when `XDG_CURRENT_DESKTOP` is ambiguous or unset.
552///
553/// Returns `None` if D-Bus is unavailable or no recognized portal backend
554/// is found.
555pub(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    // === parse_gnome_font_to_fontspec with weight extraction ===
580
581    #[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    // === portal_color_to_rgba tests ===
666
667    #[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    // === build_gnome_variant tests ===
705
706    #[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        // high_contrast now on AccessibilityPreferences
722    }
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        // high_contrast now on AccessibilityPreferences
733    }
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); // out of range
753        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    // === build_theme tests ===
763
764    fn adwaita_base() -> crate::Theme {
765        crate::Theme::preset("adwaita").unwrap()
766    }
767
768    /// Helper: extract the ThemeMode from a ReaderResult for test assertions.
769    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    // === accent color tests ===
828
829    #[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    // === high contrast tests ===
850
851    #[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        // high_contrast now on AccessibilityPreferences
864    }
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        // Adwaita preset sets high_contrast = false; OS variant doesn't override it
879        // high_contrast now on AccessibilityPreferences
880    }
881
882    // === build_theme merge correctness ===
883
884    #[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        // Adwaita colors should be preserved since OS variant doesn't set colors
900        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    // === extract_weight_from_family tests ===
926
927    #[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    // === build_gnome_spec_pure tests (no ashpd types, no I/O) ===
956
957    /// Default GnomePortalData for testing (light, no accent, Cantarell 11, 96 DPI).
958    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        // high_contrast now on AccessibilityPreferences
1018    }
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        // Build without accent to get baseline
1034        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        // Build with out-of-range accent
1040        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        // Out-of-range accent should NOT override -- result matches baseline
1045        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        // high_contrast now on AccessibilityPreferences
1056    }
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        // reduce_motion now on AccessibilityPreferences
1065    }
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        // enable-animations=false means reduce_motion=true
1075        // reduce_motion now on AccessibilityPreferences
1076    }
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    // === Plan 94-03 (G8): GnomeReader + GnomePortalKdeReader ThemeReader impls ===
1088    //
1089    // Locks the G8 contract on the GNOME side: two unit structs `GnomeReader`
1090    // and `GnomePortalKdeReader` (the latter gated on `feature = "kde"`) exist
1091    // at the module root and implement `crate::reader::ThemeReader`. Before
1092    // Task 2 lands, this fails to compile (`no struct GnomeReader` / `no struct
1093    // GnomePortalKdeReader` / `unresolved module crate::reader`).
1094    #[test]
1095    fn gnome_reader_exists() {
1096        // Compile-probe: construct trait objects for each reader available on
1097        // this build. Do NOT invoke .read() — portal I/O is environment-
1098        // dependent (D-Bus may be unavailable in the test sandbox) and the
1099        // structural invariant (the trait coercion) is the load-bearing check.
1100        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}