gardesk/garnotify / 5131005

Browse files

feat(ui): add icon loading with freedesktop theme support

Load icons from theme directories, file paths, and D-Bus image-data hints.
Uses resvg for SVG rendering, image crate for raster formats.
Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
5131005167f4e5914351cfa7ebbc3c8322d8929b
Parents
1acaac2
Tree
a60b57f

4 changed files

StatusFile+-
M Cargo.toml 5 0
M garnotify/Cargo.toml 5 0
M garnotify/src/notification/mod.rs 1 1
A garnotify/src/ui/icons.rs 459 0
Cargo.tomlmodified
@@ -48,3 +48,8 @@ thiserror = "1.0"
4848
 # Utilities
4949
 dirs = "5.0"
5050
 shellexpand = "3.0"
51
+
52
+# Icon loading
53
+image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
54
+resvg = "0.44"
55
+tiny-skia = "0.11"
garnotify/Cargo.tomlmodified
@@ -45,3 +45,8 @@ thiserror = { workspace = true }
4545
 # Utilities
4646
 dirs = { workspace = true }
4747
 shellexpand = { workspace = true }
48
+
49
+# Icon loading
50
+image = { workspace = true }
51
+resvg = { workspace = true }
52
+tiny-skia = { workspace = true }
garnotify/src/notification/mod.rsmodified
@@ -6,4 +6,4 @@ mod types;
66
 
77
 pub use history::History;
88
 pub use store::{new_shared_store, NotificationEvent, SharedNotificationStore};
