1#[doc = include_str!("../README.md")]
9#[cfg(doctest)]
10pub struct ReadmeDoctests;
11
12#[macro_export]
44macro_rules! impl_merge {
45 (
46 $struct_name:ident {
47 $(option { $($opt_field:ident),* $(,)? })?
48 $(nested { $($nest_field:ident),* $(,)? })?
49 }
50 ) => {
51 impl $struct_name {
52 pub fn merge(&mut self, overlay: &Self) {
56 $($(
57 if overlay.$opt_field.is_some() {
58 self.$opt_field = overlay.$opt_field.clone();
59 }
60 )*)?
61 $($(
62 self.$nest_field.merge(&overlay.$nest_field);
63 )*)?
64 }
65
66 pub fn is_empty(&self) -> bool {
68 true
69 $($(&& self.$opt_field.is_none())*)?
70 $($(&& self.$nest_field.is_empty())*)?
71 }
72 }
73 };
74}
75
76pub mod color;
77pub mod error;
78#[cfg(all(target_os = "linux", feature = "portal"))]
79pub mod gnome;
80#[cfg(all(target_os = "linux", feature = "kde"))]
81pub mod kde;
82pub mod model;
83pub mod presets;
84
85pub use color::Rgba;
86pub use error::Error;
87pub use model::{
88 IconData, IconRole, IconSet, NativeTheme, ThemeColors, ThemeFonts, ThemeGeometry, ThemeSpacing,
89 ThemeVariant, WidgetMetrics, bundled_icon_by_name, bundled_icon_svg,
90};
91pub use model::icons::{icon_name, system_icon_set, system_icon_theme};
93
94#[cfg(all(target_os = "linux", feature = "system-icons"))]
95pub mod freedesktop;
96pub mod macos;
97#[cfg(feature = "svg-rasterize")]
98pub mod rasterize;
99#[cfg(all(target_os = "macos", feature = "system-icons"))]
100pub mod sficons;
101#[cfg(all(target_os = "windows", feature = "windows"))]
102pub mod windows;
103#[cfg(all(target_os = "windows", feature = "system-icons"))]
104pub mod winicons;
105
106#[cfg(all(target_os = "linux", feature = "system-icons"))]
107pub use freedesktop::{load_freedesktop_icon, load_freedesktop_icon_by_name};
108#[cfg(all(target_os = "linux", feature = "portal"))]
109pub use gnome::from_gnome;
110#[cfg(all(target_os = "linux", feature = "portal", feature = "kde"))]
111pub use gnome::from_kde_with_portal;
112#[cfg(all(target_os = "linux", feature = "kde"))]
113pub use kde::from_kde;
114#[cfg(all(target_os = "macos", feature = "macos"))]
115pub use macos::from_macos;
116#[cfg(feature = "svg-rasterize")]
117pub use rasterize::rasterize_svg;
118#[cfg(all(target_os = "macos", feature = "system-icons"))]
119pub use sficons::load_sf_icon;
120#[cfg(all(target_os = "windows", feature = "windows"))]
121pub use windows::from_windows;
122#[cfg(all(target_os = "windows", feature = "system-icons"))]
123pub use winicons::load_windows_icon;
124
125pub type Result<T> = std::result::Result<T, Error>;
127
128#[cfg(target_os = "linux")]
130#[derive(Debug, Clone, Copy, PartialEq)]
131pub enum LinuxDesktop {
132 Kde,
133 Gnome,
134 Xfce,
135 Cinnamon,
136 Mate,
137 LxQt,
138 Budgie,
139 Unknown,
140}
141
142#[cfg(target_os = "linux")]
148pub fn detect_linux_de(xdg_current_desktop: &str) -> LinuxDesktop {
149 for component in xdg_current_desktop.split(':') {
150 match component {
151 "KDE" => return LinuxDesktop::Kde,
152 "Budgie" => return LinuxDesktop::Budgie,
153 "GNOME" => return LinuxDesktop::Gnome,
154 "XFCE" => return LinuxDesktop::Xfce,
155 "X-Cinnamon" | "Cinnamon" => return LinuxDesktop::Cinnamon,
156 "MATE" => return LinuxDesktop::Mate,
157 "LXQt" => return LinuxDesktop::LxQt,
158 _ => {}
159 }
160 }
161 LinuxDesktop::Unknown
162}
163
164#[cfg(target_os = "linux")]
178#[must_use = "this returns whether the system uses dark mode"]
179pub fn system_is_dark() -> bool {
180 static CACHED_IS_DARK: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
181 *CACHED_IS_DARK.get_or_init(detect_is_dark_inner)
182}
183
184#[cfg(target_os = "linux")]
188fn detect_is_dark_inner() -> bool {
189 if let Ok(output) = std::process::Command::new("gsettings")
191 .args(["get", "org.gnome.desktop.interface", "color-scheme"])
192 .output()
193 && output.status.success()
194 {
195 let val = String::from_utf8_lossy(&output.stdout);
196 if val.contains("prefer-dark") {
197 return true;
198 }
199 if val.contains("prefer-light") || val.contains("default") {
200 return false;
201 }
202 }
203
204 #[cfg(feature = "kde")]
206 {
207 let path = crate::kde::kdeglobals_path();
208 if let Ok(content) = std::fs::read_to_string(&path) {
209 let mut ini = crate::kde::create_kde_parser();
210 if ini.read(content).is_ok() {
211 return crate::kde::is_dark_theme(&ini);
212 }
213 }
214 }
215
216 false
217}
218
219#[cfg(target_os = "linux")]
223fn from_linux() -> crate::Result<NativeTheme> {
224 let desktop = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default();
225 match detect_linux_de(&desktop) {
226 #[cfg(feature = "kde")]
227 LinuxDesktop::Kde => crate::kde::from_kde(),
228 #[cfg(not(feature = "kde"))]
229 LinuxDesktop::Kde => NativeTheme::preset("adwaita"),
230 LinuxDesktop::Gnome | LinuxDesktop::Budgie => NativeTheme::preset("adwaita"),
231 LinuxDesktop::Xfce | LinuxDesktop::Cinnamon | LinuxDesktop::Mate | LinuxDesktop::LxQt => {
232 NativeTheme::preset("adwaita")
233 }
234 LinuxDesktop::Unknown => {
235 #[cfg(feature = "kde")]
236 {
237 let path = crate::kde::kdeglobals_path();
238 if path.exists() {
239 return crate::kde::from_kde();
240 }
241 }
242 NativeTheme::preset("adwaita")
243 }
244 }
245}
246
247#[must_use = "this returns the detected theme; it does not apply it"]
268pub fn from_system() -> crate::Result<NativeTheme> {
269 #[cfg(target_os = "macos")]
270 {
271 #[cfg(feature = "macos")]
272 return crate::macos::from_macos();
273
274 #[cfg(not(feature = "macos"))]
275 return Err(crate::Error::Unsupported);
276 }
277
278 #[cfg(target_os = "windows")]
279 {
280 #[cfg(feature = "windows")]
281 return crate::windows::from_windows();
282
283 #[cfg(not(feature = "windows"))]
284 return Err(crate::Error::Unsupported);
285 }
286
287 #[cfg(target_os = "linux")]
288 {
289 from_linux()
290 }
291
292 #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
293 {
294 Err(crate::Error::Unsupported)
295 }
296}
297
298#[cfg(target_os = "linux")]
308#[must_use = "this returns the detected theme; it does not apply it"]
309pub async fn from_system_async() -> crate::Result<NativeTheme> {
310 let desktop = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default();
311 match detect_linux_de(&desktop) {
312 #[cfg(feature = "kde")]
313 LinuxDesktop::Kde => {
314 #[cfg(feature = "portal")]
315 return crate::gnome::from_kde_with_portal().await;
316 #[cfg(not(feature = "portal"))]
317 return crate::kde::from_kde();
318 }
319 #[cfg(not(feature = "kde"))]
320 LinuxDesktop::Kde => NativeTheme::preset("adwaita"),
321 #[cfg(feature = "portal")]
322 LinuxDesktop::Gnome | LinuxDesktop::Budgie => crate::gnome::from_gnome().await,
323 #[cfg(not(feature = "portal"))]
324 LinuxDesktop::Gnome | LinuxDesktop::Budgie => NativeTheme::preset("adwaita"),
325 LinuxDesktop::Xfce | LinuxDesktop::Cinnamon | LinuxDesktop::Mate | LinuxDesktop::LxQt => {
326 NativeTheme::preset("adwaita")
327 }
328 LinuxDesktop::Unknown => {
329 #[cfg(feature = "portal")]
331 {
332 if let Some(detected) = crate::gnome::detect_portal_backend().await {
333 return match detected {
334 #[cfg(feature = "kde")]
335 LinuxDesktop::Kde => crate::gnome::from_kde_with_portal().await,
336 #[cfg(not(feature = "kde"))]
337 LinuxDesktop::Kde => NativeTheme::preset("adwaita"),
338 LinuxDesktop::Gnome => crate::gnome::from_gnome().await,
339 _ => {
340 unreachable!("detect_portal_backend only returns Kde or Gnome")
341 }
342 };
343 }
344 }
345 #[cfg(feature = "kde")]
347 {
348 let path = crate::kde::kdeglobals_path();
349 if path.exists() {
350 return crate::kde::from_kde();
351 }
352 }
353 NativeTheme::preset("adwaita")
354 }
355 }
356}
357
358#[cfg(not(target_os = "linux"))]
362#[must_use = "this returns the detected theme; it does not apply it"]
363pub async fn from_system_async() -> crate::Result<NativeTheme> {
364 from_system()
365}
366
367#[must_use = "this returns the loaded icon data; it does not display it"]
394#[allow(unreachable_patterns, clippy::needless_return, unused_variables)]
395pub fn load_icon(role: IconRole, icon_set: &str) -> Option<IconData> {
396 let set = IconSet::from_name(icon_set).unwrap_or_else(system_icon_set);
397
398 match set {
399 #[cfg(all(target_os = "linux", feature = "system-icons"))]
400 IconSet::Freedesktop => freedesktop::load_freedesktop_icon(role),
401
402 #[cfg(all(target_os = "macos", feature = "system-icons"))]
403 IconSet::SfSymbols => sficons::load_sf_icon(role),
404
405 #[cfg(all(target_os = "windows", feature = "system-icons"))]
406 IconSet::SegoeIcons => winicons::load_windows_icon(role),
407
408 #[cfg(feature = "material-icons")]
409 IconSet::Material => {
410 bundled_icon_svg(IconSet::Material, role).map(|b| IconData::Svg(b.to_vec()))
411 }
412
413 #[cfg(feature = "lucide-icons")]
414 IconSet::Lucide => {
415 bundled_icon_svg(IconSet::Lucide, role).map(|b| IconData::Svg(b.to_vec()))
416 }
417
418 _ => {
420 #[cfg(feature = "material-icons")]
421 {
422 return bundled_icon_svg(IconSet::Material, role)
423 .map(|b| IconData::Svg(b.to_vec()));
424 }
425 #[cfg(not(feature = "material-icons"))]
426 {
427 None
428 }
429 }
430 }
431}
432
433#[cfg(test)]
437pub(crate) static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
438
439#[cfg(all(test, target_os = "linux"))]
440mod dispatch_tests {
441 use super::*;
442
443 #[test]
446 fn detect_kde_simple() {
447 assert_eq!(detect_linux_de("KDE"), LinuxDesktop::Kde);
448 }
449
450 #[test]
451 fn detect_kde_colon_separated_after() {
452 assert_eq!(detect_linux_de("ubuntu:KDE"), LinuxDesktop::Kde);
453 }
454
455 #[test]
456 fn detect_kde_colon_separated_before() {
457 assert_eq!(detect_linux_de("KDE:plasma"), LinuxDesktop::Kde);
458 }
459
460 #[test]
461 fn detect_gnome_simple() {
462 assert_eq!(detect_linux_de("GNOME"), LinuxDesktop::Gnome);
463 }
464
465 #[test]
466 fn detect_gnome_ubuntu() {
467 assert_eq!(detect_linux_de("ubuntu:GNOME"), LinuxDesktop::Gnome);
468 }
469
470 #[test]
471 fn detect_xfce() {
472 assert_eq!(detect_linux_de("XFCE"), LinuxDesktop::Xfce);
473 }
474
475 #[test]
476 fn detect_cinnamon() {
477 assert_eq!(detect_linux_de("X-Cinnamon"), LinuxDesktop::Cinnamon);
478 }
479
480 #[test]
481 fn detect_cinnamon_short() {
482 assert_eq!(detect_linux_de("Cinnamon"), LinuxDesktop::Cinnamon);
483 }
484
485 #[test]
486 fn detect_mate() {
487 assert_eq!(detect_linux_de("MATE"), LinuxDesktop::Mate);
488 }
489
490 #[test]
491 fn detect_lxqt() {
492 assert_eq!(detect_linux_de("LXQt"), LinuxDesktop::LxQt);
493 }
494
495 #[test]
496 fn detect_budgie() {
497 assert_eq!(detect_linux_de("Budgie:GNOME"), LinuxDesktop::Budgie);
498 }
499
500 #[test]
501 fn detect_empty_string() {
502 assert_eq!(detect_linux_de(""), LinuxDesktop::Unknown);
503 }
504
505 #[test]
508 fn from_linux_non_kde_returns_adwaita() {
509 let _guard = crate::ENV_MUTEX.lock().unwrap();
510 unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "GNOME") };
514 let result = from_linux();
515 unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") };
516
517 let theme = result.expect("from_linux() should return Ok for non-KDE desktop");
518 assert_eq!(theme.name, "Adwaita");
519 }
520
521 #[test]
524 #[cfg(feature = "kde")]
525 fn from_linux_unknown_de_with_kdeglobals_fallback() {
526 let _guard = crate::ENV_MUTEX.lock().unwrap();
527 use std::io::Write;
528
529 let tmp_dir = std::env::temp_dir().join("native_theme_test_kde_fallback");
531 std::fs::create_dir_all(&tmp_dir).unwrap();
532 let kdeglobals = tmp_dir.join("kdeglobals");
533 let mut f = std::fs::File::create(&kdeglobals).unwrap();
534 writeln!(
535 f,
536 "[General]\nColorScheme=TestTheme\n\n[Colors:Window]\nBackgroundNormal=239,240,241\n"
537 )
538 .unwrap();
539
540 let orig_xdg = std::env::var("XDG_CONFIG_HOME").ok();
542 let orig_desktop = std::env::var("XDG_CURRENT_DESKTOP").ok();
543
544 unsafe { std::env::set_var("XDG_CONFIG_HOME", &tmp_dir) };
545 unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "SomeUnknownDE") };
546
547 let result = from_linux();
548
549 match orig_xdg {
551 Some(val) => unsafe { std::env::set_var("XDG_CONFIG_HOME", val) },
552 None => unsafe { std::env::remove_var("XDG_CONFIG_HOME") },
553 }
554 match orig_desktop {
555 Some(val) => unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", val) },
556 None => unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") },
557 }
558
559 let _ = std::fs::remove_dir_all(&tmp_dir);
561
562 let theme = result.expect("from_linux() should return Ok with kdeglobals fallback");
563 assert_eq!(
564 theme.name, "TestTheme",
565 "should use KDE theme name from kdeglobals"
566 );
567 }
568
569 #[test]
570 fn from_linux_unknown_de_without_kdeglobals_returns_adwaita() {
571 let _guard = crate::ENV_MUTEX.lock().unwrap();
572 let orig_xdg = std::env::var("XDG_CONFIG_HOME").ok();
574 let orig_desktop = std::env::var("XDG_CURRENT_DESKTOP").ok();
575
576 unsafe {
577 std::env::set_var(
578 "XDG_CONFIG_HOME",
579 "/tmp/nonexistent_native_theme_test_no_kde",
580 )
581 };
582 unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "SomeUnknownDE") };
583
584 let result = from_linux();
585
586 match orig_xdg {
588 Some(val) => unsafe { std::env::set_var("XDG_CONFIG_HOME", val) },
589 None => unsafe { std::env::remove_var("XDG_CONFIG_HOME") },
590 }
591 match orig_desktop {
592 Some(val) => unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", val) },
593 None => unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") },
594 }
595
596 let theme = result.expect("from_linux() should return Ok (adwaita fallback)");
597 assert_eq!(
598 theme.name, "Adwaita",
599 "should fall back to Adwaita without kdeglobals"
600 );
601 }
602
603 #[test]
606 fn from_system_returns_result() {
607 let _guard = crate::ENV_MUTEX.lock().unwrap();
608 unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "GNOME") };
612 let result = from_system();
613 unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") };
614
615 let theme = result.expect("from_system() should return Ok on Linux");
616 assert_eq!(theme.name, "Adwaita");
617 }
618}
619
620#[cfg(test)]
621mod load_icon_tests {
622 use super::*;
623
624 #[test]
625 #[cfg(feature = "material-icons")]
626 fn load_icon_material_returns_svg() {
627 let result = load_icon(IconRole::ActionCopy, "material");
628 assert!(result.is_some(), "material ActionCopy should return Some");
629 match result.unwrap() {
630 IconData::Svg(bytes) => {
631 let content = std::str::from_utf8(&bytes).expect("should be valid UTF-8");
632 assert!(content.contains("<svg"), "should contain SVG data");
633 }
634 _ => panic!("expected IconData::Svg for bundled material icon"),
635 }
636 }
637
638 #[test]
639 #[cfg(feature = "lucide-icons")]
640 fn load_icon_lucide_returns_svg() {
641 let result = load_icon(IconRole::ActionCopy, "lucide");
642 assert!(result.is_some(), "lucide ActionCopy should return Some");
643 match result.unwrap() {
644 IconData::Svg(bytes) => {
645 let content = std::str::from_utf8(&bytes).expect("should be valid UTF-8");
646 assert!(content.contains("<svg"), "should contain SVG data");
647 }
648 _ => panic!("expected IconData::Svg for bundled lucide icon"),
649 }
650 }
651
652 #[test]
653 #[cfg(feature = "material-icons")]
654 fn load_icon_unknown_theme_falls_back() {
655 let result = load_icon(IconRole::ActionCopy, "unknown-theme");
658 assert!(
659 result.is_some(),
660 "unknown theme should fall back to bundled Material"
661 );
662 }
663
664 #[test]
665 #[cfg(feature = "material-icons")]
666 fn load_icon_all_roles_material() {
667 let mut some_count = 0;
670 for role in IconRole::ALL {
671 if load_icon(role, "material").is_some() {
672 some_count += 1;
673 }
674 }
675 assert_eq!(
677 some_count, 42,
678 "Material should cover all 42 roles via bundled SVGs"
679 );
680 }
681
682 #[test]
683 #[cfg(feature = "lucide-icons")]
684 fn load_icon_all_roles_lucide() {
685 let mut some_count = 0;
686 for role in IconRole::ALL {
687 if load_icon(role, "lucide").is_some() {
688 some_count += 1;
689 }
690 }
691 assert_eq!(
693 some_count, 42,
694 "Lucide should cover all 42 roles via bundled SVGs"
695 );
696 }
697
698 #[test]
699 fn load_icon_unrecognized_set_no_features() {
700 let _result = load_icon(IconRole::ActionCopy, "sf-symbols");
703 }
705}