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 }