9
-pub use types::{Action, CloseReason, Hints, Notification, Urgency, UrgencyTimeouts};
9
+pub use types::{Action, CloseReason, Hints, ImageData, Notification, Urgency, UrgencyTimeouts};
garnotify/src/ui/icons.rsadded
@@ -0,0 +1,459 @@
1
+//! Icon loading utilities for notification popups
2
+//!
3
+//! Handles loading icons from:
4
+//! - Freedesktop icon themes (by name)
5
+//! - File paths (PNG, JPEG, SVG)
6
+//! - Raw image data from D-Bus hints
7
+//! - Application icons via desktop entry
8
+
9
+use anyhow::{Context, Result};
10
+use image::GenericImageView;
11
+use std::path::{Path, PathBuf};
12
+use tracing::{debug, warn};
13
+
14
+use crate::notification::{ImageData, Notification};
15
+
16
+/// Loaded icon ready for Cairo rendering
17
+#[derive(Debug, Clone)]
18
+pub struct LoadedIcon {
19
+    /// Width in pixels
20
+    pub width: i32,
21
+    /// Height in pixels
22
+    pub height: i32,
23
+    /// BGRA pixel data (premultiplied alpha) for Cairo
24
+    pub data: Vec<u8>,
25
+}
26
+
27
+impl LoadedIcon {
28
+    /// Create a Cairo ImageSurface from this icon
29
+    pub fn to_cairo_surface(&self) -> Result<cairo::ImageSurface> {
30
+        cairo::ImageSurface::create_for_data(
31
+            self.data.clone(),
32
+            cairo::Format::ARgb32,
33
+            self.width,
34
+            self.height,
35
+            self.width * 4, // stride
36
+        )
37
+        .map_err(|e| anyhow::anyhow!("Failed to create Cairo surface: {}", e))
38
+    }
39
+}
40
+
41
+/// Load the best available icon for a notification
42
+///
43
+/// Priority order:
44
+/// 1. `image-data` hint (raw pixels from D-Bus)
45
+/// 2. `image-path` hint (file path)
46
+/// 3. `app_icon` parameter (theme name or file path)
47
+/// 4. Desktop entry icon lookup
48
+/// 5. None (no icon available)
49
+pub fn load_notification_icon(notif: &Notification, target_size: u32) -> Option<LoadedIcon> {
50
+    // 1. Try image-data hint (raw pixels)
51
+    if let Some(ref img_data) = notif.hints.image_data {
52
+        match load_from_image_data(img_data, target_size) {
53
+            Ok(icon) => {
54
+                debug!("Loaded icon from image-data hint for notification {}", notif.id);
55
+                return Some(icon);
56
+            }
57
+            Err(e) => {
58
+                warn!("Failed to load image-data: {}", e);
59
+            }
60
+        }
61
+    }
62
+
63
+    // 2. Try image-path hint
64
+    if let Some(ref path) = notif.hints.image_path {
65
+        let path = Path::new(path);
66
+        if path.exists() {
67
+            match load_from_file(path, target_size) {
68
+                Ok(icon) => {
69
+                    debug!("Loaded icon from image-path: {}", path.display());
70
+                    return Some(icon);
71
+                }
72
+                Err(e) => {
73
+                    warn!("Failed to load image-path {}: {}", path.display(), e);
74
+                }
75
+            }
76
+        }
77
+    }
78
+
79
+    // 3. Try app_icon (can be theme name or file path)
80
+    if !notif.app_icon.is_empty() {
81
+        let app_icon = &notif.app_icon;
82
+
83
+        // Check if it's a file path
84
+        if app_icon.starts_with('/') || app_icon.starts_with("file://") {
85
+            let path = if let Some(p) = app_icon.strip_prefix("file://") {
86
+                PathBuf::from(p)
87
+            } else {
88
+                PathBuf::from(app_icon)
89
+            };
90
+
91
+            if path.exists() {
92
+                match load_from_file(&path, target_size) {
93
+                    Ok(icon) => {
94
+                        debug!("Loaded icon from app_icon path: {}", path.display());
95
+                        return Some(icon);
96
+                    }
97
+                    Err(e) => {
98
+                        warn!("Failed to load app_icon path {}: {}", path.display(), e);
99
+                    }
100
+                }
101
+            }
102
+        }
103
+
104
+        // Try as theme icon name
105
+        if let Some(path) = find_theme_icon(app_icon, target_size) {
106
+            match load_from_file(&path, target_size) {
107
+                Ok(icon) => {
108
+                    debug!("Loaded theme icon '{}' from {}", app_icon, path.display());
109
+                    return Some(icon);
110
+                }
111
+                Err(e) => {
112
+                    warn!("Failed to load theme icon {}: {}", path.display(), e);
113
+                }
114
+            }
115
+        }
116
+    }
117
+
118
+    // 4. Try desktop entry lookup
119
+    if let Some(ref desktop_entry) = notif.hints.desktop_entry {
120
+        if let Some(icon) = load_from_desktop_entry(desktop_entry, target_size) {
121
+            debug!("Loaded icon from desktop entry: {}", desktop_entry);
122
+            return Some(icon);
123
+        }
124
+    }
125
+
126
+    // 5. Try app_name as fallback theme lookup
127
+    if !notif.app_name.is_empty() {
128
+        let app_name_lower = notif.app_name.to_lowercase();
129
+        if let Some(path) = find_theme_icon(&app_name_lower, target_size) {
130
+            match load_from_file(&path, target_size) {
131
+                Ok(icon) => {
132
+                    debug!("Loaded icon from app_name '{}': {}", notif.app_name, path.display());
133
+                    return Some(icon);
134
+                }
135
+                Err(e) => {
136
+                    warn!("Failed to load app_name icon {}: {}", path.display(), e);
137
+                }
138
+            }
139
+        }
140
+    }
141
+
142
+    debug!("No icon found for notification {}", notif.id);
143
+    None
144
+}
145
+
146
+/// Load icon from D-Bus image-data hint
147
+fn load_from_image_data(img: &ImageData, target_size: u32) -> Result<LoadedIcon> {
148
+    // The image-data format from D-Bus is:
149
+    // (width, height, rowstride, has_alpha, bits_per_sample, channels, data)
150
+    // Data is typically RGBA or RGB with 8 bits per sample
151
+
152
+    if img.width <= 0 || img.height <= 0 {
153
+        anyhow::bail!("Invalid image dimensions: {}x{}", img.width, img.height);
154
+    }
155
+
156
+    let has_alpha = img.has_alpha;
157
+    let channels = img.channels as usize;
158
+    let expected_channels = if has_alpha { 4 } else { 3 };
159
+
160
+    if channels != expected_channels {
161
+        warn!("Unexpected channel count: {} (expected {})", channels, expected_channels);
162
+    }
163
+
164
+    // Calculate expected data size
165
+    let expected_size = (img.rowstride as usize) * (img.height as usize);
166
+    if img.data.len() < expected_size {
167
+        anyhow::bail!(
168
+            "Image data too short: {} bytes, expected at least {}",
169
+            img.data.len(),
170
+            expected_size
171
+        );
172
+    }
173
+
174
+    // Convert to BGRA with premultiplied alpha
175
+    let mut rgba_data = Vec::with_capacity((img.width * img.height * 4) as usize);
176
+    let rowstride = img.rowstride as usize;
177
+
178
+    for y in 0..img.height as usize {
179
+        let row_start = y * rowstride;
180
+        for x in 0..img.width as usize {
181
+            let pixel_start = row_start + x * channels;
182
+
183
+            let (r, g, b, a) = if has_alpha {
184
+                (
185
+                    img.data[pixel_start],
186
+                    img.data[pixel_start + 1],
187
+                    img.data[pixel_start + 2],
188
+                    img.data[pixel_start + 3],
189
+                )
190
+            } else {
191
+                (
192
+                    img.data[pixel_start],
193
+                    img.data[pixel_start + 1],
194
+                    img.data[pixel_start + 2],
195
+                    255,
196
+                )
197
+            };
198
+
199
+            // Premultiply alpha and convert to BGRA for Cairo
200
+            let af = a as f32 / 255.0;
201
+            let r = (r as f32 * af).round() as u8;
202
+            let g = (g as f32 * af).round() as u8;
203
+            let b = (b as f32 * af).round() as u8;
204
+            rgba_data.extend_from_slice(&[b, g, r, a]);
205
+        }
206
+    }
207
+
208
+    // Scale if needed
209
+    if img.width as u32 != target_size || img.height as u32 != target_size {
210
+        scale_bgra_image(&rgba_data, img.width as u32, img.height as u32, target_size)
211
+    } else {
212
+        Ok(LoadedIcon {
213
+            width: img.width,
214
+            height: img.height,
215
+            data: rgba_data,
216
+        })
217
+    }
218
+}
219
+
220
+/// Load icon from file (PNG, JPEG, or SVG)
221
+fn load_from_file(path: &Path, target_size: u32) -> Result<LoadedIcon> {
222
+    let ext = path
223
+        .extension()
224
+        .and_then(|e| e.to_str())
225
+        .unwrap_or("");
226
+
227
+    match ext.to_lowercase().as_str() {
228
+        "svg" => load_svg(path, target_size),
229
+        "png" | "jpg" | "jpeg" => load_raster(path, target_size),
230
+        _ => anyhow::bail!("Unsupported icon format: {}", ext),
231
+    }
232
+}
233
+
234
+/// Load a raster image (PNG, JPEG) and scale to target size
235
+fn load_raster(path: &Path, target_size: u32) -> Result<LoadedIcon> {
236
+    let img = image::open(path)
237
+        .with_context(|| format!("Failed to open image: {}", path.display()))?;
238
+
239
+    // Calculate scaling to fit in target_size while preserving aspect ratio
240
+    let (orig_w, orig_h) = img.dimensions();
241
+    let scale = target_size as f32 / orig_w.max(orig_h) as f32;
242
+    let new_w = ((orig_w as f32 * scale).round() as u32).max(1);
243
+    let new_h = ((orig_h as f32 * scale).round() as u32).max(1);
244
+
245
+    let resized = img.resize_exact(new_w, new_h, image::imageops::FilterType::Lanczos3);
246
+    let rgba = resized.into_rgba8();
247
+
248
+    // Convert RGBA to BGRA (premultiplied alpha) for Cairo
249
+    let mut data: Vec<u8> = Vec::with_capacity((new_w * new_h * 4) as usize);
250
+    for pixel in rgba.pixels() {
251
+        let [r, g, b, a] = pixel.0;
252
+        // Premultiply alpha for Cairo
253
+        let af = a as f32 / 255.0;
254
+        let r = (r as f32 * af).round() as u8;
255
+        let g = (g as f32 * af).round() as u8;
256
+        let b = (b as f32 * af).round() as u8;
257
+        data.extend_from_slice(&[b, g, r, a]);
258
+    }
259
+
260
+    debug!("Loaded raster icon: {}x{} from {}", new_w, new_h, path.display());
261
+    Ok(LoadedIcon {
262
+        width: new_w as i32,
263
+        height: new_h as i32,
264
+        data,
265
+    })
266
+}
267
+
268
+/// Load an SVG and render at target size
269
+fn load_svg(path: &Path, target_size: u32) -> Result<LoadedIcon> {
270
+    let svg_data = std::fs::read(path)
271
+        .with_context(|| format!("Failed to read SVG: {}", path.display()))?;
272
+
273
+    let options = resvg::usvg::Options::default();
274
+    let tree = resvg::usvg::Tree::from_data(&svg_data, &options)
275
+        .with_context(|| format!("Failed to parse SVG: {}", path.display()))?;
276
+
277
+    let svg_size = tree.size();
278
+    let scale = target_size as f32 / svg_size.width().max(svg_size.height());
279
+    let new_w = ((svg_size.width() * scale).round() as u32).max(1);
280
+    let new_h = ((svg_size.height() * scale).round() as u32).max(1);
281
+
282
+    let mut pixmap = tiny_skia::Pixmap::new(new_w, new_h)
283
+        .ok_or_else(|| anyhow::anyhow!("Failed to create pixmap for SVG"))?;
284
+
285
+    // Clear to transparent
286
+    pixmap.fill(tiny_skia::Color::TRANSPARENT);
287
+
288
+    let transform = tiny_skia::Transform::from_scale(scale, scale);
289
+    resvg::render(&tree, transform, &mut pixmap.as_mut());
290
+
291
+    // tiny-skia uses RGBA premultiplied, convert to BGRA for Cairo
292
+    let mut data = pixmap.take();
293
+    for chunk in data.chunks_exact_mut(4) {
294
+        chunk.swap(0, 2); // R <-> B
295
+    }
296
+
297
+    debug!("Loaded SVG icon: {}x{} from {}", new_w, new_h, path.display());
298
+    Ok(LoadedIcon {
299
+        width: new_w as i32,
300
+        height: new_h as i32,
301
+        data,
302
+    })
303
+}
304
+
305
+/// Find icon in freedesktop icon themes
306
+fn find_theme_icon(name: &str, size: u32) -> Option<PathBuf> {
307
+    // Common icon theme search paths
308
+    let search_dirs = [
309
+        dirs::data_dir().map(|p| p.join("icons")),
310
+        dirs::home_dir().map(|p| p.join(".local/share/icons")),
311
+        Some(PathBuf::from("/usr/share/icons")),
312
+        Some(PathBuf::from("/usr/share/pixmaps")),
313
+    ];
314
+
315
+    // Common theme names (in priority order)
316
+    let themes = ["Adwaita", "breeze", "Papirus", "hicolor"];
317
+
318
+    // Sizes to try (closest to requested first, then fallbacks)
319
+    let sizes_to_try = [size, 48, 32, 24, 22, 16];
320
+
321
+    // Categories to search
322
+    let categories = ["apps", "status", "devices", "actions", "places", "mimetypes", "emblems"];
323
+
324
+    // SVG directories to search (both scalable and symbolic)
325
+    let svg_dirs = ["scalable", "symbolic"];
326
+
327
+    for dir in search_dirs.iter().flatten() {
328
+        for theme in &themes {
329
+            // Try SVGs first (best quality) in both scalable and symbolic directories
330
+            for svg_dir in &svg_dirs {
331
+                for category in &categories {
332
+                    // Try exact name
333
+                    let svg_path = dir
334
+                        .join(theme)
335
+                        .join(svg_dir)
336
+                        .join(category)
337
+                        .join(format!("{}.svg", name));
338
+                    if svg_path.exists() {
339
+                        debug!("Found SVG icon: {}", svg_path.display());
340
+                        return Some(svg_path);
341
+                    }
342
+
343
+                    // Try symbolic variant
344
+                    let symbolic_path = dir
345
+                        .join(theme)
346
+                        .join(svg_dir)
347
+                        .join(category)
348
+                        .join(format!("{}-symbolic.svg", name));
349
+                    if symbolic_path.exists() {
350
+                        debug!("Found symbolic SVG icon: {}", symbolic_path.display());
351
+                        return Some(symbolic_path);
352
+                    }
353
+                }
354
+            }
355
+
356
+            // Try PNG at various sizes
357
+            for &sz in &sizes_to_try {
358
+                for category in &categories {
359
+                    let png_path = dir
360
+                        .join(theme)
361
+                        .join(format!("{}x{}", sz, sz))
362
+                        .join(category)
363
+                        .join(format!("{}.png", name));
364
+                    if png_path.exists() {
365
+                        debug!("Found PNG icon: {}", png_path.display());
366
+                        return Some(png_path);
367
+                    }
368
+                }
369
+            }
370
+        }
371
+    }
372
+
373
+    // Try pixmaps directory directly
374
+    let pixmap_paths = [
375
+        PathBuf::from(format!("/usr/share/pixmaps/{}.svg", name)),
376
+        PathBuf::from(format!("/usr/share/pixmaps/{}.png", name)),
377
+        PathBuf::from(format!("/usr/share/pixmaps/{}.xpm", name)),
378
+    ];
379
+
380
+    for path in &pixmap_paths {
381
+        if path.exists() {
382
+            debug!("Found pixmap icon: {}", path.display());
383
+            return Some(path.clone());
384
+        }
385
+    }
386
+
387
+    debug!("Icon not found in themes: {}", name);
388
+    None
389
+}
390
+
391
+/// Try to load icon from a desktop entry
392
+fn load_from_desktop_entry(desktop_entry: &str, target_size: u32) -> Option<LoadedIcon> {
393
+    // Desktop entry paths
394
+    let search_dirs = [
395
+        dirs::data_dir().map(|p| p.join("applications")),
396
+        dirs::home_dir().map(|p| p.join(".local/share/applications")),
397
+        Some(PathBuf::from("/usr/share/applications")),
398
+        Some(PathBuf::from("/usr/local/share/applications")),
399
+    ];
400
+
401
+    for dir in search_dirs.iter().flatten() {
402
+        let desktop_file = dir.join(format!("{}.desktop", desktop_entry));
403
+        if desktop_file.exists() {
404
+            if let Ok(contents) = std::fs::read_to_string(&desktop_file) {
405
+                // Parse Icon= line
406
+                for line in contents.lines() {
407
+                    if let Some(icon_name) = line.strip_prefix("Icon=") {
408
+                        let icon_name = icon_name.trim();
409
+                        if !icon_name.is_empty() {
410
+                            // Check if it's a path
411
+                            if icon_name.starts_with('/') {
412
+                                let path = Path::new(icon_name);
413
+                                if path.exists() {
414
+                                    if let Ok(icon) = load_from_file(path, target_size) {
415
+                                        return Some(icon);
416
+                                    }
417
+                                }
418
+                            } else {
419
+                                // It's a theme icon name
420
+                                if let Some(path) = find_theme_icon(icon_name, target_size) {
421
+                                    if let Ok(icon) = load_from_file(&path, target_size) {
422
+                                        return Some(icon);
423
+                                    }
424
+                                }
425
+                            }
426
+                        }
427
+                        break;
428
+                    }
429
+                }
430
+            }
431
+        }
432
+    }
433
+
434
+    None
435
+}
436
+
437
+/// Scale BGRA image data to target size (simple nearest-neighbor for speed)
438
+fn scale_bgra_image(data: &[u8], width: u32, height: u32, target_size: u32) -> Result<LoadedIcon> {
439
+    let scale = target_size as f32 / width.max(height) as f32;
440
+    let new_w = ((width as f32 * scale).round() as u32).max(1);
441
+    let new_h = ((height as f32 * scale).round() as u32).max(1);
442
+
443
+    let mut scaled = Vec::with_capacity((new_w * new_h * 4) as usize);
444
+
445
+    for y in 0..new_h {
446
+        let src_y = ((y as f32 / scale) as u32).min(height - 1);
447
+        for x in 0..new_w {
448
+            let src_x = ((x as f32 / scale) as u32).min(width - 1);
449
+            let src_idx = ((src_y * width + src_x) * 4) as usize;
450
+            scaled.extend_from_slice(&data[src_idx..src_idx + 4]);
451
+        }
452
+    }
453
+
454
+    Ok(LoadedIcon {
455
+        width: new_w as i32,
456
+        height: new_h as i32,
457
+        data: scaled,
458
+    })
459
+}