1use std::borrow::Cow;
10use std::collections::HashMap;
11use std::ffi::OsStr;
12use std::num::NonZeroU32;
13use std::sync::{Mutex, OnceLock};
14
15use crate::IconData;
16use crate::icons::{has_theme_index, icon_base_dirs};
17use crate::model::animated::{AnimatedIcon, TransformAnimation};
18use std::path::{Component, Path, PathBuf};
19
20const FREEDESKTOP_FRAME_DURATION_MS: u32 = 80;
22
23const FREEDESKTOP_SPIN_DURATION_MS: u32 = 1000;
25
26fn inherits(index: &str) -> Vec<&str> {
29 let mut in_icon_theme = false;
30 for line in index.lines().map(str::trim) {
31 if let Some(group) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
32 in_icon_theme = group == "Icon Theme";
33 continue;
34 }
35 if in_icon_theme
36 && let Some((key, value)) = line.split_once('=')
37 && key.trim() == "Inherits"
38 {
39 return value
40 .split(',')
41 .map(str::trim)
42 .filter(|parent| !parent.is_empty())
43 .collect();
44 }
45 }
46 Vec::new()
47}
48
49fn declared_parents(theme: &str, bases: &[PathBuf]) -> Vec<String> {
53 let mut parents: Vec<String> = Vec::new();
54 for base in bases {
55 let Ok(index) = std::fs::read_to_string(base.join(theme).join("index.theme")) else {
56 continue;
57 };
58 for parent in inherits(&index) {
59 if !parents.iter().any(|known| known == parent) {
60 parents.push(parent.to_string());
61 }
62 }
63 }
64 parents
65}
66
67fn theme_chain(theme: &str, bases: &[PathBuf]) -> Option<Vec<String>> {
76 if !has_theme_index(theme, bases) {
77 return None;
78 }
79 let mut chain: Vec<String> = Vec::new();
80 let mut pending = vec![theme.to_string()];
81 while let Some(name) = pending.pop() {
82 if chain.contains(&name) {
83 continue;
84 }
85 let parents = declared_parents(&name, bases);
86 chain.push(name);
87 pending.extend(
88 parents
89 .into_iter()
90 .rev()
91 .filter(|parent| parent != "hicolor" && has_theme_index(parent, bases)),
92 );
93 }
94 Some(chain)
95}
96
97fn cached_theme_chain(theme: &str) -> Option<Vec<String>> {
100 type Chains = Mutex<HashMap<String, Option<Vec<String>>>>;
101 static CHAINS: OnceLock<Chains> = OnceLock::new();
102 let chains = CHAINS.get_or_init(Chains::default);
103 if let Ok(known) = chains.lock()
104 && let Some(chain) = known.get(theme)
105 {
106 return chain.clone();
107 }
108 let chain = theme_chain(theme, icon_base_dirs());
109 if let Ok(mut known) = chains.lock() {
110 known.insert(theme.to_string(), chain.clone());
111 }
112 chain
113}
114
115fn is_in_theme_dir(path: &Path, theme: &str, bases: &[PathBuf]) -> bool {
122 if path
123 .components()
124 .any(|component| component == Component::ParentDir)
125 {
126 return false;
127 }
128 bases.iter().any(|base| {
129 let Ok(rest) = path.strip_prefix(base) else {
130 return false;
131 };
132 let mut rest = rest.components();
133 let in_theme = matches!(
134 rest.next(),
135 Some(Component::Normal(dir)) if dir == OsStr::new(theme)
136 );
137 in_theme && rest.next().is_some()
138 })
139}
140
141fn first_in_chain(
152 chain: &[String],
153 bases: &[PathBuf],
154 mut lookup: impl FnMut(&str) -> Option<PathBuf>,
155) -> Option<PathBuf> {
156 chain
157 .iter()
158 .find_map(|theme| lookup(theme).filter(|path| is_in_theme_dir(path, theme, bases)))
159}
160
161fn lookup_in_chain(name: &str, size: u16, chain: &[String]) -> Option<PathBuf> {
164 first_in_chain(chain, icon_base_dirs(), |theme| {
165 freedesktop_icons::lookup(name)
166 .with_theme(theme)
167 .with_size(size)
168 .force_svg()
169 .find()
170 })
171}
172
173fn find_icon(name: &str, theme: &str, size: u16) -> Option<(PathBuf, bool)> {
188 let chain = cached_theme_chain(theme)?;
189 let symbolic = format!("{name}-symbolic");
193 if let Some(path) = lookup_in_chain(&symbolic, size, &chain) {
194 return Some((path, true));
195 }
196 lookup_in_chain(name, size, &chain).map(|path| (path, name.ends_with("-symbolic")))
200}
201
202#[must_use]
223pub(crate) fn load_freedesktop_icon_by_name(
224 name: &str,
225 theme: &str,
226 size: u16,
227 fg_color: Option<[u8; 3]>,
228) -> Option<IconData> {
229 let (path, is_symbolic) = find_icon(name, theme, size)?;
230 load_icon_file(&path, is_symbolic, fg_color)
231}
232
233fn load_icon_file(path: &Path, is_symbolic: bool, fg_color: Option<[u8; 3]>) -> Option<IconData> {
239 let ext = path
240 .extension()
241 .and_then(|e| e.to_str())
242 .map(str::to_ascii_lowercase);
243 match ext.as_deref() {
244 Some("svg") => {
245 let bytes = std::fs::read(path).ok()?;
246 let bytes = if is_symbolic {
247 let replacement = fg_to_replacement(fg_color);
248 normalize_gtk_symbolic(bytes, &replacement)
249 } else {
250 bytes
251 };
252 Some(IconData::Svg(Cow::Owned(bytes)))
253 }
254 Some("png") => {
255 let bytes = std::fs::read(path).ok()?;
256 decode_png_to_rgba(&bytes)
257 }
258 _ => None,
259 }
260}
261
262fn decode_png_to_rgba(bytes: &[u8]) -> Option<IconData> {
272 let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes));
273 decoder.set_transformations(
274 png::Transformations::EXPAND | png::Transformations::STRIP_16 | png::Transformations::ALPHA,
275 );
276 let mut reader = decoder.read_info().ok()?;
277 let mut buf = vec![0u8; reader.output_buffer_size()?];
278 let info = reader.next_frame(&mut buf).ok()?;
279 buf.truncate(info.buffer_size());
280
281 let (width, height) = (info.width, info.height);
282 let pixel_count = (width as usize).checked_mul(height as usize)?;
283 let rgba_len = pixel_count.checked_mul(4)?;
284
285 let rgba = match info.color_type {
286 png::ColorType::Rgba => buf,
287 png::ColorType::Rgb => expand_to_rgba(&buf, 3, |p, out| {
288 if let [r, g, b] = p {
290 out.extend_from_slice(&[*r, *g, *b, 0xff]);
291 }
292 })?,
293 png::ColorType::GrayscaleAlpha => expand_to_rgba(&buf, 2, |p, out| {
294 if let [gray, alpha] = p {
295 out.extend_from_slice(&[*gray, *gray, *gray, *alpha]);
296 }
297 })?,
298 png::ColorType::Grayscale => expand_to_rgba(&buf, 1, |p, out| {
299 if let [gray] = p {
300 out.extend_from_slice(&[*gray, *gray, *gray, 0xff]);
301 }
302 })?,
303 png::ColorType::Indexed => return None,
306 };
307
308 if rgba.len() != rgba_len {
309 return None;
310 }
311 Some(IconData::Rgba {
312 width,
313 height,
314 data: rgba,
315 })
316}
317
318fn expand_to_rgba<F>(buf: &[u8], sample_stride: usize, mut write: F) -> Option<Vec<u8>>
324where
325 F: FnMut(&[u8], &mut Vec<u8>),
326{
327 if sample_stride == 0 || !buf.len().is_multiple_of(sample_stride) {
328 return None;
329 }
330 let pixel_count = buf.len().checked_div(sample_stride)?;
332 let mut out = Vec::with_capacity(pixel_count.saturating_mul(4));
333 for chunk in buf.chunks_exact(sample_stride) {
334 write(chunk, &mut out);
335 }
336 Some(out)
337}
338
339fn fg_to_replacement(fg_color: Option<[u8; 3]>) -> String {
342 match fg_color {
343 Some([r, g, b]) => format!("#{r:02x}{g:02x}{b:02x}"),
344 None => "currentColor".to_string(),
345 }
346}
347
348fn parse_sprite_sheet(svg_bytes: &[u8]) -> Option<Vec<Vec<u8>>> {
358 let svg_str = std::str::from_utf8(svg_bytes).ok()?;
359
360 let (vb_attr_start, vb_val_start, quote) = svg_str
362 .find("viewBox=\"")
363 .map(|i| (i, i.saturating_add(9), '"'))
364 .or_else(|| {
365 svg_str
366 .find("viewBox='")
367 .map(|i| (i, i.saturating_add(9), '\''))
368 })?;
369
370 let tail = svg_str.get(vb_val_start..)?;
371 let vb_val_end = tail.find(quote)?.saturating_add(vb_val_start);
372 let vb_value = svg_str.get(vb_val_start..vb_val_end)?;
373
374 let parts: Vec<f64> = vb_value
376 .split(|c: char| c.is_whitespace() || c == ',')
377 .filter(|s| !s.is_empty())
378 .filter_map(|s| s.parse().ok())
379 .collect();
380
381 let [_, _, width, height] = parts.as_slice() else {
382 return None;
383 };
384 let (width, height) = (*width, *height);
385 if height <= width {
386 return None; }
388
389 let frame_count = (height / width).round() as usize;
390 if frame_count < 2 {
391 return None;
392 }
393
394 if (height - width * frame_count as f64).abs() > 0.01 {
396 return None;
397 }
398
399 let original_vb_attr = svg_str.get(vb_attr_start..vb_val_end.saturating_add(1))?; let frames = (0..frame_count)
403 .map(|i| {
404 let y_offset = width * i as f64;
405 let new_vb = format!("viewBox={quote}0 {y_offset} {width} {width}{quote}");
406 svg_str.replacen(original_vb_attr, &new_vb, 1).into_bytes()
407 })
408 .collect();
409
410 Some(frames)
411}
412
413pub(crate) fn load_freedesktop_spinner(theme: Option<&str>) -> Option<AnimatedIcon> {
429 load_freedesktop_spinner_with(theme, crate::system_icon_theme)
430}
431
432fn load_freedesktop_spinner_with(
435 theme: Option<&str>,
436 detect: impl FnOnce() -> crate::Result<String>,
437) -> Option<AnimatedIcon> {
438 let detected;
439 let theme: &str = match theme {
440 Some(t) => t,
441 None => {
442 detected = detect().ok()?;
443 &detected
444 }
445 };
446 let chain = cached_theme_chain(theme)?;
447
448 if let Some(path) = lookup_in_chain("process-working", 22, &chain) {
450 let bytes = std::fs::read(&path).ok()?;
451 let frame_dur = NonZeroU32::new(FREEDESKTOP_FRAME_DURATION_MS)?;
452 let spin_dur = NonZeroU32::new(FREEDESKTOP_SPIN_DURATION_MS)?;
453 if let Some(frames) = parse_sprite_sheet(&bytes) {
454 let frame_icons: Vec<IconData> = frames
455 .into_iter()
456 .map(|b| IconData::Svg(Cow::Owned(b)))
457 .collect();
458 return AnimatedIcon::frames(frame_icons, frame_dur).ok();
459 }
460 return Some(AnimatedIcon::transform(
462 IconData::Svg(Cow::Owned(bytes)),
463 TransformAnimation::Spin {
464 duration_ms: spin_dur,
465 },
466 ));
467 }
468
469 if let Some(path) = lookup_in_chain("process-working-symbolic", 22, &chain) {
471 let bytes = std::fs::read(&path).ok()?;
472 let spin_dur = NonZeroU32::new(FREEDESKTOP_SPIN_DURATION_MS)?;
473 return Some(AnimatedIcon::transform(
474 IconData::Svg(Cow::Owned(bytes)),
475 TransformAnimation::Spin {
476 duration_ms: spin_dur,
477 },
478 ));
479 }
480
481 None
482}
483
484const GTK_FG_COLORS: &[&str] = &["#2e3436", "#2e3434", "#222222", "#474747"];
497
498fn normalize_gtk_symbolic(svg_bytes: Vec<u8>, replacement: &str) -> Vec<u8> {
517 let Ok(svg_str) = std::str::from_utf8(&svg_bytes) else {
518 return svg_bytes;
519 };
520
521 if svg_str.contains("currentColor") {
523 return svg_bytes;
524 }
525
526 if !GTK_FG_COLORS.iter().any(|c| svg_str.contains(c)) {
528 return svg_bytes;
529 }
530
531 let mut result = svg_str.to_string();
532 for color in GTK_FG_COLORS {
533 result = result.replace(
535 &format!("fill=\"{color}\""),
536 &format!("fill=\"{replacement}\""),
537 );
538 result = result.replace(
539 &format!("stroke=\"{color}\""),
540 &format!("stroke=\"{replacement}\""),
541 );
542 result = result.replace(&format!("fill:{color}"), &format!("fill:{replacement}"));
544 result = result.replace(&format!("stroke:{color}"), &format!("stroke:{replacement}"));
545 }
546 result.into_bytes()
547}
548
549#[cfg(test)]
550#[cfg(feature = "system-icons")]
551#[allow(clippy::unwrap_used, clippy::expect_used)]
552mod tests {
553 use super::*;
554 use crate::{IconRole, IconSet, icon_name};
555
556 #[test]
557 #[ignore = "requires a freedesktop icon theme installed (not available on CI)"]
558 fn load_icon_returns_some_for_dialog_error() {
559 let theme = crate::system_icon_theme().expect("requires a detected icon theme");
560 let name = icon_name(IconRole::DialogError, IconSet::Freedesktop).unwrap();
561 let result = load_freedesktop_icon_by_name(name, &theme, 24, None);
562 assert!(result.is_some(), "DialogError should resolve to an icon");
563 match result.unwrap() {
564 IconData::Svg(ref cow) => {
565 let s = String::from_utf8_lossy(cow);
566 assert!(s.contains("<svg"), "Icon data should contain <svg tag");
567 }
568 other => panic!("Expected SVG data, got {other:?}"),
569 }
570 }
571
572 #[test]
573 fn load_icon_notification_attempts_native_lookup() {
574 if let Ok(theme) = crate::system_icon_theme()
578 && let Some(name) = icon_name(IconRole::Notification, IconSet::Freedesktop)
579 {
580 let _result = load_freedesktop_icon_by_name(name, &theme, 24, None);
581 }
582 }
584
585 #[test]
586 #[ignore = "requires a freedesktop icon theme installed (not available on CI)"]
587 fn load_icon_returns_svg_variant() {
588 let theme = crate::system_icon_theme().expect("requires a detected icon theme");
589 let name = icon_name(IconRole::ActionCopy, IconSet::Freedesktop).unwrap();
590 let result = load_freedesktop_icon_by_name(name, &theme, 24, None);
591 assert!(result.is_some(), "ActionCopy should resolve to an icon");
592 assert!(
593 matches!(result.unwrap(), IconData::Svg(_)),
594 "Expected Svg variant"
595 );
596 }
597
598 #[test]
601 fn spinner_without_a_detected_theme_is_none() {
602 let spinner = load_freedesktop_spinner_with(None, || {
603 Err(crate::Error::PlatformUnsupported {
604 platform: "the test's failing detection",
605 })
606 });
607 assert!(
608 spinner.is_none(),
609 "a spinner loaded though no icon theme was detected"
610 );
611 }
612
613 #[test]
614 fn find_icon_nonexistent_returns_none() {
615 let result = find_icon("totally-nonexistent-icon-xyz", "hicolor", 24);
616 assert!(result.is_none(), "Nonexistent icon should return None");
617 }
618
619 #[test]
620 #[ignore = "requires a freedesktop icon theme installed (not available on CI)"]
621 fn load_icon_by_name_finds_edit_copy() {
622 let theme = crate::system_icon_theme().expect("requires a detected icon theme");
623 let result = load_freedesktop_icon_by_name("edit-copy", &theme, 24, None);
624 assert!(
625 result.is_some(),
626 "edit-copy should be found in system theme"
627 );
628 assert!(matches!(result.unwrap(), IconData::Svg(_)));
629 }
630
631 struct Fixture {
643 root: PathBuf,
644 bases: Vec<PathBuf>,
645 }
646
647 impl Fixture {
648 fn new(tag: &str) -> Self {
649 let root = std::env::temp_dir().join(format!(
650 "native-theme-icon-dirs-{}-{tag}",
651 std::process::id()
652 ));
653 let _ = std::fs::remove_dir_all(&root);
654 let icons = root.join("share/icons");
655 let home_icons = root.join("home/.icons");
656 let theme = |base: &Path, name: &str, inherits: Option<&str>| {
657 let dir = base.join(name);
658 std::fs::create_dir_all(dir.join("actions")).unwrap();
659 let inherits = inherits.map_or(String::new(), |i| format!("Inherits={i}\n"));
660 std::fs::write(
661 dir.join("index.theme"),
662 format!("[Icon Theme]\nName={name}\n{inherits}Directories=actions\n"),
663 )
664 .unwrap();
665 };
666 theme(&icons, "child", Some("parent,hicolor"));
667 theme(&icons, "parent", Some("grand"));
668 theme(&icons, "grand", None);
669 theme(&icons, "fork", Some("left,right"));
670 theme(&icons, "left", Some("deep"));
671 theme(&icons, "right", None);
672 theme(&icons, "deep", None);
673 theme(&icons, "hicolor", None);
674 theme(&icons, "breeze", None);
675 theme(&icons, "breeze-dark", None);
676 theme(&icons, "cycle-a", Some("cycle-b"));
677 theme(&icons, "cycle-b", Some("cycle-a"));
678 std::fs::create_dir_all(home_icons.join("child/actions")).unwrap();
679 std::fs::write(icons.join("loose.svg"), "<svg/>").unwrap();
680 std::fs::create_dir_all(root.join("share/pixmaps")).unwrap();
681 std::fs::write(root.join("share/pixmaps/pixmap.svg"), "<svg/>").unwrap();
682 Self {
683 bases: vec![icons, home_icons],
684 root,
685 }
686 }
687
688 fn icons(&self, rest: &str) -> PathBuf {
689 self.root.join("share/icons").join(rest)
690 }
691
692 fn accepts(&self, theme: &str, path: &Path) -> bool {
695 theme_chain(theme, &self.bases).is_some_and(|names| {
696 names
697 .iter()
698 .any(|name| is_in_theme_dir(path, name, &self.bases))
699 })
700 }
701
702 fn add_icon(&self, theme: &str, name: &str) -> PathBuf {
704 let path = self.icons(&format!("{theme}/actions/{name}.svg"));
705 std::fs::write(&path, "<svg/>").unwrap();
706 path
707 }
708
709 fn freedesktop_icons_lookup(&self, name: &str, theme: &str) -> Option<PathBuf> {
713 let mut searched = vec![theme.to_string()];
714 searched.extend(declared_parents(theme, &self.bases));
715 searched.push("hicolor".to_string());
716 searched
717 .iter()
718 .flat_map(|t| {
719 self.bases
720 .iter()
721 .map(move |base| base.join(t).join("actions").join(format!("{name}.svg")))
722 })
723 .find(|path| path.exists())
724 }
725
726 fn find(&self, name: &str, theme: &str) -> Option<PathBuf> {
729 let chain = theme_chain(theme, &self.bases)?;
730 first_in_chain(&chain, &self.bases, |t| {
731 self.freedesktop_icons_lookup(name, t)
732 })
733 }
734 }
735
736 impl Drop for Fixture {
737 fn drop(&mut self) {
738 let _ = std::fs::remove_dir_all(&self.root);
739 }
740 }
741
742 #[test]
743 fn theme_dirs_accept_the_theme_itself() {
744 let fx = Fixture::new("own");
745 assert!(fx.accepts("child", &fx.icons("child/actions/edit-copy.svg")));
746 assert!(fx.accepts(
747 "child",
748 &fx.root.join("home/.icons/child/actions/edit-copy.svg")
749 ));
750 }
751
752 #[test]
753 fn theme_dirs_accept_a_declared_parent() {
754 let fx = Fixture::new("parent");
755 assert!(fx.accepts("child", &fx.icons("parent/actions/edit-copy.svg")));
756 }
757
758 #[test]
759 fn theme_dirs_reject_hicolor_even_when_declared() {
760 let fx = Fixture::new("hicolor");
761 assert!(!fx.accepts("child", &fx.icons("hicolor/actions/edit-copy.svg")));
762 assert!(!fx.accepts("parent", &fx.icons("hicolor/actions/edit-copy.svg")));
763 }
764
765 #[test]
766 fn theme_dirs_accept_hicolor_when_it_is_the_theme() {
767 let fx = Fixture::new("hicolor-chosen");
768 assert!(fx.accepts("hicolor", &fx.icons("hicolor/actions/edit-copy.svg")));
769 }
770
771 #[test]
772 fn theme_dirs_reject_loose_and_pixmaps_files() {
773 let fx = Fixture::new("loose");
774 assert!(!fx.accepts("child", &fx.icons("loose.svg")));
775 assert!(!fx.accepts("child", &fx.root.join("home/.icons/loose.svg")));
776 assert!(!fx.accepts("child", &fx.root.join("share/pixmaps/pixmap.svg")));
777 assert!(!fx.accepts(
778 "child",
779 &fx.root.join("share/pixmaps/child/actions/edit-copy.svg")
780 ));
781 }
782
783 #[test]
784 fn theme_dirs_match_whole_components() {
785 let fx = Fixture::new("components");
786 assert!(!fx.accepts("breeze", &fx.icons("breeze-dark/actions/edit-copy.svg")));
787 assert!(!fx.accepts("breeze-dark", &fx.icons("breeze/actions/edit-copy.svg")));
788 assert!(fx.accepts("breeze", &fx.icons("breeze/actions/edit-copy.svg")));
789 }
790
791 #[test]
792 fn theme_dirs_reject_a_path_climbing_out_of_the_theme() {
793 let fx = Fixture::new("climb");
794 assert!(!fx.accepts("child", &fx.icons("child/../hicolor/actions/edit-copy.svg")));
795 }
796
797 #[test]
798 fn theme_chain_is_depth_first_in_declared_order() {
799 let fx = Fixture::new("order");
800 assert_eq!(
801 theme_chain("child", &fx.bases).unwrap(),
802 ["child", "parent", "grand"]
803 );
804 assert_eq!(
805 theme_chain("fork", &fx.bases).unwrap(),
806 ["fork", "left", "deep", "right"]
807 );
808 }
809
810 #[test]
811 fn chain_lookup_reaches_a_grandparent() {
812 let fx = Fixture::new("grand");
813 let grand = fx.add_icon("grand", "grand-only");
814 fx.add_icon("hicolor", "grand-only");
815 assert_eq!(fx.find("grand-only", "child"), Some(grand));
816 }
817
818 #[test]
819 fn chain_lookup_takes_a_theme_s_own_file_only() {
820 let fx = Fixture::new("own-file");
821 fx.add_icon("parent", "parent-only");
822 fx.add_icon("hicolor", "hicolor-only");
823 let child_only = ["child".to_string()];
824 let lookup = |t: &str| fx.freedesktop_icons_lookup("parent-only", t);
825 assert_eq!(first_in_chain(&child_only, &fx.bases, lookup), None);
826 let lookup = |t: &str| fx.freedesktop_icons_lookup("hicolor-only", t);
827 assert_eq!(first_in_chain(&child_only, &fx.bases, lookup), None);
828 assert_eq!(fx.find("hicolor-only", "child"), None);
829 }
830
831 #[test]
832 fn chain_lookup_takes_the_nearer_theme() {
833 let fx = Fixture::new("nearer");
834 let parent = fx.add_icon("parent", "both");
835 fx.add_icon("grand", "both");
836 assert_eq!(fx.find("both", "child"), Some(parent));
837 let deep = fx.add_icon("deep", "split");
838 fx.add_icon("right", "split");
839 assert_eq!(fx.find("split", "fork"), Some(deep));
840 }
841
842 #[test]
843 fn theme_chain_terminates_on_a_cycle() {
844 let fx = Fixture::new("cycle");
845 let mut names = theme_chain("cycle-a", &fx.bases).unwrap();
846 names.sort();
847 assert_eq!(names, ["cycle-a", "cycle-b"]);
848 }
849
850 #[test]
851 fn theme_chain_of_a_missing_theme_is_none() {
852 let fx = Fixture::new("missing");
853 assert_eq!(theme_chain("no-such-theme", &fx.bases), None);
854 assert_eq!(theme_chain("../share/icons/child", &fx.bases), None);
855 }
856
857 #[test]
860 fn find_icon_rejects_a_loose_base_dir_file() {
861 let loose = Path::new("/usr/share/icons/cachyos.svg");
862 if !loose.exists() || !Path::new("/usr/share/icons/breeze/index.theme").exists() {
863 eprintln!("skipped: needs {} and the breeze theme", loose.display());
864 return;
865 }
866 assert_eq!(find_icon("cachyos", "breeze", 24), None);
867 }
868
869 #[test]
871 fn find_icon_never_returns_hicolor_for_another_theme() {
872 let apps = Path::new("/usr/share/icons/hicolor/scalable/apps");
873 let (Ok(entries), true) = (
874 std::fs::read_dir(apps),
875 Path::new("/usr/share/icons/breeze/index.theme").exists(),
876 ) else {
877 eprintln!("skipped: needs {} and the breeze theme", apps.display());
878 return;
879 };
880 let hicolor = Path::new("/usr/share/icons/hicolor");
881 let names: Vec<String> = entries
882 .filter_map(Result::ok)
883 .filter_map(|e| {
884 let path = e.path();
885 (path.extension()? == "svg").then_some(())?;
886 Some(path.file_stem()?.to_str()?.to_string())
887 })
888 .take(20)
889 .collect();
890 for name in &names {
891 if let Some((path, _)) = find_icon(name, "breeze", 24) {
892 assert!(
893 !path.starts_with(hicolor),
894 "{name} for breeze came from hicolor: {}",
895 path.display()
896 );
897 }
898 }
899 }
900
901 #[test]
904 fn find_icon_reaches_a_grandparent_theme() {
905 let icons = Path::new("/usr/share/icons");
906 let default_index = std::fs::read_to_string(icons.join("default/index.theme"));
907 let inherits_adwaita = default_index
908 .as_deref()
909 .is_ok_and(|index| inherits(index).contains(&"Adwaita"));
910 let legacy = icons.join("AdwaitaLegacy");
911 if !inherits_adwaita || !legacy.join("index.theme").exists() {
912 eprintln!("skipped: needs `default` inheriting Adwaita, and AdwaitaLegacy");
913 return;
914 }
915 fn stems(dir: &Path, out: &mut std::collections::BTreeSet<String>) {
916 for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() {
917 let path = entry.path();
918 if path.is_dir() {
919 stems(&path, out);
920 } else if let (Some(stem), Some("png" | "svg")) = (
921 path.file_stem().and_then(|s| s.to_str()),
922 path.extension().and_then(|e| e.to_str()),
923 ) {
924 out.insert(stem.to_string());
925 }
926 }
927 }
928 let mut legacy_names = std::collections::BTreeSet::new();
929 stems(&legacy, &mut legacy_names);
930 let mut adwaita_names = std::collections::BTreeSet::new();
931 stems(&icons.join("Adwaita"), &mut adwaita_names);
932 stems(&icons.join("default"), &mut adwaita_names);
933 let legacy_only: Vec<&String> = legacy_names
934 .iter()
935 .filter(|name| {
936 !name.ends_with("-symbolic")
937 && !adwaita_names.contains(name.as_str())
938 && !adwaita_names.contains(&format!("{name}-symbolic"))
939 })
940 .take(20)
941 .collect();
942 assert!(!legacy_only.is_empty(), "no AdwaitaLegacy-only icon found");
943 for name in legacy_only {
944 let found = find_icon(name, "default", 24);
945 assert!(
946 found
947 .as_ref()
948 .is_some_and(|(path, _)| path.starts_with(&legacy)),
949 "{name} for default: {found:?}"
950 );
951 }
952 }
953
954 #[test]
956 fn find_icon_still_finds_breeze_edit_copy() {
957 if !Path::new("/usr/share/icons/breeze/index.theme").exists() {
958 eprintln!("skipped: the breeze theme is not installed");
959 return;
960 }
961 let (path, _) = find_icon("edit-copy", "breeze", 24).expect("breeze has edit-copy");
962 assert!(
963 path.starts_with("/usr/share/icons/breeze"),
964 "{}",
965 path.display()
966 );
967 }
968
969 #[test]
970 fn load_icon_by_name_returns_none_for_nonexistent() {
971 let result = load_freedesktop_icon_by_name("zzz-nonexistent-icon", "hicolor", 24, None);
972 assert!(result.is_none());
973 }
974
975 #[test]
978 fn test_parse_sprite_sheet_two_frames() {
979 let svg = br#"<svg viewBox="0 0 10 20" xmlns="http://www.w3.org/2000/svg">
981 <rect x="0" y="0" width="10" height="10" fill="red"/>
982 <rect x="0" y="10" width="10" height="10" fill="blue"/>
983 </svg>"#;
984
985 let frames = parse_sprite_sheet(svg).expect("should parse 2-frame sprite sheet");
986 assert_eq!(frames.len(), 2);
987
988 let frame0 = std::str::from_utf8(&frames[0]).unwrap();
989 assert!(
990 frame0.contains(r#"viewBox="0 0 10 10""#),
991 "frame 0 viewBox: {frame0}"
992 );
993
994 let frame1 = std::str::from_utf8(&frames[1]).unwrap();
995 assert!(
996 frame1.contains(r#"viewBox="0 10 10 10""#),
997 "frame 1 viewBox: {frame1}"
998 );
999 }
1000
1001 #[test]
1002 fn test_parse_sprite_sheet_fifteen_frames() {
1003 let svg = br#"<svg viewBox="0 0 22 330" xmlns="http://www.w3.org/2000/svg">
1005 <path d="M0 0"/>
1006 </svg>"#;
1007
1008 let frames = parse_sprite_sheet(svg).expect("should parse 15-frame sprite sheet");
1009 assert_eq!(frames.len(), 15);
1010
1011 let first = std::str::from_utf8(&frames[0]).unwrap();
1013 assert!(first.contains(r#"viewBox="0 0 22 22""#));
1014
1015 let last = std::str::from_utf8(&frames[14]).unwrap();
1016 assert!(last.contains(r#"viewBox="0 308 22 22""#));
1017 }
1018
1019 #[test]
1020 fn test_parse_sprite_sheet_single_frame_returns_none() {
1021 let svg = br#"<svg viewBox="0 0 22 22" xmlns="http://www.w3.org/2000/svg">
1023 <circle cx="11" cy="11" r="10"/>
1024 </svg>"#;
1025 assert!(parse_sprite_sheet(svg).is_none());
1026 }
1027
1028 #[test]
1029 fn test_parse_sprite_sheet_non_multiple_returns_none() {
1030 let svg = br#"<svg viewBox="0 0 22 33" xmlns="http://www.w3.org/2000/svg">
1032 <path d="M0 0"/>
1033 </svg>"#;
1034 assert!(parse_sprite_sheet(svg).is_none());
1035 }
1036
1037 #[test]
1038 fn test_parse_sprite_sheet_invalid_svg_returns_none() {
1039 assert!(parse_sprite_sheet(b"not svg at all").is_none());
1040 }
1041
1042 #[test]
1043 fn test_parse_sprite_sheet_comma_separated_viewbox() {
1044 let svg = br#"<svg viewBox="0,0,10,20" xmlns="http://www.w3.org/2000/svg">
1046 <rect x="0" y="0" width="10" height="10" fill="red"/>
1047 </svg>"#;
1048
1049 let frames = parse_sprite_sheet(svg).expect("should parse comma-separated viewBox");
1050 assert_eq!(frames.len(), 2);
1051
1052 let frame0 = std::str::from_utf8(&frames[0]).unwrap();
1053 assert!(frame0.contains(r#"viewBox="0 0 10 10""#));
1054 }
1055
1056 #[test]
1057 fn test_parse_sprite_sheet_preserves_svg_content() {
1058 let svg = br#"<svg viewBox="0 0 10 20" xmlns="http://www.w3.org/2000/svg">
1059 <rect x="0" y="0" width="10" height="10" fill="red" id="unique-marker"/>
1060 <rect x="0" y="10" width="10" height="10" fill="blue"/>
1061 </svg>"#;
1062
1063 let frames = parse_sprite_sheet(svg).unwrap();
1064 for frame in &frames {
1066 let s = std::str::from_utf8(frame).unwrap();
1067 assert!(
1068 s.contains("unique-marker"),
1069 "SVG content should be preserved in all frames"
1070 );
1071 assert!(s.contains("<rect"), "rect elements should be preserved");
1072 assert!(s.contains("xmlns="), "namespace should be preserved");
1073 }
1074 }
1075
1076 #[test]
1077 fn test_load_freedesktop_spinner_no_panic() {
1078 let _result = load_freedesktop_spinner(None);
1080 }
1081
1082 #[test]
1085 fn normalize_gtk_symbolic_replaces_2e3436() {
1086 let svg = br##"<svg><path fill="#2e3436" d="M0 0"/></svg>"##.to_vec();
1087 let result = normalize_gtk_symbolic(svg, "#ffffff");
1088 let s = std::str::from_utf8(&result).unwrap();
1089 assert!(s.contains(r##"fill="#ffffff""##));
1090 assert!(!s.contains("#2e3436"));
1091 }
1092
1093 #[test]
1094 fn normalize_gtk_symbolic_replaces_2e3434_preserves_opacity() {
1095 let svg = br##"<svg><path fill="#2e3434" fill-opacity="0.35" d="M0 0"/></svg>"##.to_vec();
1096 let result = normalize_gtk_symbolic(svg, "#ffffff");
1097 let s = std::str::from_utf8(&result).unwrap();
1098 assert!(s.contains(r##"fill="#ffffff""##));
1099 assert!(s.contains(r#"fill-opacity="0.35""#));
1100 }
1101
1102 #[test]
1103 fn normalize_gtk_symbolic_replaces_222222() {
1104 let svg = br##"<svg><path fill="#222222" d="M0 0"/></svg>"##.to_vec();
1105 let result = normalize_gtk_symbolic(svg, "#ffffff");
1106 let s = std::str::from_utf8(&result).unwrap();
1107 assert!(s.contains(r##"fill="#ffffff""##));
1108 assert!(!s.contains("#222222"));
1109 }
1110
1111 #[test]
1112 fn normalize_gtk_symbolic_replaces_474747() {
1113 let svg = br##"<svg><path fill="#474747" d="M0 0"/></svg>"##.to_vec();
1114 let result = normalize_gtk_symbolic(svg, "#ffffff");
1115 let s = std::str::from_utf8(&result).unwrap();
1116 assert!(s.contains(r##"fill="#ffffff""##));
1117 assert!(!s.contains("#474747"));
1118 }
1119
1120 #[test]
1121 fn normalize_gtk_symbolic_replaces_stroke() {
1122 let svg = br##"<svg><path stroke="#2e3436" fill="none" d="M1 1l14 14"/></svg>"##.to_vec();
1123 let result = normalize_gtk_symbolic(svg, "#ffffff");
1124 let s = std::str::from_utf8(&result).unwrap();
1125 assert!(s.contains(r##"stroke="#ffffff""##));
1126 assert!(!s.contains("#2e3436"));
1127 }
1128
1129 #[test]
1130 fn normalize_gtk_symbolic_replaces_css_style_fill() {
1131 let svg = br##"<svg><path style="fill:#2e3436;fill-opacity:1" d="M0 0"/></svg>"##.to_vec();
1132 let result = normalize_gtk_symbolic(svg, "#ffffff");
1133 let s = std::str::from_utf8(&result).unwrap();
1134 assert!(s.contains("fill:#ffffff"));
1135 assert!(!s.contains("#2e3436"));
1136 }
1137
1138 #[test]
1139 fn normalize_gtk_symbolic_preserves_semantic_colors() {
1140 let svg = br##"<svg><path fill="#2e3436"/><path fill="#ff7800"/><path fill="#33d17a"/><path fill="#e01b24"/></svg>"##.to_vec();
1141 let result = normalize_gtk_symbolic(svg, "#ffffff");
1142 let s = std::str::from_utf8(&result).unwrap();
1143 assert!(s.contains("#ffffff"));
1144 assert!(s.contains("#ff7800"), "warning color must be preserved");
1145 assert!(s.contains("#33d17a"), "success color must be preserved");
1146 assert!(s.contains("#e01b24"), "error color must be preserved");
1147 }
1148
1149 #[test]
1150 fn normalize_gtk_symbolic_skips_currentcolor_svgs() {
1151 let svg = br##"<svg><defs><style>.ColorScheme-Text{color:#232629}</style></defs><path fill="currentColor"/></svg>"##.to_vec();
1152 let original = svg.clone();
1153 let result = normalize_gtk_symbolic(svg, "#ffffff");
1154 assert_eq!(
1155 result, original,
1156 "Breeze-style SVGs should pass through unchanged"
1157 );
1158 }
1159
1160 #[test]
1161 fn normalize_gtk_symbolic_skips_non_gtk_svgs() {
1162 let svg = br#"<svg><path fill="red"/></svg>"#.to_vec();
1163 let original = svg.clone();
1164 let result = normalize_gtk_symbolic(svg, "#ffffff");
1165 assert_eq!(
1166 result, original,
1167 "non-GTK SVGs should pass through unchanged"
1168 );
1169 }
1170
1171 fn encode_test_png(
1176 width: u32,
1177 height: u32,
1178 color: png::ColorType,
1179 pixels: &[u8],
1180 ) -> Option<Vec<u8>> {
1181 let mut out = Vec::new();
1182 let mut encoder = png::Encoder::new(&mut out, width, height);
1183 encoder.set_color(color);
1184 encoder.set_depth(png::BitDepth::Eight);
1185 let mut writer = encoder.write_header().ok()?;
1186 writer.write_image_data(pixels).ok()?;
1187 drop(writer);
1188 Some(out)
1189 }
1190
1191 #[test]
1192 fn decode_png_to_rgba_roundtrips_2x2_rgba() {
1193 let pixels: [u8; 16] = [
1195 0xff, 0x00, 0x00, 0xff, 0x00, 0xff, 0x00, 0x80, 0x00, 0x00, 0xff, 0xff, 0x11, 0x22, 0x33, 0x44, ];
1200 let bytes = encode_test_png(2, 2, png::ColorType::Rgba, &pixels).expect("test PNG encode");
1201
1202 let result = decode_png_to_rgba(&bytes);
1203 match result {
1204 Some(IconData::Rgba {
1205 width,
1206 height,
1207 data,
1208 }) => {
1209 assert_eq!(width, 2);
1210 assert_eq!(height, 2);
1211 assert_eq!(data, pixels.to_vec());
1212 }
1213 other => panic!("expected Rgba, got {other:?}"),
1214 }
1215 }
1216
1217 #[test]
1218 fn decode_png_to_rgba_expands_rgb_to_rgba() {
1219 let pixels: [u8; 6] = [
1221 0xab, 0xcd, 0xef, 0x11, 0x22, 0x33, ];
1224 let bytes = encode_test_png(2, 1, png::ColorType::Rgb, &pixels).expect("test PNG encode");
1225
1226 match decode_png_to_rgba(&bytes) {
1227 Some(IconData::Rgba {
1228 width,
1229 height,
1230 data,
1231 }) => {
1232 assert_eq!((width, height), (2, 1));
1233 assert_eq!(data, vec![0xab, 0xcd, 0xef, 0xff, 0x11, 0x22, 0x33, 0xff]);
1234 }
1235 other => panic!("expected Rgba, got {other:?}"),
1236 }
1237 }
1238
1239 #[test]
1240 fn decode_png_to_rgba_expands_grayscale_to_rgba() {
1241 let pixels: [u8; 2] = [0x40, 0xc0];
1242 let bytes =
1243 encode_test_png(2, 1, png::ColorType::Grayscale, &pixels).expect("test PNG encode");
1244
1245 match decode_png_to_rgba(&bytes) {
1246 Some(IconData::Rgba { data, .. }) => {
1247 assert_eq!(data, vec![0x40, 0x40, 0x40, 0xff, 0xc0, 0xc0, 0xc0, 0xff]);
1248 }
1249 other => panic!("expected Rgba, got {other:?}"),
1250 }
1251 }
1252
1253 #[test]
1254 fn decode_png_to_rgba_rejects_non_png_bytes() {
1255 assert!(decode_png_to_rgba(b"not a png at all").is_none());
1256 assert!(decode_png_to_rgba(&[]).is_none());
1257 }
1258
1259 #[test]
1260 #[ignore = "requires AdwaitaLegacy icon theme installed (GNOME legacy raster theme)"]
1261 fn load_icon_from_png_only_theme_returns_rgba() {
1262 let result = load_freedesktop_icon_by_name("edit-copy", "AdwaitaLegacy", 24, None);
1265 let data = result.expect("edit-copy exists in AdwaitaLegacy 24x24/legacy");
1266 match data {
1267 IconData::Rgba {
1268 width,
1269 height,
1270 data,
1271 } => {
1272 assert!(width > 0 && height > 0);
1273 assert_eq!(
1274 data.len(),
1275 (width as usize) * (height as usize) * 4,
1276 "RGBA byte count must match width*height*4"
1277 );
1278 }
1279 IconData::Svg(bytes) => {
1280 let head: Vec<u8> = bytes.iter().take(8).copied().collect();
1281 panic!(
1282 "expected Rgba, got Svg with {} bytes starting {:x?} \
1283 (PNG magic is 89 50 4e 47)",
1284 bytes.len(),
1285 head
1286 );
1287 }
1288 }
1289 }
1290}