git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

src/icon.rs (11.2K)

  1 //! XDG icon-theme lookup: an `Icon=` name from a `.desktop` entry (or an SNI
  2 //! tray item) resolved to a file on disk.
  3 //!
  4 //! **This is not [`crate::upload_icon`].** That one loads a *bundled* cce-icons
  5 //! glyph by its own name (`upload_icon("folder", 32)` reads
  6 //! `$CCE_ICONS_DIR/folder.svg`) and is how a widget draws a chevron or a copy
  7 //! button. This module resolves a *theme* name — anything any installed app may
  8 //! have shipped — by searching the icon-theme directories per the freedesktop
  9 //! icon theme spec. cce's own app icons land in
 10 //! `$XDG_DATA_HOME/icons/hicolor/scalable/apps/` (installed by `ccebuild` from
 11 //! `cce-icons/hicolor/`), so for a cce app the two happen to resolve to the same
 12 //! artwork by different routes.
 13 //!
 14 //! The search is a deliberate simplification of the spec: it walks a fixed
 15 //! preference order of themes, sizes and extensions rather than parsing every
 16 //! `index.theme`. That is enough for the two callers (the launcher's app list
 17 //! and the status bar's tray) and avoids reading a ~40KB index on every lookup.
 18 
 19 use std::path::{Path, PathBuf};
 20 
 21 /// Themes searched, in order. `hicolor` is the spec's fallback — every
 22 /// implementation searches it whatever the user's theme is, and it is where cce
 23 /// installs its own app icons. `Adwaita` follows because it is present on
 24 /// essentially every desktop and carries the generic names (`system-file-manager`,
 25 /// `accessories-text-editor`, …) that third-party entries lean on. `$CCE_ICON_THEME`
 26 /// prepends a preferred theme; there is no icon-theme setting in the DE, which is
 27 /// exactly why the fallbacks have to be good.
 28 const THEMES: [&str; 2] = ["hicolor", "Adwaita"];
 29 
 30 /// Size directories, best first. `scalable` leads because callers rasterize it
 31 /// to whatever size they need. The bitmap sizes that follow are ordered for the
 32 /// 16–48px range both callers actually draw at — a list row and a status-bar
 33 /// tray — so a modest downscale beats both upscaling a 16px icon and decoding a
 34 /// 512px one to draw it at 18.
 35 const SIZES: [&str; 11] = [
 36     "scalable", "48x48", "64x64", "32x32", "96x96", "128x128", "24x24", "22x22", "16x16",
 37     "256x256", "512x512",
 38 ];
 39 
 40 /// Extensions, best first. SVG scales; PNG is what most apps actually ship.
 41 const EXTS: [&str; 3] = ["svg", "png", "xpm"];
 42 
 43 /// Resolve an icon name in the `apps` context — the one a `.desktop` `Icon=` key
 44 /// lives in. See [`lookup_in`] for the general form.
 45 pub fn lookup(name: &str) -> Option<PathBuf> {
 46     lookup_in(name, &["apps"])
 47 }
 48 
 49 /// Resolve an icon name, searching `contexts` (`"apps"`, `"status"`, …) in the
 50 /// order given within each theme/size directory.
 51 ///
 52 /// Per the spec an `Icon=` value may also be an **absolute path**, in which case
 53 /// it is used directly; and it should *not* carry an extension, though entries
 54 /// in the wild routinely include one, so a known extension is stripped before
 55 /// searching. Returns `None` when nothing matches — every caller has a text
 56 /// fallback, so a missing icon must not be fatal.
 57 pub fn lookup_in(name: &str, contexts: &[&str]) -> Option<PathBuf> {
 58     if name.is_empty() {
 59         return None;
 60     }
 61 
 62     // An absolute path is used as given. This is also the shape of the stale
 63     // entries this system replaced (Icon=/home/…/star.png), so it resolves to
 64     // None the moment the file goes away rather than silently searching for a
 65     // theme icon named after a whole path.
 66     if name.starts_with('/') {
 67         let path = Path::new(name);
 68         return path.is_file().then(|| path.to_path_buf());
 69     }
 70 
 71     let stem = EXTS
 72         .iter()
 73         .find_map(|e| name.strip_suffix(&format!(".{e}")))
 74         .unwrap_or(name);
 75 
 76     let themes: Vec<String> = std::env::var("CCE_ICON_THEME")
 77         .ok()
 78         .filter(|t| !t.is_empty())
 79         .into_iter()
 80         .chain(THEMES.iter().map(|t| t.to_string()))
 81         .collect();
 82 
 83     for base in base_dirs() {
 84         for theme in &themes {
 85             for size in SIZES {
 86                 for context in contexts {
 87                     for ext in EXTS {
 88                         let path = base
 89                             .join(theme)
 90                             .join(size)
 91                             .join(context)
 92                             .join(format!("{stem}.{ext}"));
 93                         if path.is_file() {
 94                             return Some(path);
 95                         }
 96                     }
 97                 }
 98             }
 99         }
100     }
101 
102     // /usr/share/pixmaps is themeless and flat — the pre-icon-theme location,
103     // still used by a long tail of packages.
104     for ext in EXTS {
105         let path = PathBuf::from("/usr/share/pixmaps").join(format!("{stem}.{ext}"));
106         if path.is_file() {
107             return Some(path);
108         }
109     }
110 
111     None
112 }
113 
114 /// Resolve a theme icon name and upload it as a renderer texture, returning
115 /// `(image id, pixel w, pixel h)` for [`crate::scene::paint::PaintCtx::image`].
116 /// SVGs are rasterized at `px` on the longer side; bitmaps are uploaded at their
117 /// stored size and scaled by the GPU when drawn, so `px` is a hint, not the
118 /// result — fit the returned dimensions into the destination rect to keep the
119 /// aspect ratio.
120 ///
121 /// **The upload is deliberately not cached, only the decode is.** An image id is
122 /// meaningless to any renderer other than the one that drained the upload queue
123 /// for it, and `cce-cloud`'s daemon builds and tears down a whole `VkRenderer`
124 /// per popup — a cached id from the previous popup would name GPU resources that
125 /// no longer exist. Caching the decoded pixels instead means a second popup
126 /// still skips the disk read and the rasterizer, which is where the time goes.
127 pub fn upload_themed(name: &str, px: u32) -> Option<(u32, u32, u32)> {
128     let (pixels, w, h) = decode(name, px)?;
129     Some((crate::vk::upload_rgba(pixels, w, h), w, h))
130 }
131 
132 /// [`upload_themed`]'s cached half: name → straight RGBA8 pixels + dimensions.
133 fn decode(name: &str, px: u32) -> Option<(Vec<u8>, u32, u32)> {
134     use std::collections::HashMap;
135     use std::sync::Mutex;
136     static CACHE: Mutex<Option<HashMap<(String, u32), Option<(Vec<u8>, u32, u32)>>>> =
137         Mutex::new(None);
138 
139     let key = (name.to_string(), px);
140     let mut guard = CACHE.lock().unwrap();
141     let cache = guard.get_or_insert_with(HashMap::new);
142     if let Some(hit) = cache.get(&key) {
143         return hit.clone();
144     }
145 
146     let loaded = (|| {
147         let path = lookup(name)?;
148         let data = std::fs::read(&path).ok()?;
149         match path.extension().and_then(|e| e.to_str()) {
150             Some("svg") => crate::rasterize_svg(&data, px),
151             Some("png") => decode_png(&data),
152             // XPM has no decoder here; it is in the search list because finding
153             // one and skipping it still beats falling through to a worse match.
154             _ => None,
155         }
156     })();
157     cache.insert(key, loaded.clone());
158     loaded
159 }
160 
161 /// Decode a PNG to straight (un-premultiplied) RGBA8, the layout
162 /// [`crate::vk::upload_rgba`] takes. `EXPAND` folds palette, sub-byte grayscale
163 /// and `tRNS` into plain channels, which leaves only the four color types below;
164 /// 16-bit samples are truncated to their high byte.
165 fn decode_png(data: &[u8]) -> Option<(Vec<u8>, u32, u32)> {
166     let mut decoder = png::Decoder::new(data);
167     decoder.set_transformations(png::Transformations::EXPAND);
168     let mut reader = decoder.read_info().ok()?;
169     let mut buf = vec![0; reader.output_buffer_size()];
170     let info = reader.next_frame(&mut buf).ok()?;
171 
172     let bytes = &buf[..info.buffer_size()];
173     let step = if info.bit_depth == png::BitDepth::Sixteen { 2 } else { 1 };
174     let samples: Vec<u8> = bytes.iter().step_by(step).copied().collect();
175 
176     let channels = match info.color_type {
177         png::ColorType::Rgba => 4,
178         png::ColorType::Rgb => 3,
179         png::ColorType::GrayscaleAlpha => 2,
180         png::ColorType::Grayscale => 1,
181         // EXPAND has already turned an indexed image into one of the above.
182         png::ColorType::Indexed => return None,
183     };
184 
185     let mut rgba = Vec::with_capacity((info.width * info.height * 4) as usize);
186     for px in samples.chunks_exact(channels) {
187         let (r, g, b, a) = match channels {
188             4 => (px[0], px[1], px[2], px[3]),
189             3 => (px[0], px[1], px[2], 255),
190             2 => (px[0], px[0], px[0], px[1]),
191             _ => (px[0], px[0], px[0], 255),
192         };
193         rgba.extend_from_slice(&[r, g, b, a]);
194     }
195     if rgba.len() != (info.width * info.height * 4) as usize {
196         return None;
197     }
198     Some((rgba, info.width, info.height))
199 }
200 
201 /// Icon base directories in spec precedence: `$XDG_DATA_HOME/icons`, then the
202 /// legacy `~/.icons`, then `icons` under each `$XDG_DATA_DIRS` entry.
203 fn base_dirs() -> Vec<PathBuf> {
204     let mut dirs = Vec::new();
205     let home = std::env::var_os("HOME").map(PathBuf::from);
206 
207     match std::env::var_os("XDG_DATA_HOME").filter(|v| !v.is_empty()) {
208         Some(v) => dirs.push(PathBuf::from(v).join("icons")),
209         None => {
210             if let Some(h) = &home {
211                 dirs.push(h.join(".local/share/icons"));
212             }
213         }
214     }
215     if let Some(h) = &home {
216         dirs.push(h.join(".icons"));
217     }
218 
219     let data_dirs = std::env::var("XDG_DATA_DIRS")
220         .ok()
221         .filter(|v| !v.is_empty())
222         .unwrap_or_else(|| "/usr/local/share:/usr/share".to_string());
223     for dir in std::env::split_paths(&data_dirs) {
224         if !dir.as_os_str().is_empty() {
225             dirs.push(dir.join("icons"));
226         }
227     }
228     dirs
229 }
230 
231 #[cfg(test)]
232 mod tests {
233     use super::*;
234 
235     #[test]
236     fn empty_name_resolves_to_nothing() {
237         assert!(lookup("").is_none());
238     }
239 
240     #[test]
241     fn absolute_path_is_used_as_given() {
242         let dir = std::env::temp_dir().join("cce-ui-icon-abs");
243         std::fs::create_dir_all(&dir).unwrap();
244         let file = dir.join("thing.png");
245         std::fs::write(&file, b"x").unwrap();
246         assert_eq!(lookup(file.to_str().unwrap()), Some(file.clone()));
247 
248         // A dead absolute path must not fall through to a theme search.
249         std::fs::remove_file(&file).unwrap();
250         assert!(lookup(file.to_str().unwrap()).is_none());
251     }
252 
253     #[test]
254     fn finds_a_scalable_apps_icon_and_strips_a_given_extension() {
255         let root = std::env::temp_dir().join("cce-ui-icon-theme");
256         let apps = root.join("icons/hicolor/scalable/apps");
257         std::fs::create_dir_all(&apps).unwrap();
258         let icon = apps.join("cce-widget.svg");
259         std::fs::write(&icon, b"<svg/>").unwrap();
260 
261         // Scoped env mutation: these tests share a process, so keep it to one test.
262         let prev = std::env::var_os("XDG_DATA_HOME");
263         unsafe { std::env::set_var("XDG_DATA_HOME", &root) };
264 
265         assert_eq!(lookup("cce-widget"), Some(icon.clone()));
266         // Entries in the wild include the extension even though the spec says not to.
267         assert_eq!(lookup("cce-widget.svg"), Some(icon));
268         // The apps context must not match a name that is not there.
269         assert!(lookup("cce-absent").is_none());
270         // A different context must not see it.
271         assert!(lookup_in("cce-widget", &["status"]).is_none());
272 
273         match prev {
274             Some(v) => unsafe { std::env::set_var("XDG_DATA_HOME", v) },
275             None => unsafe { std::env::remove_var("XDG_DATA_HOME") },
276         }
277         std::fs::remove_dir_all(&root).ok();
278     }
279 }