@@ -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 = ¬if.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 | +} |