Skip to main content

native_theme/
freedesktop.rs

1// Linux freedesktop icon theme lookup
2//
3// Resolves IconRole variants to SVG bytes from the user's active desktop
4// icon theme (Adwaita, Breeze, Papirus, etc.) using the freedesktop-icons
5// crate. Returns None when the role has no freedesktop mapping or the
6// icon is not found in the active theme or a theme it inherits from;
7// hicolor, loose files and pixmaps never stand in for a missing icon.
8
9use 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
20/// Frame duration for freedesktop sprite sheet animations (80ms per frame).
21const FREEDESKTOP_FRAME_DURATION_MS: u32 = 80;
22
23/// Spin duration for single-frame icons animated with rotation (1 second).
24const FREEDESKTOP_SPIN_DURATION_MS: u32 = 1000;
25
26/// The themes listed by the `Inherits=` key of an `index.theme`'s
27/// `[Icon Theme]` group, in order.
28fn 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
49/// The parents `theme` declares, from every `index.theme` it has in
50/// `bases` (in base-dir order), as freedesktop-icons reads each of them
51/// (`freedesktop-icons-0.4.0/src/lib.rs:309-320`).
52fn 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
67/// The themes an icon for `theme` may come from, in the order the Icon
68/// Theme Specification's `FindIconHelper` searches them: `theme`, then
69/// each parent it declares in `Inherits=`, in declared order, each
70/// followed depth-first by its own parents. A theme reached twice is
71/// searched once, so a cycle ends. `hicolor` is left out unless it is
72/// `theme` itself, and so is a parent that is not installed.
73///
74/// `None` when `theme` is not installed: nothing may stand in for it.
75fn 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
97/// [`theme_chain`] over [`icon_base_dirs`], kept per theme name for the
98/// life of the process, as freedesktop-icons keeps its theme list.
99fn 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
115/// Whether `path` lies inside `theme`'s own directory under one of
116/// `bases`, compared by path component, so `breeze` does not take a file
117/// of `breeze-dark`. The path is not canonicalized: the theme directory
118/// freedesktop-icons searched is what decides, even where it links into
119/// another theme. A path with a `..` component is refused, as it could
120/// leave the directory it names.
121fn 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
141/// The first icon `lookup` finds for a theme of `chain`, in chain order,
142/// counting only a file in the directory of the theme it was looked up
143/// in.
144///
145/// freedesktop-icons, asked for one theme, goes on to its direct parents
146/// (not theirs), then to `hicolor`, loose files in the icon base dirs and
147/// `/usr/share/pixmaps` (`freedesktop-icons-0.4.0/src/lib.rs:297-347`).
148/// Asking it for each theme of the chain in turn and keeping only that
149/// theme's own files reaches every depth of the chain in the
150/// specification's order, and none of those fallbacks.
151fn 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
161/// Look `name` up at `size` in the themes of `chain` (see
162/// [`theme_chain`] and [`first_in_chain`]).
163fn 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
173/// Look up an icon by freedesktop name using a two-pass strategy.
174///
175/// First tries the `-symbolic` suffix (single-frame static icons), then
176/// falls back to the plain name. This order avoids animation sprite
177/// sheets (e.g. Breeze's `animations/process-working.svg` is a 15-frame
178/// vertical strip) which render incorrectly as static images.
179///
180/// The symbolic-first order also naturally handles Adwaita, which stores
181/// most action icons only as `*-symbolic.svg`.
182///
183/// Each pass searches `theme` and the themes of its `Inherits=` chain, in
184/// the specification's order, never `hicolor` unless `theme` is `hicolor`
185/// (see [`theme_chain`]); the symbolic pass searches the whole chain
186/// before the plain pass starts. `None` when `theme` is not installed.
187fn find_icon(name: &str, theme: &str, size: u16) -> Option<(PathBuf, bool)> {
188    let chain = cached_theme_chain(theme)?;
189    // First try: symbolic variant (e.g., "edit-copy-symbolic")
190    // Symbolic icons are always single-frame, avoiding sprite sheets
191    // in themes like Breeze that put animation strips under plain names.
192    let symbolic = format!("{name}-symbolic");
193    if let Some(path) = lookup_in_chain(&symbolic, size, &chain) {
194        return Some((path, true));
195    }
196    // Second try: plain name (e.g., "edit-copy")
197    // If the name itself already ends with "-symbolic" (caller passed it
198    // explicitly via load_freedesktop_icon_by_name), mark as symbolic.
199    lookup_in_chain(name, size, &chain).map(|path| (path, name.ends_with("-symbolic")))
200}
201
202/// Load a freedesktop icon by name from the given theme.
203///
204/// Looks up the name in the specified theme directory (with `-symbolic`
205/// suffix fallback for Adwaita-style themes), then dispatches on file
206/// extension:
207///
208/// - `.svg` → returns [`IconData::Svg`]. For GTK-convention symbolic
209///   icons (Adwaita, Yaru, elementary), hardcoded foreground placeholders
210///   are replaced with `fg_color` so the SVG renders correctly on both
211///   light and dark themes. Pass `None` to fall back to `currentColor`
212///   (requires connector colorization).
213/// - `.png` → decodes the PNG to RGBA and returns [`IconData::Rgba`]
214///   (raster-only legacy themes like `AdwaitaLegacy`).
215/// - Any other extension → `None`.
216///
217/// Returns `None` if the icon is not found in the theme or if the
218/// decoded file cannot be parsed.
219///
220/// **Performance note:** Each call reads the icon file from disk. Callers
221/// that load the same icon repeatedly should cache the returned `IconData`.
222#[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
233/// Read and decode an icon file, branching on its extension.
234///
235/// Split out from [`load_freedesktop_icon_by_name`] so format handling
236/// is testable without the freedesktop-icons lookup and is easy to
237/// extend (e.g. future XPM support).
238fn 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
262/// Decode a PNG byte slice to an 8-bit RGBA [`IconData`].
263///
264/// Uses `png` crate transformations to normalise palette, grayscale, and
265/// 16-bit inputs to 8-bit. Non-RGBA outputs (RGB, Grayscale, GrayscaleAlpha)
266/// are expanded to RGBA in-place so downstream renderers only ever see
267/// 4-byte-per-pixel data.
268///
269/// Returns `None` if the input is not a valid PNG, decoding fails, or
270/// the output buffer shape does not match `width * height * 4`.
271fn 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            // `expand_to_rgba` passes chunks of exactly `sample_stride` bytes (3 here).
289            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        // Indexed is converted to RGB/RGBA by EXPAND + ALPHA transformations,
304        // so we should never see it here. Treat as unsupported if we do.
305        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
318/// Expand a packed pixel buffer to RGBA using the caller-provided per-pixel writer.
319///
320/// `sample_stride` is the number of bytes per pixel in the input (1 for
321/// Grayscale, 2 for GrayscaleAlpha, 3 for Rgb). Returns `None` if `buf`
322/// length is not a multiple of `sample_stride`.
323fn 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    // sample_stride > 0 verified above; checked_div cannot return None here.
331    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
339/// Convert an optional RGB foreground color to a replacement string
340/// for GTK symbolic icon normalization.
341fn 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
348/// Parse a vertical SVG sprite sheet into individual frame SVGs.
349///
350/// Detection: if the viewBox height > width and is an exact multiple,
351/// the SVG is treated as a sprite sheet with `height/width` frames.
352/// Each frame's SVG is the original with viewBox rewritten to window
353/// into the correct vertical slice.
354///
355/// Returns `None` if the SVG is not a sprite sheet (single-frame,
356/// non-multiple dimensions, or parse error).
357fn parse_sprite_sheet(svg_bytes: &[u8]) -> Option<Vec<Vec<u8>>> {
358    let svg_str = std::str::from_utf8(svg_bytes).ok()?;
359
360    // Find viewBox attribute (handle both double and single quotes)
361    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    // Split on whitespace or commas
375    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; // Single-frame, not a sprite sheet
387    }
388
389    let frame_count = (height / width).round() as usize;
390    if frame_count < 2 {
391        return None;
392    }
393
394    // Verify exact division (within floating-point tolerance)
395    if (height - width * frame_count as f64).abs() > 0.01 {
396        return None;
397    }
398
399    // Build the full original viewBox attribute string for replacement
400    let original_vb_attr = svg_str.get(vb_attr_start..vb_val_end.saturating_add(1))?; // includes closing quote
401
402    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
413/// Load the freedesktop loading spinner from the active icon theme.
414///
415/// Strategy:
416/// 1. Try "process-working" (plain) at size 22 -- may be a sprite sheet -> Frames
417/// 2. If found but single-frame (parse_sprite_sheet returns None) -> Transform::Spin
418/// 3. Try "process-working-symbolic" at size 22 -- single frame -> Transform::Spin
419/// 4. Return None if neither found; no other icon set stands in
420///
421/// Each pass searches the theme and the themes of its `Inherits=` chain,
422/// in the specification's order, never `hicolor` unless the theme is
423/// `hicolor` (see [`theme_chain`]); the plain pass searches the whole
424/// chain before the symbolic pass starts.
425///
426/// A `theme` of `None` is the system's icon theme, and where that cannot be
427/// detected there is no spinner.
428pub(crate) fn load_freedesktop_spinner(theme: Option<&str>) -> Option<AnimatedIcon> {
429    load_freedesktop_spinner_with(theme, crate::system_icon_theme)
430}
431
432/// [`load_freedesktop_spinner`], with `detect` giving the system's icon
433/// theme where `theme` is `None`.
434fn 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    // First pass: plain name (finds sprite sheets in animations/ dirs)
449    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        // Not a sprite sheet -- treat as single frame with spin
461        return Some(AnimatedIcon::transform(
462            IconData::Svg(Cow::Owned(bytes)),
463            TransformAnimation::Spin {
464                duration_ms: spin_dur,
465            },
466        ));
467    }
468
469    // Second pass: symbolic name (always single frame)
470    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
484/// The GTK symbolic icon foreground placeholder colors.
485///
486/// GTK's icon rendering pipeline replaces these at paint time with the
487/// widget's CSS `color` property (the text/foreground color). We replace
488/// them with the caller-provided foreground color so the SVG is
489/// self-contained and renders correctly without connector colorization.
490///
491/// Measured from `/usr/share/icons/Adwaita/symbolic/`:
492/// - `#2e3436`: 483 fill attrs + 8 CSS style fills + 1 stroke (Tango Aluminium 6)
493/// - `#2e3434`: 118 files (68 primary, 50 with fill-opacity)
494/// - `#222222`: 27 occurrences (primary + dimmed)
495/// - `#474747`: 50 emote/legacy icons (monochrome, never mixed with above)
496const GTK_FG_COLORS: &[&str] = &["#2e3436", "#2e3434", "#222222", "#474747"];
497
498/// Recolor a GTK-convention symbolic SVG by replacing foreground placeholders.
499///
500/// GTK symbolic icons use hardcoded dark fill colors (e.g., `#2e3436`)
501/// that GTK replaces at render time with the widget's CSS text color.
502/// This function does the same: it replaces those placeholders with
503/// `replacement`, which should be either a hex color (e.g., `#ffffff`)
504/// or `currentColor` as a fallback.
505///
506/// Handles three placement patterns found in Adwaita:
507/// - XML attributes: `fill="#2e3436"`, `stroke="#2e3436"`
508/// - CSS style attributes: `style="fill:#2e3436;..."`
509///
510/// Only foreground placeholder colors are replaced. Semantic colors
511/// (success green `#33d17a`, warning orange `#ff7800`, error red
512/// `#e01b24`/`#ed333b`) are preserved.
513///
514/// Returns the original bytes unchanged if the SVG already uses
515/// `currentColor` (Breeze-style) or is not valid UTF-8.
516fn 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    // Already uses currentColor (Breeze convention) -- no normalization needed
522    if svg_str.contains("currentColor") {
523        return svg_bytes;
524    }
525
526    // Check if any GTK foreground colors are present
527    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        // XML attributes: fill="..." and stroke="..."
534        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        // CSS style attributes: fill:#2e3436 (8 icons use this form)
543        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        // Notification is mapped to "notification-active" (KDE convention).
575        // Result depends on whether the active theme ships this icon.
576        // This test verifies the loader does not panic and does not fall back to Material.
577        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        // No assertion on Some/None -- theme-dependent
583    }
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    /// With no theme given and no icon theme detected, there is no spinner:
599    /// no theme stands in for the one that could not be detected.
600    #[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    // === Theme directory filter (fixture base dirs) ===
632
633    /// A throwaway tree of icon base dirs, removed on drop:
634    ///
635    /// - `share/icons/`: `child` (`Inherits=parent,hicolor`), `parent`
636    ///   (`Inherits=grand`), `grand`, `hicolor`, `breeze`, `breeze-dark`,
637    ///   `cycle-a` and `cycle-b` (each inheriting the other), `fork`
638    ///   (`Inherits=left,right`), `left` (`Inherits=deep`), `right`,
639    ///   `deep`, and a loose `loose.svg`;
640    /// - `home/.icons/`: `child/` again, without an `index.theme`;
641    /// - `share/pixmaps/`: `pixmap.svg`, outside every base dir.
642    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        /// Whether `path` lies in the own directory of a theme of
693        /// `theme`'s chain.
694        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        /// Put `<name>.svg` in `theme`'s `actions` directory.
703        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        /// What freedesktop-icons 0.4.0 returns for `name` asked of
710        /// `theme`: the file in `theme`, else in a theme it names in
711        /// `Inherits=` (not their parents), else in `hicolor`.
712        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        /// [`first_in_chain`] for `name` over `theme`'s chain, with
727        /// [`Self::freedesktop_icons_lookup`] as the lookup.
728        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    /// A loose file in an icon base directory (`/usr/share/icons/cachyos.svg`
858    /// on CachyOS) belongs to no theme, so a theme lookup must not return it.
859    #[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    /// An icon breeze lacks is not taken from `hicolor`.
870    #[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    /// `default` inherits Adwaita, which inherits AdwaitaLegacy: an icon
902    /// only AdwaitaLegacy has is two parents away, and still found.
903    #[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    /// The fix must not lose an icon the theme has.
955    #[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    // === Sprite sheet parser tests ===
976
977    #[test]
978    fn test_parse_sprite_sheet_two_frames() {
979        // 10x20 viewBox = 2 frames of 10x10
980        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        // 22x330 viewBox = 15 frames (Breeze-like)
1004        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        // Verify first and last frame viewBox values
1012        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        // 22x22 = single frame, not a sprite sheet
1022        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        // 22x33: height is not an exact multiple of width
1031        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        // viewBox with commas instead of spaces
1045        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        // Both frames should preserve the full SVG content
1065        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        // Just verify the function doesn't panic -- result is theme-dependent
1079        let _result = load_freedesktop_spinner(None);
1080    }
1081
1082    // === GTK symbolic icon normalization tests ===
1083
1084    #[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    // === PNG decode path (for raster-only legacy themes like AdwaitaLegacy) ===
1172
1173    /// Encode a minimal in-memory PNG with a known pixel pattern for
1174    /// round-trip tests. Returns `None` on encode error.
1175    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        // Known pixel pattern: 4 distinct RGBA pixels
1194        let pixels: [u8; 16] = [
1195            0xff, 0x00, 0x00, 0xff, // red, opaque
1196            0x00, 0xff, 0x00, 0x80, // green, half-alpha
1197            0x00, 0x00, 0xff, 0xff, // blue, opaque
1198            0x11, 0x22, 0x33, 0x44, // arbitrary
1199        ];
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        // RGB (no alpha) input must come out as RGBA with alpha=0xff.
1220        let pixels: [u8; 6] = [
1221            0xab, 0xcd, 0xef, // pixel 0
1222            0x11, 0x22, 0x33, // pixel 1
1223        ];
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        // AdwaitaLegacy is a PNG-only legacy theme. The loader must decode
1263        // PNG to Rgba, not wrap raw PNG bytes inside IconData::Svg.
1264        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}