srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/crates/wayland/src/icon_theme.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/wayland/src/icon_theme.rs')
-rw-r--r--crates/wayland/src/icon_theme.rs278
1 files changed, 278 insertions, 0 deletions
diff --git a/crates/wayland/src/icon_theme.rs b/crates/wayland/src/icon_theme.rs
new file mode 100644
index 0000000..eaecdad
--- /dev/null
+++ b/crates/wayland/src/icon_theme.rs
@@ -0,0 +1,278 @@
+//! Real desktop-icon artwork loaded from the user's installed freedesktop
+//! icon theme (GTK's own configured theme - WhiteSur on this machine),
+//! rendered from SVG via `resvg`. `decoration::render_desktop_icon`'s
+//! hand-drawn glyphs remain as the fallback for whatever this can't
+//! resolve (no icon theme installed at all, a name no theme in the chain
+//! ships, a corrupt SVG) - reported live as the hand-drawn glyphs reading
+//! as generic placeholder art next to every real desktop's own icons
+//! (GNOME/KDE/macOS/Windows all ship real theme artwork, not procedural
+//! shapes), so this is the real fix, not a redraw of the same shapes.
+//!
+//! Deliberately narrow: this compositor has no use for icon lookup beyond
+//! desktop icons (no taskbar/app-list that needs `.desktop`-file icon
+//! resolution), so this only ever looks up the five fixed names `desktop_
+//! icons.rs`'s own `IconKind` needs, not a general-purpose icon-theme
+//! library. A real (if partial) implementation of the freedesktop icon
+//! theme spec: theme `Inherits=` chains walked recursively, `hicolor`
+//! always searched last, `scalable` preferred over any fixed raster size
+//! since every lookup here wants one specific pixel size rendered from
+//! source, not picked from a handful of pre-baked ones.
+
+use std::collections::HashSet;
+use std::path::{Path, PathBuf};
+
+use crate::desktop_icons::IconKind;
+
+/// Canonical freedesktop icon name for each `IconKind` - the name every
+/// spec-compliant theme (GNOME's Adwaita, KDE's Breeze, macOS-style
+/// WhiteSur, etc.) ships its own artwork under.
+pub(crate) fn icon_name(kind: IconKind) -> &'static str {
+ match kind {
+ IconKind::Home => "user-home",
+ IconKind::Computer => "computer",
+ IconKind::Trash => "user-trash",
+ IconKind::Folder => "folder",
+ IconKind::File => "text-x-generic",
+ }
+}
+
+/// GTK's configured icon theme name - read the same places GTK itself
+/// would, so this follows whatever the user actually has set (WhiteSur
+/// here) rather than a hardcoded choice: `$GTK_THEME`'s icon-theme
+/// sibling doesn't exist as its own env var, so `gtk-3.0/settings.ini`
+/// (present even in a GNOME-less session, unlike `gsettings`, which needs
+/// a working dconf backend) is checked first, `gsettings` second, falling
+/// back to `"hicolor"` - the one theme the spec guarantees exists
+/// alongside any other, so a lookup always has *something* to search
+/// rather than an empty chain.
+fn configured_theme_name() -> String {
+ let home = std::env::var("HOME").unwrap_or_default();
+ let candidates = [format!("{home}/.config/gtk-3.0/settings.ini"), format!("{home}/.config/gtk-4.0/settings.ini")];
+ for path in candidates {
+ if let Ok(contents) = std::fs::read_to_string(&path) {
+ for line in contents.lines() {
+ let line = line.trim();
+ if let Some(value) = line.strip_prefix("gtk-icon-theme-name=") {
+ let value = value.trim();
+ if !value.is_empty() {
+ return value.to_string();
+ }
+ }
+ }
+ }
+ }
+ if let Ok(output) = std::process::Command::new("gsettings").args(["get", "org.gnome.desktop.interface", "icon-theme"]).output() {
+ if output.status.success() {
+ let value = String::from_utf8_lossy(&output.stdout);
+ let value = value.trim().trim_matches('\'');
+ if !value.is_empty() {
+ return value.to_string();
+ }
+ }
+ }
+ "hicolor".to_string()
+}
+
+/// Base directories icon themes live under, in the priority XDG's own
+/// icon theme spec defines: the user's own override directories first,
+/// then every `$XDG_DATA_DIRS` entry's `icons` subdirectory, then the
+/// hardcoded system fallback last.
+fn icon_base_dirs() -> Vec<PathBuf> {
+ let home = std::env::var("HOME").unwrap_or_default();
+ let mut dirs = vec![PathBuf::from(format!("{home}/.local/share/icons")), PathBuf::from(format!("{home}/.icons"))];
+ let data_dirs = std::env::var("XDG_DATA_DIRS").unwrap_or_else(|_| "/usr/local/share:/usr/share".to_string());
+ for dir in data_dirs.split(':') {
+ if !dir.is_empty() {
+ dirs.push(PathBuf::from(dir).join("icons"));
+ }
+ }
+ dirs.push(PathBuf::from("/usr/share/icons"));
+ dirs
+}
+
+/// `<theme>/index.theme`'s own `Inherits=a,b,c` line, parsed into a plain
+/// list - empty if the theme has no index (a raw, index-less icon
+/// directory, or a name that doesn't resolve to anything installed at
+/// all) or no `Inherits` key of its own.
+fn theme_inherits(theme_dir: &Path) -> Vec<String> {
+ let Ok(contents) = std::fs::read_to_string(theme_dir.join("index.theme")) else { return Vec::new() };
+ for line in contents.lines() {
+ if let Some(value) = line.trim().strip_prefix("Inherits=") {
+ return value.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect();
+ }
+ }
+ Vec::new()
+}
+
+/// Every theme name to search, in order: `theme` itself, then its own
+/// `Inherits=` chain walked recursively (a theme installed under more
+/// than one base directory only needs to contribute its name once), with
+/// `hicolor` appended at the end if nothing in the chain already named it
+/// - the spec's own explicit last-resort fallback.
+fn theme_search_chain(theme: &str) -> Vec<String> {
+ let bases = icon_base_dirs();
+ let mut chain = Vec::new();
+ let mut seen = HashSet::new();
+ let mut queue = vec![theme.to_string()];
+ while let Some(name) = queue.pop() {
+ if !seen.insert(name.clone()) {
+ continue;
+ }
+ chain.push(name.clone());
+ for base in &bases {
+ let dir = base.join(&name);
+ if dir.is_dir() {
+ queue.extend(theme_inherits(&dir));
+ break;
+ }
+ }
+ }
+ if !seen.contains("hicolor") {
+ chain.push("hicolor".to_string());
+ }
+ chain
+}
+
+/// Walks every subdirectory of `theme_dir` (unbounded depth is
+/// unnecessary - real theme trees are `<context>/<size-or-scalable>/`,
+/// two levels deep) looking for `<name>.svg` or `<name>.png`, preferring
+/// an exact `scalable` directory match (vector art renders correctly at
+/// any size this compositor asks for) over a fixed-size raster one.
+fn find_in_theme(theme_dir: &Path, name: &str) -> Option<PathBuf> {
+ let mut scalable_hit = None;
+ let mut raster_hit = None;
+ let Ok(contexts) = std::fs::read_dir(theme_dir) else { return None };
+ for context in contexts.flatten() {
+ let context_path = context.path();
+ if !context_path.is_dir() {
+ continue;
+ }
+ let Ok(sizes) = std::fs::read_dir(&context_path) else { continue };
+ for size_dir in sizes.flatten() {
+ let size_path = size_dir.path();
+ if !size_path.is_dir() {
+ continue;
+ }
+ let is_scalable = size_dir.file_name().to_string_lossy().contains("scalable");
+ for ext in ["svg", "png"] {
+ let candidate = size_path.join(format!("{name}.{ext}"));
+ if candidate.is_file() {
+ if is_scalable && ext == "svg" {
+ scalable_hit.get_or_insert(candidate);
+ } else {
+ raster_hit.get_or_insert(candidate);
+ }
+ }
+ }
+ }
+ }
+ scalable_hit.or(raster_hit)
+}
+
+/// Resolves `name` (one of this module's own fixed canonical names) to a
+/// real on-disk icon file, searching the configured theme's full
+/// inheritance chain. `None` means "no installed theme ships this icon at
+/// all" - a legitimate, expected outcome on a minimal system, not an
+/// error; the caller falls back to the hand-drawn glyph.
+pub(crate) fn find_icon(name: &str) -> Option<PathBuf> {
+ let theme = configured_theme_name();
+ let bases = icon_base_dirs();
+ for theme_name in theme_search_chain(&theme) {
+ for base in &bases {
+ let theme_dir = base.join(&theme_name);
+ if theme_dir.is_dir() {
+ if let Some(found) = find_in_theme(&theme_dir, name) {
+ return Some(found);
+ }
+ }
+ }
+ }
+ // `/usr/share/pixmaps` is flat (no theme/context/size structure at
+ // all) - the spec's own final fallback location, checked last.
+ let flat = PathBuf::from(format!("/usr/share/pixmaps/{name}.png"));
+ flat.is_file().then_some(flat)
+}
+
+/// Rasterizes the SVG at `path` into a `width`x`height` BGRA8 buffer --
+/// the same byte order every other rasterizer in `decoration.rs` produces
+/// (see that module's own doc comment) - so the caller can hand the
+/// result straight to `MemoryRenderBuffer::from_slice` unchanged. `None`
+/// on any read/parse/render failure (corrupt file, a `resvg`/`usvg`
+/// feature gap): the caller degrades to the hand-drawn glyph rather than
+/// propagating an error, the same "a missing/bad theme icon shouldn't be
+/// worse than not trying" stance `find_icon` itself takes.
+pub(crate) fn rasterize_svg(path: &Path, width: u32, height: u32) -> Option<Vec<u8>> {
+ let data = std::fs::read(path).ok()?;
+ let opt = usvg::Options::default();
+ let tree = usvg::Tree::from_data(&data, &opt).ok()?;
+ let mut pixmap = tiny_skia::Pixmap::new(width, height)?;
+ let size = tree.size();
+ let scale = (width as f32 / size.width()).min(height as f32 / size.height());
+ let offset_x = (width as f32 - size.width() * scale) / 2.0;
+ let offset_y = (height as f32 - size.height() * scale) / 2.0;
+ let transform = tiny_skia::Transform::from_translate(offset_x, offset_y).pre_scale(scale, scale);
+ resvg::render(&tree, transform, &mut pixmap.as_mut());
+ let rgba = pixmap.data();
+ let mut bgra = vec![0u8; rgba.len()];
+ for (dst, src) in bgra.chunks_exact_mut(4).zip(rgba.chunks_exact(4)) {
+ dst[0] = src[2];
+ dst[1] = src[1];
+ dst[2] = src[0];
+ dst[3] = src[3];
+ }
+ Some(bgra)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn every_icon_kind_maps_to_a_real_freedesktop_name() {
+ // Locks in the spec names themselves - a typo here would silently
+ // never match any real theme, degrading permanently to the
+ // hand-drawn fallback with no obvious error anywhere.
+ assert_eq!(icon_name(IconKind::Home), "user-home");
+ assert_eq!(icon_name(IconKind::Computer), "computer");
+ assert_eq!(icon_name(IconKind::Trash), "user-trash");
+ assert_eq!(icon_name(IconKind::Folder), "folder");
+ assert_eq!(icon_name(IconKind::File), "text-x-generic");
+ }
+
+ #[test]
+ fn hicolor_is_always_in_the_search_chain_even_for_an_unrelated_theme() {
+ let chain = theme_search_chain("a-theme-name-that-does-not-exist-anywhere");
+ assert!(chain.contains(&"hicolor".to_string()), "the spec's own last-resort fallback must always be searched");
+ }
+
+ #[test]
+ #[ignore = "visual spot-check only, dumps PNGs to /tmp - not run in CI"]
+ fn dump_every_icon_for_visual_inspection() {
+ for kind in [IconKind::Home, IconKind::Computer, IconKind::Trash, IconKind::Folder, IconKind::File] {
+ let name = icon_name(kind);
+ let Some(path) = find_icon(name) else {
+ eprintln!("{name}: not found in any theme");
+ continue;
+ };
+ eprintln!("{name}: {path:?}");
+ let mut pixmap = tiny_skia::Pixmap::new(40, 36).unwrap();
+ let opt = usvg::Options::default();
+ let data = std::fs::read(&path).unwrap();
+ let tree = usvg::Tree::from_data(&data, &opt).unwrap();
+ let size = tree.size();
+ let scale = (40.0 / size.width()).min(36.0 / size.height());
+ let transform = tiny_skia::Transform::from_scale(scale, scale);
+ resvg::render(&tree, transform, &mut pixmap.as_mut());
+ pixmap.save_png(format!("/tmp/icon-{name}.png")).unwrap();
+ }
+ }
+
+ #[test]
+ fn the_chain_has_no_duplicate_even_if_a_theme_inherits_hicolor_explicitly() {
+ // `WhiteSur-dark`'s own real `index.theme` (checked live on this
+ // machine) already lists `Inherits=hicolor,breeze` - this locks
+ // in that appending hicolor again afterward doesn't happen.
+ let chain = theme_search_chain("hicolor");
+ assert_eq!(chain.iter().filter(|n| n.as_str() == "hicolor").count(), 1);
+ }
+}