1pub mod colors;
5pub mod fonts;
7
8use crate::Rgba;
9use crate::model::IconSizes;
10
11pub 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 colors::populate_colors(&ini, &mut variant);
36 fonts::populate_fonts(&ini, &mut variant);
37 crate::kde_metrics::populate_widget_sizing(&mut variant);
38
39 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 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 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 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
95pub(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 let font_dpi = detect_font_dpi(&ini);
113
114 let (theme, _dpi, accessibility) = from_kde_content_pure(content, Some(font_dpi))?;
115
116 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 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
144fn 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
153fn detect_font_dpi(ini: &configparser::ini::Ini) -> f32 {
161 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 if let Some(dpi) = crate::detect::xft_dpi() {
174 return dpi;
175 }
176
177 if let Some(dpi) = crate::detect::physical_dpi() {
179 return dpi;
180 }
181
182 96.0
184}
185
186pub(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
213pub(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 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 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 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 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 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 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
304pub(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
332fn find_index_theme_path(theme_name: &str) -> Option<std::path::PathBuf> {
336 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 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
367pub(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 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
411pub(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
422pub(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
437pub(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 std::path::PathBuf::from("/etc/xdg/kdeglobals")
454}
455
456#[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 std::path::PathBuf::from("/etc/xdg/kdeglobals")
475}
476
477#[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
491pub(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 #[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 #[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 #[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 #[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 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 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 const MINIMAL_FIXTURE: &str = "\
738[Colors:Window]
739BackgroundNormal=49,54,59
740";
741
742 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 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 #[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 #[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 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 assert!(dpi.is_none());
971 }
972
973 #[test]
976 fn test_icon_theme_from_icons_theme() {
977 let result = from_kde_content(BREEZE_DARK_FULL).unwrap();
978 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 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 assert_eq!(
1054 sizes.small,
1055 Some(16.0),
1056 "small should be 16 from actions/16 and status/16"
1057 );
1058
1059 assert_eq!(
1061 sizes.toolbar,
1062 Some(22.0),
1063 "toolbar should be 22 from actions/22"
1064 );
1065
1066 assert_eq!(sizes.large, Some(32.0), "large should be 32 from apps/32");
1068
1069 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 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 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 }
1154
1155 #[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 #[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 #[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)); }
1216
1217 #[test]
1220 fn test_kde_resolve_validate() {
1221 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 base.merge(&kde_theme);
1232
1233 let mut dark = base
1235 .dark
1236 .clone()
1237 .expect("dark variant should exist after merge");
1238
1239 dark.resolve_all();
1241
1242 let resolved = dark.validate().unwrap_or_else(|e| {
1244 panic!("KDE resolve/validate pipeline failed: {e}");
1245 });
1246
1247 assert_eq!(
1250 resolved.defaults.accent_color,
1251 crate::Rgba::rgb(61, 174, 233),
1252 "accent should be from KDE reader"
1253 );
1254
1255 assert_eq!(
1257 resolved.defaults.font.family.as_ref(),
1258 "Noto Sans",
1259 "font family should be from KDE reader"
1260 );
1261
1262 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 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 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 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 #[test]
1300 fn kde_reader_exists_and_implements_theme_reader() {
1301 let r: Box<dyn crate::reader::ThemeReader> = Box::new(super::KdeReader);
1303 let _: crate::Result<crate::ReaderResult> = pollster::block_on(r.read());
1309 }
1310}