Skip to main content

native_theme/kde/
mod.rs

1// KDE theme reader -- reads kdeglobals INI file and maps to Theme
2
3/// KDE color group parsing and mapping.
4pub mod colors;
5/// Qt font string parsing with weight extraction.
6pub mod fonts;
7
8use crate::Rgba;
9use crate::model::IconSizes;
10
11/// Parse KDE kdeglobals content into a Theme without any I/O.
12///
13/// This function is intentionally `pub` (not `pub(crate)`) because integration tests
14/// in `tests/reader_kde.rs` import it from outside the crate. See doc 1 section C6
15/// and Phase 79-02 decision for rationale.
16///
17/// `font_dpi`: if `Some`, used directly for font DPI; if `None`, attempts to
18/// extract `forceFontDPI` from the INI content, falling back to `None` (no DPI set).
19/// Icon sizes are NOT populated (requires filesystem access) -- the caller
20/// (`from_kde_content` / `from_kde`) handles that after this returns.
21pub fn from_kde_content_pure(
22    content: &str,
23    font_dpi: Option<f32>,
24) -> crate::Result<(crate::Theme, Option<f32>, crate::AccessibilityPreferences)> {
25    let mut ini = create_kde_parser();
26    ini.read(content.to_string())
27        .map_err(|e| crate::Error::ReaderFailed {
28            reader: "kde",
29            source: e.into(),
30        })?;
31
32    let mut variant = crate::ThemeMode::default();
33
34    // Populate colors, fonts, and widget sizing on the variant
35    colors::populate_colors(&ini, &mut variant);
36    fonts::populate_fonts(&ini, &mut variant);
37    crate::kde_metrics::populate_widget_sizing(&mut variant);
38
39    // KDE-06: Accessibility flags (pure -- no I/O)
40    // AnimationDurationFactor from [KDE]
41    let reduce_motion = if let Some(anim_str) = ini.get("KDE", "AnimationDurationFactor")
42        && let Ok(value) = anim_str.trim().parse::<f32>()
43    {
44        value == 0.0
45    } else {
46        false
47    };
48
49    let accessibility = crate::AccessibilityPreferences {
50        reduce_motion,
51        ..Default::default()
52    };
53
54    // Font DPI: use provided value, or try extracting forceFontDPI from INI content
55    let resolved_dpi = font_dpi.or_else(|| parse_force_font_dpi(&ini));
56
57    let dark = is_dark_theme(&ini);
58
59    let name = ini
60        .get("General", "ColorScheme")
61        .unwrap_or_else(|| "KDE".to_string());
62
63    // KDE-05: Icon theme name from [Icons] Theme (per-variant)
64    variant.defaults.icon_theme = ini
65        .get("Icons", "Theme")
66        .filter(|s| !s.is_empty())
67        .map(std::borrow::Cow::Owned);
68
69    let theme = if dark {
70        crate::Theme {
71            name: std::borrow::Cow::Owned(name),
72            light: None,
73            dark: Some(variant),
74            layout: crate::LayoutTheme::default(),
75            icon_set: None,
76            // KDE sets icon_theme per-variant (breeze / breeze-dark) on
77            // variant.defaults.icon_theme; the Theme-level field is left None
78            // so tier 1 (per-variant) always wins.
79            icon_theme: None,
80        }
81    } else {
82        crate::Theme {
83            name: std::borrow::Cow::Owned(name),
84            light: Some(variant),
85            dark: None,
86            layout: crate::LayoutTheme::default(),
87            icon_set: None,
88            icon_theme: None,
89        }
90    };
91
92    Ok((theme, resolved_dpi, accessibility))
93}
94
95/// Parse a KDE kdeglobals content string into a ReaderResult.
96///
97/// Builds a sparse ThemeMode with per-widget colors, fonts with Qt5/Qt6
98/// weight conversion, text scale from Kirigami multipliers, accessibility
99/// flags, icon set, and Breeze widget sizing constants.
100///
101/// Delegates to [`from_kde_content_pure`] for parsing, then performs I/O
102/// for full DPI detection and icon size lookup.
103pub(crate) fn from_kde_content(content: &str) -> crate::Result<crate::ReaderResult> {
104    let mut ini = create_kde_parser();
105    ini.read(content.to_string())
106        .map_err(|e| crate::Error::ReaderFailed {
107            reader: "kde",
108            source: e.into(),
109        })?;
110
111    // I/O: full DPI detection chain (forceFontDPI -> kcmfontsrc -> xrdb -> xrandr -> 96.0)
112    let font_dpi = detect_font_dpi(&ini);
113
114    let (theme, _dpi, accessibility) = from_kde_content_pure(content, Some(font_dpi))?;
115
116    // Determine dark/light from theme shape
117    let is_dark = theme.dark.is_some() && theme.light.is_none();
118    let mut mode_data = if is_dark {
119        theme.dark.unwrap_or_default()
120    } else {
121        theme.light.unwrap_or_default()
122    };
123
124    // I/O: icon sizes from filesystem (icon_theme is on variant defaults)
125    if let Some(ref theme_name) = mode_data.defaults.icon_theme {
126        mode_data.defaults.icon_sizes = parse_icon_sizes_from_index_theme(theme_name);
127    }
128
129    let output = crate::ReaderOutput::Single {
130        mode: Box::new(mode_data),
131        is_dark,
132    };
133
134    Ok(crate::ReaderResult {
135        output,
136        name: theme.name,
137        icon_set: theme.icon_set,
138        layout: theme.layout,
139        font_dpi: Some(font_dpi),
140        accessibility,
141    })
142}
143
144/// Extract forceFontDPI from the parsed INI content.
145/// Returns `Some(dpi)` if `[General]` `forceFontDPI` exists and is a valid positive f32.
146/// This is the pure (no I/O) portion of DPI detection.
147fn parse_force_font_dpi(ini: &configparser::ini::Ini) -> Option<f32> {
148    let dpi_str = ini.get("General", "forceFontDPI")?;
149    let dpi = dpi_str.trim().parse::<f32>().ok()?;
150    if dpi > 0.0 { Some(dpi) } else { None }
151}
152
153/// Detect font DPI from KDE settings.
154///
155/// Detection chain (first positive value wins):
156/// 1. `forceFontDPI` from kdeglobals `[General]` or kcmfontsrc
157/// 2. `Xft.dpi` from X resources (via `xrdb -query`)
158/// 3. Physical DPI from display hardware (via `xrandr`)
159/// 4. Fallback: 96.0
160fn detect_font_dpi(ini: &configparser::ini::Ini) -> f32 {
161    // Check forceFontDPI from kdeglobals [General], then kcmfontsrc
162    let dpi_str = ini
163        .get("General", "forceFontDPI")
164        .or_else(|| read_kcmfontsrc_key("General", "forceFontDPI"));
165    if let Some(dpi_str) = dpi_str
166        && let Ok(dpi) = dpi_str.trim().parse::<f32>()
167        && dpi > 0.0
168    {
169        return dpi;
170    }
171
172    // Fallback to Xft.dpi from X resources
173    if let Some(dpi) = crate::detect::xft_dpi() {
174        return dpi;
175    }
176
177    // Physical DPI from display hardware (xrandr)
178    if let Some(dpi) = crate::detect::physical_dpi() {
179        return dpi;
180    }
181
182    // Default: standard 96 DPI
183    96.0
184}
185
186/// Read a single key from `$XDG_CONFIG_HOME/kcmfontsrc` (or `~/.config/kcmfontsrc`).
187///
188/// Returns `None` if the file is missing, unreadable, or the key is not found.
189pub(crate) fn read_kcmfontsrc_key(section: &str, key: &str) -> Option<String> {
190    let path = if let Ok(config_home) = std::env::var("XDG_CONFIG_HOME") {
191        if config_home.is_empty() {
192            None
193        } else {
194            Some(std::path::PathBuf::from(config_home).join("kcmfontsrc"))
195        }
196    } else {
197        None
198    }
199    .or_else(|| {
200        std::env::var("HOME").ok().map(|h| {
201            std::path::PathBuf::from(h)
202                .join(".config")
203                .join("kcmfontsrc")
204        })
205    })?;
206
207    let content = std::fs::read_to_string(path).ok()?;
208    let mut ini = create_kde_parser();
209    ini.read(content).ok()?;
210    ini.get(section, key)
211}
212
213/// Parse icon sizes from a freedesktop index.theme INI already loaded into a parser.
214///
215/// Reads the `[Icon Theme]` section's `Directories` key, then for each listed
216/// directory reads `Size` and `Context`. Derives:
217/// - `small`: smallest Size where Context is "Actions" or "Status"
218/// - `toolbar`: Size closest to 22 from "Actions" entries
219/// - `large`: smallest Size >= 32 from "Applications" entries
220///
221/// Fields without matching entries remain None.
222pub(crate) fn parse_icon_sizes_from_content(ini: &configparser::ini::Ini) -> IconSizes {
223    let dirs_str = match ini.get("Icon Theme", "Directories") {
224        Some(s) if !s.trim().is_empty() => s,
225        _ => return IconSizes::default(),
226    };
227
228    // Collect (size, context) pairs from each directory section
229    let mut entries: Vec<(u32, String)> = Vec::new();
230    for dir_name in dirs_str.split(',') {
231        let dir_name = dir_name.trim();
232        if dir_name.is_empty() {
233            continue;
234        }
235        let size = ini
236            .get(dir_name, "Size")
237            .and_then(|s| s.trim().parse::<u32>().ok());
238        let context = ini.get(dir_name, "Context");
239        if let (Some(sz), Some(ctx)) = (size, context) {
240            entries.push((sz, ctx));
241        }
242    }
243
244    if entries.is_empty() {
245        return IconSizes::default();
246    }
247
248    // small: smallest Size from Actions or Status context
249    let small = entries
250        .iter()
251        .filter(|(_, ctx)| ctx == "Actions" || ctx == "Status")
252        .map(|(sz, _)| *sz)
253        .min()
254        .map(|sz| sz as f32);
255
256    // toolbar: Actions entry closest to 22
257    let toolbar = entries
258        .iter()
259        .filter(|(_, ctx)| ctx == "Actions")
260        .map(|(sz, _)| *sz)
261        .min_by_key(|sz| sz.abs_diff(22))
262        .map(|sz| sz as f32);
263
264    // large: smallest Applications entry >= 32 (or largest if none >= 32)
265    let large = {
266        let app_sizes: Vec<u32> = entries
267            .iter()
268            .filter(|(_, ctx)| ctx == "Applications")
269            .map(|(sz, _)| *sz)
270            .collect();
271        if app_sizes.is_empty() {
272            None
273        } else {
274            let ge32: Vec<u32> = app_sizes.iter().copied().filter(|&s| s >= 32).collect();
275            if ge32.is_empty() {
276                app_sizes.iter().copied().max().map(|sz| sz as f32)
277            } else {
278                ge32.iter().copied().min().map(|sz| sz as f32)
279            }
280        }
281    };
282
283    // dialog: DialogDefault from [Icon Theme] (e.g., 22)
284    let dialog = ini
285        .get("Icon Theme", "DialogDefault")
286        .and_then(|s| s.trim().parse::<u32>().ok())
287        .map(|sz| sz as f32);
288
289    // panel: PanelDefault from [Icon Theme] (e.g., 48)
290    let panel = ini
291        .get("Icon Theme", "PanelDefault")
292        .and_then(|s| s.trim().parse::<u32>().ok())
293        .map(|sz| sz as f32);
294
295    IconSizes {
296        small,
297        toolbar,
298        large,
299        dialog,
300        panel,
301    }
302}
303
304/// Parse icon sizes from the active icon theme's index.theme file.
305///
306/// Searches XDG icon theme directories for `{theme_name}/index.theme`:
307/// 1. `$HOME/.local/share/icons/{theme_name}/index.theme`
308/// 2. Each directory in `$XDG_DATA_DIRS` (default `/usr/local/share:/usr/share`)
309///    appended with `/icons/{theme_name}/index.theme`
310///
311/// Returns `IconSizes::default()` (all None) if no index.theme is found or
312/// the file cannot be parsed.
313pub(crate) fn parse_icon_sizes_from_index_theme(theme_name: &str) -> IconSizes {
314    let path = match find_index_theme_path(theme_name) {
315        Some(p) => p,
316        None => return IconSizes::default(),
317    };
318
319    let content = match std::fs::read_to_string(&path) {
320        Ok(c) => c,
321        Err(_) => return IconSizes::default(),
322    };
323
324    let mut ini = create_kde_parser();
325    if ini.read(content).is_err() {
326        return IconSizes::default();
327    }
328
329    parse_icon_sizes_from_content(&ini)
330}
331
332/// Locate the index.theme file for a given icon theme name.
333///
334/// Searches in XDG standard order: user local first, then system dirs.
335fn find_index_theme_path(theme_name: &str) -> Option<std::path::PathBuf> {
336    // 1. $HOME/.local/share/icons/{theme_name}/index.theme
337    if let Ok(home) = std::env::var("HOME") {
338        let p = std::path::PathBuf::from(home)
339            .join(".local/share/icons")
340            .join(theme_name)
341            .join("index.theme");
342        if p.exists() {
343            return Some(p);
344        }
345    }
346
347    // 2. XDG_DATA_DIRS entries + /icons/{theme_name}/index.theme
348    let data_dirs = std::env::var("XDG_DATA_DIRS")
349        .unwrap_or_else(|_| "/usr/local/share:/usr/share".to_string());
350    for dir in data_dirs.split(':') {
351        let dir = dir.trim();
352        if dir.is_empty() {
353            continue;
354        }
355        let p = std::path::PathBuf::from(dir)
356            .join("icons")
357            .join(theme_name)
358            .join("index.theme");
359        if p.exists() {
360            return Some(p);
361        }
362    }
363
364    None
365}
366
367/// Read the current KDE theme from kdeglobals.
368///
369/// Parses `~/.config/kdeglobals` (respecting `XDG_CONFIG_HOME`) and maps
370/// KDE color groups and font strings to a [`ReaderResult`](crate::ReaderResult).
371///
372/// Internal reader dispatched through the [`crate::reader::ThemeReader`] trait
373/// by [`pipeline::select_reader`]; external consumers should use
374/// [`SystemTheme::from_system()`](crate::SystemTheme::from_system).
375pub(crate) struct KdeReader;
376
377#[async_trait::async_trait]
378impl crate::reader::ThemeReader for KdeReader {
379    async fn read(&self) -> crate::Result<crate::ReaderResult> {
380        let path = kdeglobals_path();
381        let content = std::fs::read_to_string(&path).map_err(|e| crate::Error::ReaderFailed {
382            reader: "kde",
383            source: format!("cannot read {}: {e}", path.display()).into(),
384        })?;
385        let mut result = from_kde_content(&content)?;
386
387        // Cascade: if kdeglobals lacks [Icons] Theme, check kdedefaults/kdeglobals
388        let mode = match &mut result.output {
389            crate::ReaderOutput::Single { mode, .. } => &mut **mode,
390            crate::ReaderOutput::Dual { light, .. } => &mut **light,
391        };
392        if mode.defaults.icon_theme.is_none()
393            && let Some(parent) = path.parent()
394        {
395            let defaults_path = parent.join("kdedefaults").join("kdeglobals");
396            if let Ok(defaults_content) = std::fs::read_to_string(&defaults_path) {
397                let mut defaults_ini = create_kde_parser();
398                if defaults_ini.read(defaults_content).is_ok() {
399                    mode.defaults.icon_theme = defaults_ini
400                        .get("Icons", "Theme")
401                        .filter(|s| !s.is_empty())
402                        .map(std::borrow::Cow::Owned);
403                }
404            }
405        }
406
407        Ok(result)
408    }
409}
410
411/// Create a configparser Ini instance configured for KDE files.
412///
413/// Uses case-sensitive mode and equals-only delimiter to correctly
414/// handle KDE's PascalCase keys and colon-containing section names.
415pub(crate) fn create_kde_parser() -> configparser::ini::Ini {
416    let tmp = configparser::ini::Ini::new_cs();
417    let mut defaults = tmp.defaults();
418    defaults.delimiters = vec!['='];
419    configparser::ini::Ini::new_from_defaults(defaults)
420}
421
422/// Parse a KDE "R,G,B" color string into an Rgba (opaque).
423///
424/// Returns None for malformed values (never panics).
425/// Exactly 3 comma-separated u8 components required.
426pub(crate) fn parse_rgb(value: &str) -> Option<Rgba> {
427    let parts: Vec<&str> = value.split(',').collect();
428    let [r_s, g_s, b_s] = parts.as_slice() else {
429        return None;
430    };
431    let r = r_s.trim().parse::<u8>().ok()?;
432    let g = g_s.trim().parse::<u8>().ok()?;
433    let b = b_s.trim().parse::<u8>().ok()?;
434    Some(Rgba::rgb(r, g, b))
435}
436
437/// Resolve the path to the kdeglobals file.
438///
439/// Checks XDG_CONFIG_HOME (non-empty), then $HOME/.config/kdeglobals,
440/// then /etc/xdg/kdeglobals as last resort.
441pub(crate) fn kdeglobals_path() -> std::path::PathBuf {
442    if let Ok(config_home) = std::env::var("XDG_CONFIG_HOME")
443        && !config_home.is_empty()
444    {
445        return std::path::PathBuf::from(config_home).join("kdeglobals");
446    }
447    if let Ok(home) = std::env::var("HOME") {
448        return std::path::PathBuf::from(home)
449            .join(".config")
450            .join("kdeglobals");
451    }
452    // Last resort fallback
453    std::path::PathBuf::from("/etc/xdg/kdeglobals")
454}
455
456/// Pure version of [`kdeglobals_path`] for testing.
457///
458/// Accepts XDG_CONFIG_HOME and HOME values as parameters instead of
459/// reading environment variables, following the `_pure` suffix convention
460/// (established in Phase 63).
461#[cfg(test)]
462fn kdeglobals_path_pure(xdg_config_home: Option<&str>, home: Option<&str>) -> std::path::PathBuf {
463    if let Some(config_home) = xdg_config_home
464        && !config_home.is_empty()
465    {
466        return std::path::PathBuf::from(config_home).join("kdeglobals");
467    }
468    if let Some(home) = home {
469        return std::path::PathBuf::from(home)
470            .join(".config")
471            .join("kdeglobals");
472    }
473    // Last resort fallback
474    std::path::PathBuf::from("/etc/xdg/kdeglobals")
475}
476
477/// Read KDE theme from a specific kdeglobals file path (for testing).
478/// Returns a Theme reconstructed from the ReaderResult for test assertions.
479#[cfg(test)]
480pub(crate) fn from_kde_at(path: &std::path::Path) -> crate::Result<crate::Theme> {
481    let content = std::fs::read_to_string(path).map_err(|e| crate::Error::ReaderFailed {
482        reader: "kde",
483        source: Box::new(e),
484    })?;
485    let result = from_kde_content(&content)?;
486    Ok(result
487        .output
488        .to_theme(&result.name, result.icon_set, &result.layout))
489}
490
491/// Detect whether the active KDE theme is dark based on background luminance.
492///
493/// Uses BT.601 luminance coefficients on Colors:Window/BackgroundNormal.
494/// Defaults to false (light) if the section/key is missing.
495pub(crate) fn is_dark_theme(ini: &configparser::ini::Ini) -> bool {
496    if let Some(bg_str) = ini.get("Colors:Window", "BackgroundNormal")
497        && let Some(bg) = parse_rgb(&bg_str)
498    {
499        let luma = 0.299 * (bg.r as f32) + 0.587 * (bg.g as f32) + 0.114 * (bg.b as f32);
500        return luma < 128.0;
501    }
502    false
503}
504
505#[cfg(test)]
506#[allow(clippy::unwrap_used, clippy::expect_used)]
507mod tests {
508    use super::*;
509    use crate::Rgba;
510    use crate::model::font::FontSize;
511
512    // === parse_rgb tests ===
513
514    #[test]
515    fn parse_rgb_valid_breeze_accent() {
516        assert_eq!(parse_rgb("61,174,233"), Some(Rgba::rgb(61, 174, 233)));
517    }
518
519    #[test]
520    fn parse_rgb_handles_whitespace() {
521        assert_eq!(parse_rgb("0, 0, 0"), Some(Rgba::rgb(0, 0, 0)));
522    }
523
524    #[test]
525    fn parse_rgb_white() {
526        assert_eq!(parse_rgb("255,255,255"), Some(Rgba::rgb(255, 255, 255)));
527    }
528
529    #[test]
530    fn parse_rgb_invalid_text() {
531        assert_eq!(parse_rgb("invalid"), None);
532    }
533
534    #[test]
535    fn parse_rgb_too_few_components() {
536        assert_eq!(parse_rgb("1,2"), None);
537    }
538
539    #[test]
540    fn parse_rgb_too_many_components() {
541        assert_eq!(parse_rgb("1,2,3,4"), None);
542    }
543
544    #[test]
545    fn parse_rgb_out_of_u8_range() {
546        assert_eq!(parse_rgb("256,0,0"), None);
547    }
548
549    #[test]
550    fn parse_rgb_empty_string() {
551        assert_eq!(parse_rgb(""), None);
552    }
553
554    // === kdeglobals_path_pure tests ===
555
556    #[test]
557    fn kdeglobals_path_respects_xdg_config_home() {
558        let path = kdeglobals_path_pure(Some("/tmp/test"), Some("/home/user"));
559        assert_eq!(path, std::path::PathBuf::from("/tmp/test/kdeglobals"));
560    }
561
562    #[test]
563    fn kdeglobals_path_falls_back_to_home_config() {
564        let path = kdeglobals_path_pure(None, Some("/home/testuser"));
565        assert_eq!(
566            path,
567            std::path::PathBuf::from("/home/testuser")
568                .join(".config")
569                .join("kdeglobals")
570        );
571    }
572
573    #[test]
574    fn kdeglobals_path_fallback_to_etc_xdg() {
575        let path = kdeglobals_path_pure(None, None);
576        assert_eq!(path, std::path::PathBuf::from("/etc/xdg/kdeglobals"));
577    }
578
579    // === is_dark_theme tests ===
580
581    #[test]
582    fn is_dark_theme_detects_breeze_dark() {
583        let mut ini = create_kde_parser();
584        ini.read("[Colors:Window]\nBackgroundNormal=20,22,24\n".to_string())
585            .unwrap();
586        assert!(is_dark_theme(&ini));
587    }
588
589    #[test]
590    fn is_dark_theme_detects_breeze_light() {
591        let mut ini = create_kde_parser();
592        ini.read("[Colors:Window]\nBackgroundNormal=239,240,241\n".to_string())
593            .unwrap();
594        assert!(!is_dark_theme(&ini));
595    }
596
597    #[test]
598    fn is_dark_theme_defaults_false_when_missing() {
599        let ini = create_kde_parser();
600        assert!(!is_dark_theme(&ini));
601    }
602
603    // === create_kde_parser tests ===
604
605    #[test]
606    fn create_kde_parser_is_case_sensitive() {
607        let mut ini = create_kde_parser();
608        ini.read("[Colors:View]\nBackgroundNormal=255,255,255\n".to_string())
609            .unwrap();
610        assert!(ini.get("Colors:View", "BackgroundNormal").is_some());
611    }
612
613    #[test]
614    fn create_kde_parser_preserves_section_colons() {
615        let mut ini = create_kde_parser();
616        ini.read("[Colors:Window]\nForegroundNormal=0,0,0\n".to_string())
617            .unwrap();
618        assert!(ini.get("Colors:Window", "ForegroundNormal").is_some());
619    }
620
621    #[test]
622    fn create_kde_parser_equals_only_delimiter() {
623        let mut ini = create_kde_parser();
624        ini.read("[Test]\nsome:value=actual\n".to_string()).unwrap();
625        assert_eq!(ini.get("Test", "some:value"), Some("actual".to_string()));
626    }
627
628    // === from_kde_content / from_kde integration tests ===
629
630    /// Full Breeze Dark kdeglobals fixture with all sections including
631    /// KDE-01 through KDE-06 fields.
632    const BREEZE_DARK_FULL: &str = "\
633[General]
634ColorScheme=BreezeDark
635font=Noto Sans,10,-1,5,400,0,0,0,0,0,0,0,0,0,0,1
636fixed=Hack,10,-1,5,400,0,0,0,0,0,0,0,0,0,0,1
637menuFont=Noto Sans,9,-1,5,400,0,0,0,0,0,0,0,0,0,0,1
638toolBarFont=Noto Sans,8,-1,5,400,0,0,0,0,0,0,0,0,0,0,1
639smallestReadableFont=Noto Sans,7,-1,5,400,0,0,0,0,0,0,0,0,0,0,1
640forceFontDPI=120
641
642[KDE]
643AnimationDurationFactor=0
644
645[Icons]
646Theme=breeze-dark
647
648[Colors:View]
649BackgroundNormal=35,38,41
650BackgroundAlternate=30,33,36
651ForegroundNormal=252,252,252
652ForegroundInactive=161,169,177
653ForegroundActive=61,174,233
654ForegroundLink=29,153,243
655ForegroundNegative=218,68,83
656ForegroundNeutral=246,116,0
657ForegroundPositive=39,174,96
658ForegroundVisited=155,89,182
659DecorationFocus=61,174,233
660DecorationHover=29,153,243
661
662[Colors:Window]
663BackgroundNormal=49,54,59
664BackgroundAlternate=44,49,54
665ForegroundNormal=239,240,241
666ForegroundInactive=161,169,177
667ForegroundActive=61,174,233
668ForegroundLink=29,153,243
669ForegroundNegative=218,68,83
670ForegroundNeutral=246,116,0
671ForegroundPositive=39,174,96
672DecorationFocus=61,174,233
673DecorationHover=29,153,243
674
675[Colors:Button]
676BackgroundNormal=49,54,59
677BackgroundAlternate=44,49,54
678ForegroundNormal=239,240,241
679ForegroundInactive=161,169,177
680
681[Colors:Selection]
682BackgroundNormal=61,174,233
683BackgroundAlternate=29,153,243
684ForegroundNormal=252,252,252
685ForegroundInactive=161,169,177
686
687[Colors:Tooltip]
688BackgroundNormal=49,54,59
689ForegroundNormal=252,252,252
690
691[Colors:Complementary]
692BackgroundNormal=42,46,50
693ForegroundNormal=239,240,241
694
695[Colors:Header]
696BackgroundNormal=35,38,41
697ForegroundNormal=252,252,252
698
699[WM]
700activeBackground=49,54,59
701activeForeground=239,240,241
702inactiveBackground=42,46,50
703inactiveForeground=161,169,177
704activeFont=Noto Sans,10,-1,5,75,0,0,0,0,0
705";
706
707    /// Light theme fixture with light background colors.
708    const BREEZE_LIGHT_FULL: &str = "\
709[General]
710ColorScheme=BreezeLight
711
712[Colors:View]
713BackgroundNormal=255,255,255
714ForegroundNormal=35,38,41
715DecorationFocus=61,174,233
716
717[Colors:Window]
718BackgroundNormal=239,240,241
719ForegroundNormal=35,38,41
720ForegroundInactive=127,140,141
721DecorationFocus=61,174,233
722
723[Colors:Button]
724BackgroundNormal=239,240,241
725ForegroundNormal=35,38,41
726
727[Colors:Selection]
728BackgroundNormal=61,174,233
729ForegroundNormal=255,255,255
730
731[Colors:Tooltip]
732BackgroundNormal=247,247,247
733ForegroundNormal=35,38,41
734";
735
736    /// Minimal fixture -- only Colors:Window section.
737    const MINIMAL_FIXTURE: &str = "\
738[Colors:Window]
739BackgroundNormal=49,54,59
740";
741
742    /// Helper: extract ThemeMode from a ReaderResult for test assertions.
743    fn reader_mode(result: &crate::ReaderResult) -> &crate::ThemeMode {
744        match &result.output {
745            crate::ReaderOutput::Single { mode, .. } => mode,
746            crate::ReaderOutput::Dual { light, .. } => light,
747        }
748    }
749
750    #[test]
751    fn test_dark_theme_detection() {
752        let result = from_kde_content(BREEZE_DARK_FULL).unwrap();
753        assert!(
754            matches!(
755                result.output,
756                crate::ReaderOutput::Single { is_dark: true, .. }
757            ),
758            "dark variant should be populated"
759        );
760    }
761
762    #[test]
763    fn test_light_theme_detection() {
764        let result = from_kde_content(BREEZE_LIGHT_FULL).unwrap();
765        assert!(
766            matches!(
767                result.output,
768                crate::ReaderOutput::Single { is_dark: false, .. }
769            ),
770            "light variant should be populated"
771        );
772    }
773
774    #[test]
775    fn test_theme_name_from_colorscheme() {
776        let result = from_kde_content(BREEZE_DARK_FULL).unwrap();
777        assert_eq!(result.name, "BreezeDark");
778    }
779
780    #[test]
781    fn test_theme_name_fallback() {
782        let content = "[Colors:Window]\nBackgroundNormal=49,54,59\n";
783        let result = from_kde_content(content).unwrap();
784        assert_eq!(result.name, "KDE");
785    }
786
787    #[test]
788    fn test_colors_populated() {
789        let result = from_kde_content(BREEZE_DARK_FULL).unwrap();
790        let variant = reader_mode(&result);
791        assert!(variant.defaults.accent_color.is_some());
792        assert!(variant.defaults.background_color.is_some());
793        assert!(variant.defaults.text_color.is_some());
794    }
795
796    #[test]
797    fn test_fonts_populated() {
798        let result = from_kde_content(BREEZE_DARK_FULL).unwrap();
799        let variant = reader_mode(&result);
800        assert_eq!(variant.defaults.font.family.as_deref(), Some("Noto Sans"));
801        assert_eq!(variant.defaults.font.size, Some(FontSize::Pt(10.0)));
802        assert_eq!(variant.defaults.mono_font.family.as_deref(), Some("Hack"));
803        assert_eq!(variant.defaults.mono_font.size, Some(FontSize::Pt(10.0)));
804    }
805
806    #[test]
807    fn test_minimal_fixture_no_panic() {
808        let result = from_kde_content(MINIMAL_FIXTURE);
809        assert!(
810            result.is_ok(),
811            "minimal fixture should not panic: {result:?}"
812        );
813        let r = result.unwrap();
814        assert!(matches!(
815            r.output,
816            crate::ReaderOutput::Single { is_dark: true, .. }
817        ));
818    }
819
820    #[test]
821    fn test_from_kde_content_populates_widget_sizing() {
822        let result = from_kde_content(BREEZE_DARK_FULL).unwrap();
823        let variant = reader_mode(&result);
824        assert_eq!(variant.button.min_width, Some(80.0), "Button_MinWidth");
825        assert_eq!(
826            variant.scrollbar.groove_width,
827            Some(21.0),
828            "ScrollBar_Extend"
829        );
830    }
831
832    #[test]
833    fn test_empty_content() {
834        let result = from_kde_content("");
835        assert!(
836            result.is_ok(),
837            "empty content should produce Ok: {result:?}"
838        );
839        let r = result.unwrap();
840        assert_eq!(r.name, "KDE");
841    }
842
843    #[test]
844    fn test_missing_file() {
845        // Test that from_kde_at returns ReaderFailed for a nonexistent path
846        let path = std::path::PathBuf::from("/tmp/nonexistent_kde_test_dir_12345/kdeglobals");
847        let result = from_kde_at(&path);
848        assert!(result.is_err());
849        let err = result.unwrap_err();
850        let crate::Error::ReaderFailed { source, .. } = &err else {
851            panic!("expected ReaderFailed, got: {err:?}");
852        };
853        let msg = source.to_string();
854        assert!(
855            msg.contains("kdeglobals") || msg.contains("No such file"),
856            "unexpected error message: {msg}"
857        );
858    }
859
860    // === Per-widget color tests ===
861
862    #[test]
863    fn test_button_colors_populated() {
864        let result = from_kde_content(BREEZE_DARK_FULL).unwrap();
865        let v = reader_mode(&result);
866        assert_eq!(v.button.background_color, Some(Rgba::rgb(49, 54, 59)));
867        assert_eq!(
868            v.button.font.as_ref().and_then(|f| f.color),
869            Some(Rgba::rgb(239, 240, 241))
870        );
871    }
872
873    #[test]
874    fn test_tooltip_colors_populated() {
875        let result = from_kde_content(BREEZE_DARK_FULL).unwrap();
876        let v = reader_mode(&result);
877        assert_eq!(v.tooltip.background_color, Some(Rgba::rgb(49, 54, 59)));
878        assert_eq!(
879            v.tooltip.font.as_ref().and_then(|f| f.color),
880            Some(Rgba::rgb(252, 252, 252))
881        );
882    }
883
884    #[test]
885    fn test_sidebar_colors_populated() {
886        let result = from_kde_content(BREEZE_DARK_FULL).unwrap();
887        let v = reader_mode(&result);
888        assert_eq!(v.sidebar.background_color, Some(Rgba::rgb(42, 46, 50)));
889        assert_eq!(
890            v.sidebar.font.as_ref().and_then(|f| f.color),
891            Some(Rgba::rgb(239, 240, 241))
892        );
893    }
894
895    #[test]
896    fn test_wm_title_bar_colors() {
897        let result = from_kde_content(BREEZE_DARK_FULL).unwrap();
898        let v = reader_mode(&result);
899        assert_eq!(v.window.title_bar_background, Some(Rgba::rgb(49, 54, 59)));
900        assert_eq!(
901            v.window.title_bar_font.as_ref().and_then(|f| f.color),
902            Some(Rgba::rgb(239, 240, 241))
903        );
904        assert_eq!(
905            v.window.inactive_title_bar_background,
906            Some(Rgba::rgb(42, 46, 50))
907        );
908        assert_eq!(
909            v.window.inactive_title_bar_text_color,
910            Some(Rgba::rgb(161, 169, 177))
911        );
912    }
913
914    #[test]
915    fn test_list_header_colors() {
916        let result = from_kde_content(BREEZE_DARK_FULL).unwrap();
917        let v = reader_mode(&result);
918        assert_eq!(v.list.header_background, Some(Rgba::rgb(35, 38, 41)));
919        assert_eq!(
920            v.list.header_font.as_ref().and_then(|f| f.color),
921            Some(Rgba::rgb(252, 252, 252))
922        );
923    }
924
925    #[test]
926    fn test_link_visited() {
927        let result = from_kde_content(BREEZE_DARK_FULL).unwrap();
928        let v = reader_mode(&result);
929        assert_eq!(v.link.visited_text_color, Some(Rgba::rgb(155, 89, 182)));
930    }
931
932    // === KDE-06: Accessibility ===
933
934    #[test]
935    fn test_animation_duration_factor_zero_sets_reduce_motion_true() {
936        let (_theme, _dpi, accessibility) = from_kde_content_pure(BREEZE_DARK_FULL, None).unwrap();
937        assert!(accessibility.reduce_motion);
938    }
939
940    #[test]
941    fn test_animation_duration_factor_nonzero_sets_reduce_motion_false() {
942        let content = "\
943[Colors:Window]
944BackgroundNormal=49,54,59
945
946[KDE]
947AnimationDurationFactor=1.0
948";
949        let (_theme, _dpi, accessibility) = from_kde_content_pure(content, None).unwrap();
950        assert!(!accessibility.reduce_motion);
951    }
952
953    #[test]
954    fn test_force_font_dpi_sets_font_dpi() {
955        let (_theme, dpi, _accessibility) = from_kde_content_pure(BREEZE_DARK_FULL, None).unwrap();
956        // forceFontDPI=120 in [General] -> font_dpi=120.0 (raw DPI, not divided by 96)
957        assert_eq!(dpi, Some(120.0));
958    }
959
960    #[test]
961    fn test_missing_accessibility_leaves_defaults() {
962        let content = "\
963[Colors:Window]
964BackgroundNormal=49,54,59
965";
966        let (_theme, dpi, accessibility) = from_kde_content_pure(content, None).unwrap();
967        assert!(!accessibility.reduce_motion);
968        assert_eq!(accessibility.text_scaling_factor, 1.0);
969        // No forceFontDPI in content -> None from pure function
970        assert!(dpi.is_none());
971    }
972
973    // === KDE-05: Icon set ===
974
975    #[test]
976    fn test_icon_theme_from_icons_theme() {
977        let result = from_kde_content(BREEZE_DARK_FULL).unwrap();
978        // icon_set is on ReaderResult, icon_theme is on variant defaults
979        assert!(result.icon_set.is_none());
980        let v = reader_mode(&result);
981        assert_eq!(v.defaults.icon_theme.as_deref(), Some("breeze-dark"));
982    }
983
984    #[test]
985    fn test_icon_theme_none_when_missing() {
986        let content = "\
987[Colors:Window]
988BackgroundNormal=49,54,59
989";
990        let result = from_kde_content(content).unwrap();
991        assert!(result.icon_set.is_none());
992        let v = reader_mode(&result);
993        assert!(v.defaults.icon_theme.is_none());
994    }
995
996    // === KDE-05: Icon sizes from index.theme ===
997
998    /// Minimal index.theme content for testing icon size parsing.
999    const BREEZE_INDEX_THEME: &str = "\
1000[Icon Theme]
1001Name=breeze-dark
1002Comment=Breeze Dark
1003Directories=actions/16,actions/22,actions/32,apps/16,apps/32,apps/48,status/16,status/22
1004
1005[actions/16]
1006Size=16
1007Context=Actions
1008Type=Fixed
1009
1010[actions/22]
1011Size=22
1012Context=Actions
1013Type=Fixed
1014
1015[actions/32]
1016Size=32
1017Context=Actions
1018Type=Fixed
1019
1020[apps/16]
1021Size=16
1022Context=Applications
1023Type=Fixed
1024
1025[apps/32]
1026Size=32
1027Context=Applications
1028Type=Fixed
1029
1030[apps/48]
1031Size=48
1032Context=Applications
1033Type=Fixed
1034
1035[status/16]
1036Size=16
1037Context=Status
1038Type=Fixed
1039
1040[status/22]
1041Size=22
1042Context=Status
1043Type=Fixed
1044";
1045
1046    #[test]
1047    fn test_parse_icon_sizes_from_content() {
1048        let mut ini = create_kde_parser();
1049        ini.read(BREEZE_INDEX_THEME.to_string()).unwrap();
1050        let sizes = parse_icon_sizes_from_content(&ini);
1051
1052        // small: smallest Size from Actions/Status context = 16
1053        assert_eq!(
1054            sizes.small,
1055            Some(16.0),
1056            "small should be 16 from actions/16 and status/16"
1057        );
1058
1059        // toolbar: closest to 22 from Actions context = 22
1060        assert_eq!(
1061            sizes.toolbar,
1062            Some(22.0),
1063            "toolbar should be 22 from actions/22"
1064        );
1065
1066        // large: smallest Applications size >= 32 = 32
1067        assert_eq!(sizes.large, Some(32.0), "large should be 32 from apps/32");
1068
1069        // dialog and panel: not extracted from index.theme
1070        assert!(sizes.dialog.is_none(), "dialog should remain None");
1071        assert!(sizes.panel.is_none(), "panel should remain None");
1072    }
1073
1074    #[test]
1075    fn test_parse_icon_sizes_missing_theme() {
1076        let sizes =
1077            parse_icon_sizes_from_index_theme("nonexistent_theme_that_does_not_exist_12345");
1078        assert!(
1079            sizes.small.is_none(),
1080            "small should be None for missing theme"
1081        );
1082        assert!(
1083            sizes.toolbar.is_none(),
1084            "toolbar should be None for missing theme"
1085        );
1086        assert!(
1087            sizes.large.is_none(),
1088            "large should be None for missing theme"
1089        );
1090        assert!(
1091            sizes.dialog.is_none(),
1092            "dialog should be None for missing theme"
1093        );
1094        assert!(
1095            sizes.panel.is_none(),
1096            "panel should be None for missing theme"
1097        );
1098    }
1099
1100    #[test]
1101    fn test_parse_icon_sizes_empty_directories() {
1102        let content = "\
1103[Icon Theme]
1104Name=empty
1105Directories=
1106";
1107        let mut ini = create_kde_parser();
1108        ini.read(content.to_string()).unwrap();
1109        let sizes = parse_icon_sizes_from_content(&ini);
1110        assert!(sizes.small.is_none());
1111        assert!(sizes.toolbar.is_none());
1112        assert!(sizes.large.is_none());
1113    }
1114
1115    #[test]
1116    fn test_parse_icon_sizes_no_icon_theme_section() {
1117        let content = "\
1118[General]
1119Name=whatever
1120";
1121        let mut ini = create_kde_parser();
1122        ini.read(content.to_string()).unwrap();
1123        let sizes = parse_icon_sizes_from_content(&ini);
1124        assert!(sizes.small.is_none());
1125        assert!(sizes.toolbar.is_none());
1126        assert!(sizes.large.is_none());
1127    }
1128
1129    #[test]
1130    fn test_icon_sizes_populated_in_full_parse() {
1131        // This test checks that from_kde_content wires icon sizes from index.theme.
1132        // On systems with breeze-dark installed, icon_sizes will be populated.
1133        // On systems without it, icon_sizes will be None. Both are valid.
1134        let result = from_kde_content(BREEZE_DARK_FULL).unwrap();
1135        let v = reader_mode(&result);
1136
1137        if let Some(small) = v.defaults.icon_sizes.small {
1138            // If populated, they should be reasonable pixel values
1139            assert!(
1140                (8.0..=32.0).contains(&small),
1141                "small icon size should be reasonable: {small}"
1142            );
1143
1144            if let Some(large) = v.defaults.icon_sizes.large {
1145                assert!(
1146                    (24.0..=128.0).contains(&large),
1147                    "large icon size should be reasonable: {large}"
1148                );
1149                assert!(large > small, "large should be bigger than small");
1150            }
1151        }
1152        // else: theme not installed on this system, icon_sizes remain None -- acceptable
1153    }
1154
1155    // === Dialog button order ===
1156
1157    #[test]
1158    fn test_dialog_button_order_not_set_by_reader() {
1159        let result = from_kde_content(BREEZE_DARK_FULL).unwrap();
1160        let v = reader_mode(&result);
1161        assert_eq!(
1162            v.dialog.button_order, None,
1163            "reader must not hardcode button_order -- resolver handles it"
1164        );
1165    }
1166
1167    // === Widget sizing from Breeze metrics ===
1168
1169    #[test]
1170    fn test_widget_sizing_checkbox_indicator() {
1171        let result = from_kde_content(BREEZE_DARK_FULL).unwrap();
1172        let v = reader_mode(&result);
1173        assert_eq!(v.checkbox.indicator_width, Some(20.0));
1174    }
1175
1176    #[test]
1177    fn test_widget_sizing_splitter() {
1178        let result = from_kde_content(BREEZE_DARK_FULL).unwrap();
1179        let v = reader_mode(&result);
1180        assert_eq!(v.splitter.divider_width, Some(1.0));
1181    }
1182
1183    // === Per-widget fonts (KDE-03) ===
1184
1185    #[test]
1186    fn test_menu_font_from_menufont() {
1187        let result = from_kde_content(BREEZE_DARK_FULL).unwrap();
1188        let v = reader_mode(&result);
1189        let mf = v.menu.font.as_ref().expect("menu.font should be set");
1190        assert_eq!(mf.family.as_deref(), Some("Noto Sans"));
1191        assert_eq!(mf.size, Some(FontSize::Pt(9.0)));
1192    }
1193
1194    #[test]
1195    fn test_toolbar_font_from_toolbarfont() {
1196        let result = from_kde_content(BREEZE_DARK_FULL).unwrap();
1197        let v = reader_mode(&result);
1198        let tf = v.toolbar.font.as_ref().expect("toolbar.font should be set");
1199        assert_eq!(tf.family.as_deref(), Some("Noto Sans"));
1200        assert_eq!(tf.size, Some(FontSize::Pt(8.0)));
1201    }
1202
1203    #[test]
1204    fn test_title_bar_font_from_wm_activefont() {
1205        let result = from_kde_content(BREEZE_DARK_FULL).unwrap();
1206        let v = reader_mode(&result);
1207        let tbf = v
1208            .window
1209            .title_bar_font
1210            .as_ref()
1211            .expect("title_bar_font should be set");
1212        assert_eq!(tbf.family.as_deref(), Some("Noto Sans"));
1213        assert_eq!(tbf.size, Some(FontSize::Pt(10.0)));
1214        assert_eq!(tbf.weight, Some(700)); // Qt5 75 -> CSS 700
1215    }
1216
1217    // === Integration test: resolve() + validate() pipeline ===
1218
1219    #[test]
1220    fn test_kde_resolve_validate() {
1221        // Load the KDE Breeze preset as a base (provides geometry, spacing, icon sizes,
1222        // and other fields that KDE's kdeglobals doesn't carry).
1223        let mut base = crate::Theme::preset("kde-breeze").unwrap();
1224        let result = from_kde_content(BREEZE_DARK_FULL).unwrap();
1225        let kde_theme = result
1226            .output
1227            .to_theme(&result.name, result.icon_set, &result.layout);
1228
1229        // Merge KDE reader output on top of the base preset.
1230        // The KDE variant is dark-only; merge will clone it into the base.
1231        base.merge(&kde_theme);
1232
1233        // Extract the dark variant (KDE's output merged on top of default dark).
1234        let mut dark = base
1235            .dark
1236            .clone()
1237            .expect("dark variant should exist after merge");
1238
1239        // Run the resolution pipeline.
1240        dark.resolve_all();
1241
1242        // Validate should produce Ok(ResolvedTheme) with all fields filled.
1243        let resolved = dark.validate().unwrap_or_else(|e| {
1244            panic!("KDE resolve/validate pipeline failed: {e}");
1245        });
1246
1247        // Spot-check: KDE-sourced fields should be present.
1248        // accent from Colors:View/DecorationFocus = 61,174,233
1249        assert_eq!(
1250            resolved.defaults.accent_color,
1251            crate::Rgba::rgb(61, 174, 233),
1252            "accent should be from KDE reader"
1253        );
1254
1255        // font.family from [General] font
1256        assert_eq!(
1257            resolved.defaults.font.family.as_ref(),
1258            "Noto Sans",
1259            "font family should be from KDE reader"
1260        );
1261
1262        // button.background from Colors:Button/BackgroundNormal = 49,54,59
1263        assert_eq!(
1264            resolved.button.background_color,
1265            crate::Rgba::rgb(49, 54, 59),
1266            "button bg should be from KDE reader"
1267        );
1268
1269        // window.title_bar_background from WM/activeBackground = 49,54,59
1270        assert_eq!(
1271            resolved.window.title_bar_background,
1272            crate::Rgba::rgb(49, 54, 59),
1273            "title bar bg should be from KDE reader"
1274        );
1275
1276        // input.caret from Colors:View/DecorationFocus = 61,174,233
1277        assert_eq!(
1278            resolved.input.caret_color,
1279            crate::Rgba::rgb(61, 174, 233),
1280            "input.caret should be from KDE reader (DecorationFocus)"
1281        );
1282
1283        // icon_set is now on Theme/SystemTheme, not on ResolvedTheme
1284
1285        // dialog.button_order should be from KDE reader
1286        assert_eq!(
1287            resolved.dialog.button_order,
1288            crate::DialogButtonOrder::PrimaryLeft,
1289            "dialog button order should be leading affirmative for KDE"
1290        );
1291    }
1292
1293    // === Plan 94-03 (G8): KdeReader ThemeReader impl ===
1294    //
1295    // Locks the G8 contract on the KDE side: a unit struct `KdeReader` exists
1296    // at the module root and implements `crate::reader::ThemeReader`. Before
1297    // Task 2 lands, this fails to compile (`no struct KdeReader` / `unresolved
1298    // module crate::reader`).
1299    #[test]
1300    fn kde_reader_exists_and_implements_theme_reader() {
1301        // Object-safe coercion: proves the trait is consumable via dyn.
1302        let r: Box<dyn crate::reader::ThemeReader> = Box::new(super::KdeReader);
1303        // read() is async — drive it to completion on a simple pollster runtime.
1304        // On systems without a readable kdeglobals the result may legitimately be
1305        // Err(ReaderFailed); either outcome validates the trait dispatch path,
1306        // the strictly-tested invariant is that `r.read()` type-checks and can
1307        // be awaited without runtime panics.
1308        let _: crate::Result<crate::ReaderResult> = pollster::block_on(r.read());
1309    }
1310}