gardesk/garnotify / 0c2303e

Browse files

feat(ui): integrate icon rendering into popups

Load and render icons in notification popups. Icons are
centered vertically and positioned before the text content.
Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
0c2303ee1107cc633c6c315551e1652e08c70720
Parents
5131005
Tree
0fd641d

2 changed files

StatusFile+-
M garnotify/src/ui/mod.rs 2 0
M garnotify/src/ui/popup.rs 344 14
garnotify/src/ui/mod.rsmodified
@@ -3,10 +3,12 @@
33
 //! Handles X11 window creation, Cairo rendering, and mouse interaction
44
 //! for notification popups.
55
 
6
+mod icons;
67
 mod layout;
78
 mod popup;
89
 mod popup_manager;
910
 
11
+pub use icons::{load_notification_icon, LoadedIcon};
1012
 pub use layout::{LayoutManager, NotificationPosition, StackDirection};
1113
 pub use popup::NotificationPopup;
1214
 pub use popup_manager::PopupManager;
garnotify/src/ui/popup.rsmodified
@@ -7,11 +7,12 @@ use anyhow::{Context, Result};
77
 use cairo::{Context as CairoContext, Format, ImageSurface};
88
 use gartk_core::{Color, Rect};
99
 use gartk_x11::{Connection, Window, WindowConfig};
10
-use tracing::{debug, info};
10
+use tracing::{debug, info, warn};
1111
 use x11rb::protocol::xproto::{ConnectionExt, ImageFormat};
1212
 
1313
 use crate::config::AppearanceConfig;
1414
 use crate::notification::{Notification, Urgency};
15
+use super::icons::{load_notification_icon, LoadedIcon};
1516
 
1617
 /// Default notification height (will be calculated based on content)
1718
 const DEFAULT_HEIGHT: u32 = 80;
@@ -23,6 +24,14 @@ const BORDER_RADIUS: f64 = 8.0;
2324
 const ICON_SIZE: u32 = 48;
2425
 /// Gap between icon and text
2526
 const ICON_TEXT_GAP: u32 = 12;
27
+/// Action button height
28
+const ACTION_BUTTON_HEIGHT: u32 = 28;
29
+/// Action button padding
30
+const ACTION_BUTTON_PADDING: u32 = 8;
31
+/// Gap between action buttons
32
+const ACTION_BUTTON_GAP: u32 = 6;
33
+/// Gap between content and actions
34
+const CONTENT_ACTION_GAP: u32 = 8;
2635
 
2736
 /// A notification popup window
2837
 pub struct NotificationPopup {
@@ -42,6 +51,12 @@ pub struct NotificationPopup {
4251
     appearance: AppearanceConfig,
4352
     /// Whether the mouse is hovering over the popup
4453
     hovered: bool,
54
+    /// Loaded icon (if any)
55
+    icon: Option<LoadedIcon>,
56
+    /// Bounds of action buttons (in window-local coordinates)
57
+    action_bounds: Vec<Rect>,
58
+    /// Index of currently hovered action button (None if no button hovered)
59
+    hovered_action: Option<usize>,
4560
 }
4661
 
4762
 impl NotificationPopup {
@@ -75,12 +90,16 @@ impl NotificationPopup {
7590
         let surface = ImageSurface::create(Format::ARgb32, rect.width as i32, rect.height as i32)
7691
             .context("Failed to create Cairo surface")?;
7792
 
93
+        // Load icon
94
+        let icon = load_notification_icon(&notification, appearance.icon_size);
95
+
7896
         info!(
79
-            "Created popup window {} for notification {} at ({}, {})",
97
+            "Created popup window {} for notification {} at ({}, {}) with icon: {}",
8098
             window.id(),
8199
             notification.id,
82100
             rect.x,
83
-            rect.y
101
+            rect.y,
102
+            icon.is_some()
84103
         );
85104
 
86105
         Ok(Self {
@@ -92,6 +111,9 @@ impl NotificationPopup {
92111
             rect,
93112
             appearance: appearance.clone(),
94113
             hovered: false,
114
+            icon,
115
+            action_bounds: Vec::new(),
116
+            hovered_action: None,
95117
         })
96118
     }
97119
 
@@ -112,6 +134,8 @@ impl NotificationPopup {
112134
 
113135
     /// Update the notification content (for replacements)
114136
     pub fn update_notification(&mut self, notification: Notification) -> Result<()> {
137
+        // Reload icon if notification changed
138
+        self.icon = load_notification_icon(&notification, self.appearance.icon_size);
115139
         self.notification = notification;
116140
         self.render()?;
117141
         self.present()?;
@@ -166,6 +190,11 @@ impl NotificationPopup {
166190
         // Draw content (icon, summary, body)
167191
         self.draw_content(&ctx, fg_color)?;
168192
 
193
+        // Draw action buttons if present
194
+        if !self.notification.actions.is_empty() {
195
+            self.draw_actions(&ctx, fg_color, border_color)?;
196
+        }
197
+
169198
         self.surface.flush();
170199
         Ok(())
171200
     }
@@ -237,10 +266,22 @@ impl NotificationPopup {
237266
         let mut x = padding;
238267
         let y = padding;
239268
 
240
-        // TODO: Draw icon if present
241
-        // For now, skip icon and just draw text
242
-        if !self.notification.app_icon.is_empty() {
243
-            // Reserve space for icon
269
+        // Draw icon if loaded
270
+        if let Some(ref icon) = self.icon {
271
+            let icon_y = (self.rect.height as f64 - icon.height as f64) / 2.0;
272
+            match icon.to_cairo_surface() {
273
+                Ok(icon_surface) => {
274
+                    ctx.set_source_surface(&icon_surface, padding, icon_y)?;
275
+                    ctx.paint()?;
276
+                    debug!("Drew icon {}x{} at ({}, {})", icon.width, icon.height, padding, icon_y);
277
+                }
278
+                Err(e) => {
279
+                    warn!("Failed to create icon surface: {}", e);
280
+                }
281
+            }
282
+            x += icon.width as f64 + ICON_TEXT_GAP as f64;
283
+        } else if !self.notification.app_icon.is_empty() || self.notification.hints.image_data.is_some() || self.notification.hints.image_path.is_some() {
284
+            // Reserve space for icon even if loading failed (keeps layout consistent)
244285
             x += ICON_SIZE as f64 + ICON_TEXT_GAP as f64;
245286
         }
246287
 
@@ -271,12 +312,25 @@ impl NotificationPopup {
271312
         // Draw body if present
272313
         if !self.notification.body.is_empty() {
273314
             layout.set_font_description(Some(&font_desc));
274
-
275
-            // Strip basic HTML tags (simplified)
276
-            let body = strip_html_tags(&self.notification.body);
277
-            layout.set_text(&body);
278315
             layout.set_height(((self.rect.height as f64 - y - summary_height as f64 - padding - 4.0) * pango::SCALE as f64) as i32);
279316
 
317
+            // Try to parse as Pango markup (FreeDesktop spec allows HTML subset)
318
+            // Sanitize the markup to only allow safe tags
319
+            let body = sanitize_markup(&self.notification.body);
320
+
321
+            // Pango's set_markup doesn't return a result, so we try it and
322
+            // check if the layout has content. If markup parsing fails internally,
323
+            // the layout might be empty or show an error.
324
+            layout.set_markup(&body);
325
+
326
+            // If the markup produced no content (possible parse error), fall back to plain text
327
+            let (_, text_height) = layout.pixel_size();
328
+            if text_height == 0 && !self.notification.body.is_empty() {
329
+                let plain_body = strip_html_tags(&self.notification.body);
330
+                layout.set_text(&plain_body);
331
+                debug!("Body rendered as plain text (markup may have failed)");
332
+            }
333
+
280334
             // Slightly dimmer for body text
281335
             ctx.set_source_rgba(fg.r * 0.8, fg.g * 0.8, fg.b * 0.8, fg.a);
282336
             ctx.move_to(x, y + summary_height as f64 + 4.0);
@@ -286,6 +340,149 @@ impl NotificationPopup {
286340
         Ok(())
287341
     }
288342
 
343
+    /// Draw action buttons at the bottom of the notification
344
+    fn draw_actions(&mut self, ctx: &CairoContext, fg: Color, border: Color) -> Result<()> {
345
+        let actions = &self.notification.actions;
346
+        if actions.is_empty() {
347
+            return Ok(());
348
+        }
349
+
350
+        // Clear previous action bounds
351
+        self.action_bounds.clear();
352
+
353
+        let padding = PADDING as f64;
354
+        let btn_height = ACTION_BUTTON_HEIGHT as f64;
355
+        let btn_padding = ACTION_BUTTON_PADDING as f64;
356
+        let btn_gap = ACTION_BUTTON_GAP as f64;
357
+
358
+        // Position buttons at the bottom
359
+        let btn_y = self.rect.height as f64 - padding - btn_height;
360
+
361
+        // Calculate total width needed for all buttons
362
+        let layout = pangocairo::functions::create_layout(ctx);
363
+        let font_desc = pango::FontDescription::from_string(&self.appearance.font);
364
+        layout.set_font_description(Some(&font_desc));
365
+
366
+        // Measure button widths
367
+        let mut button_widths: Vec<f64> = Vec::new();
368
+        for action in actions {
369
+            layout.set_text(&action.label);
370
+            let (w, _) = layout.pixel_size();
371
+            button_widths.push(w as f64 + btn_padding * 2.0);
372
+        }
373
+
374
+        let total_width: f64 = button_widths.iter().sum::<f64>() + btn_gap * (actions.len() as f64 - 1.0);
375
+        let available_width = self.rect.width as f64 - padding * 2.0;
376
+
377
+        // Scale buttons if they don't fit
378
+        let scale = if total_width > available_width {
379
+            available_width / total_width
380
+        } else {
381
+            1.0
382
+        };
383
+
384
+        // Draw buttons centered or left-aligned
385
+        let mut btn_x = padding;
386
+        if total_width < available_width {
387
+            btn_x = padding + (available_width - total_width) / 2.0; // Center
388
+        }
389
+
390
+        let hovered_idx = self.hovered_action;
391
+
392
+        for (i, action) in actions.iter().enumerate() {
393
+            let btn_w = button_widths[i] * scale;
394
+            let is_hovered = hovered_idx == Some(i);
395
+
396
+            // Store button bounds for click detection
397
+            self.action_bounds.push(Rect {
398
+                x: btn_x as i32,
399
+                y: btn_y as i32,
400
+                width: btn_w as u32,
401
+                height: btn_height as u32,
402
+            });
403
+
404
+            // Draw button background - brighter when hovered
405
+            let btn_bg = if is_hovered {
406
+                Color::new(border.r, border.g, border.b, border.a * 0.6)
407
+            } else {
408
+                Color::new(border.r, border.g, border.b, border.a * 0.3)
409
+            };
410
+            self.draw_button_rect(ctx, btn_x, btn_y, btn_w, btn_height, btn_bg)?;
411
+
412
+            // Draw button border - more visible when hovered
413
+            let border_alpha = if is_hovered { border.a * 0.9 } else { border.a * 0.5 };
414
+            ctx.set_source_rgba(border.r, border.g, border.b, border_alpha);
415
+            ctx.set_line_width(if is_hovered { 1.5 } else { 1.0 });
416
+            self.stroke_button_rect(ctx, btn_x, btn_y, btn_w, btn_height)?;
417
+
418
+            // Draw button label centered
419
+            layout.set_text(&action.label);
420
+            let (label_w, label_h) = layout.pixel_size();
421
+
422
+            let label_x = btn_x + (btn_w - label_w as f64) / 2.0;
423
+            let label_y = btn_y + (btn_height - label_h as f64) / 2.0;
424
+
425
+            ctx.set_source_rgba(fg.r, fg.g, fg.b, fg.a);
426
+            ctx.move_to(label_x, label_y);
427
+            pangocairo::functions::show_layout(ctx, &layout);
428
+
429
+            btn_x += btn_w + btn_gap;
430
+        }
431
+
432
+        debug!("Drew {} action buttons", actions.len());
433
+        Ok(())
434
+    }
435
+
436
+    /// Draw a rounded button rectangle (filled)
437
+    fn draw_button_rect(&self, ctx: &CairoContext, x: f64, y: f64, w: f64, h: f64, color: Color) -> Result<()> {
438
+        let r = 4.0; // Small radius for buttons
439
+
440
+        ctx.new_path();
441
+        ctx.arc(x + w - r, y + r, r, -std::f64::consts::FRAC_PI_2, 0.0);
442
+        ctx.arc(x + w - r, y + h - r, r, 0.0, std::f64::consts::FRAC_PI_2);
443
+        ctx.arc(x + r, y + h - r, r, std::f64::consts::FRAC_PI_2, std::f64::consts::PI);
444
+        ctx.arc(x + r, y + r, r, std::f64::consts::PI, 3.0 * std::f64::consts::FRAC_PI_2);
445
+        ctx.close_path();
446
+
447
+        ctx.set_source_rgba(color.r, color.g, color.b, color.a);
448
+        ctx.fill()?;
449
+        Ok(())
450
+    }
451
+
452
+    /// Stroke a rounded button rectangle (border only)
453
+    fn stroke_button_rect(&self, ctx: &CairoContext, x: f64, y: f64, w: f64, h: f64) -> Result<()> {
454
+        let r = 4.0;
455
+
456
+        ctx.new_path();
457
+        ctx.arc(x + w - r, y + r, r, -std::f64::consts::FRAC_PI_2, 0.0);
458
+        ctx.arc(x + w - r, y + h - r, r, 0.0, std::f64::consts::FRAC_PI_2);
459
+        ctx.arc(x + r, y + h - r, r, std::f64::consts::FRAC_PI_2, std::f64::consts::PI);
460
+        ctx.arc(x + r, y + r, r, std::f64::consts::PI, 3.0 * std::f64::consts::FRAC_PI_2);
461
+        ctx.close_path();
462
+
463
+        ctx.stroke()?;
464
+        Ok(())
465
+    }
466
+
467
+    /// Check if a click hit an action button, returns the action key if so
468
+    /// x, y are window-local coordinates (from X11 ButtonPress event_x, event_y)
469
+    pub fn check_action_click(&self, x: i32, y: i32) -> Option<String> {
470
+        for (i, bounds) in self.action_bounds.iter().enumerate() {
471
+            if x >= bounds.x
472
+                && x < bounds.x + bounds.width as i32
473
+                && y >= bounds.y
474
+                && y < bounds.y + bounds.height as i32
475
+            {
476
+                if let Some(action) = self.notification.actions.get(i) {
477
+                    debug!("Action button clicked: {} ({}) at ({}, {})", action.label, action.key, x, y);
478
+                    return Some(action.key.clone());
479
+                }
480
+            }
481
+        }
482
+        debug!("Click at ({}, {}) didn't hit any action button", x, y);
483
+        None
484
+    }
485
+
289486
     /// Present the rendered surface to the window
290487
     fn present(&mut self) -> Result<()> {
291488
         self.surface.flush();
@@ -326,9 +523,35 @@ impl NotificationPopup {
326523
     /// Handle mouse leave event
327524
     pub fn on_leave(&mut self) {
328525
         self.hovered = false;
526
+        self.hovered_action = None;
329527
         debug!("Mouse left notification {}", self.notification.id);
330528
     }
331529
 
530
+    /// Handle mouse motion event, returns true if hover state changed (needs re-render)
531
+    pub fn on_motion(&mut self, x: i32, y: i32) -> bool {
532
+        let new_hovered = self.find_hovered_action(x, y);
533
+        if new_hovered != self.hovered_action {
534
+            self.hovered_action = new_hovered;
535
+            true // Need to re-render
536
+        } else {
537
+            false
538
+        }
539
+    }
540
+
541
+    /// Find which action button (if any) is at the given position
542
+    fn find_hovered_action(&self, x: i32, y: i32) -> Option<usize> {
543
+        for (i, bounds) in self.action_bounds.iter().enumerate() {
544
+            if x >= bounds.x
545
+                && x < bounds.x + bounds.width as i32
546
+                && y >= bounds.y
547
+                && y < bounds.y + bounds.height as i32
548
+            {
549
+                return Some(i);
550
+            }
551
+        }
552
+        None
553
+    }
554
+
332555
     /// Check if point is inside the popup
333556
     pub fn contains_point(&self, x: i32, y: i32) -> bool {
334557
         x >= self.rect.x
@@ -351,6 +574,106 @@ impl Drop for NotificationPopup {
351574
     }
352575
 }
353576
 
577
+/// Sanitize HTML markup for Pango
578
+///
579
+/// FreeDesktop spec allows: b, i, u, a (href), img (src, alt)
580
+/// Pango supports: b, big, i, s, sub, sup, small, tt, u, span
581
+/// We convert/pass through safe tags and strip/escape unsafe content
582
+fn sanitize_markup(html: &str) -> String {
583
+    // Tags that Pango supports and are safe to pass through
584
+    let safe_tags = ["b", "i", "u", "s", "tt", "big", "small", "sub", "sup", "span"];
585
+
586
+    let mut result = String::with_capacity(html.len());
587
+    let mut chars = html.chars().peekable();
588
+
589
+    while let Some(c) = chars.next() {
590
+        if c == '<' {
591
+            // Start of a tag
592
+            let mut tag = String::new();
593
+            let mut in_tag = true;
594
+
595
+            while let Some(&tc) = chars.peek() {
596
+                if tc == '>' {
597
+                    chars.next(); // consume '>'
598
+                    in_tag = false;
599
+                    break;
600
+                }
601
+                tag.push(chars.next().unwrap());
602
+            }
603
+
604
+            if in_tag {
605
+                // Unclosed tag, escape the '<'
606
+                result.push_str("&lt;");
607
+                result.push_str(&tag);
608
+                continue;
609
+            }
610
+
611
+            // Check if it's a safe tag
612
+            let is_closing = tag.starts_with('/');
613
+            let tag_name = if is_closing {
614
+                &tag[1..]
615
+            } else {
616
+                tag.split_whitespace().next().unwrap_or(&tag)
617
+            };
618
+            let tag_name_lower = tag_name.to_lowercase();
619
+
620
+            // Check if it's in our safe list
621
+            let is_safe = safe_tags.iter().any(|&t| t == tag_name_lower);
622
+
623
+            // Also allow closing tags for safe tags
624
+            if is_safe {
625
+                result.push('<');
626
+                result.push_str(&tag);
627
+                result.push('>');
628
+            } else if tag_name_lower == "a" {
629
+                // Convert <a href="..."> to underlined text (Pango doesn't support links)
630
+                if is_closing {
631
+                    result.push_str("</u>");
632
+                } else {
633
+                    result.push_str("<u>");
634
+                }
635
+            } else if tag_name_lower == "br" || tag_name_lower == "br/" {
636
+                // Convert <br> to newline
637
+                result.push('\n');
638
+            } else if tag_name_lower == "img" {
639
+                // Skip images (could show alt text, but let's keep it simple)
640
+            } else {
641
+                // Strip unknown tags (don't include them)
642
+            }
643
+        } else if c == '&' {
644
+            // Check if it's a valid entity
645
+            let mut entity = String::new();
646
+            entity.push(c);
647
+            let mut found_semicolon = false;
648
+
649
+            while let Some(&ec) = chars.peek() {
650
+                if ec == ';' {
651
+                    entity.push(chars.next().unwrap());
652
+                    found_semicolon = true;
653
+                    break;
654
+                } else if ec.is_alphanumeric() || ec == '#' {
655
+                    entity.push(chars.next().unwrap());
656
+                } else {
657
+                    break;
658
+                }
659
+            }
660
+
661
+            if found_semicolon {
662
+                // Pass through valid entities
663
+                result.push_str(&entity);
664
+            } else {
665
+                // Not a valid entity, escape the ampersand
666
+                result.push_str("&amp;");
667
+                result.push_str(&entity[1..]); // Skip the '&' we already escaped
668
+            }
669
+        } else {
670
+            result.push(c);
671
+        }
672
+    }
673
+
674
+    result
675
+}
676
+
354677
 /// Strip basic HTML tags from body text (simplified implementation)
355678
 fn strip_html_tags(html: &str) -> String {
356679
     let mut result = String::with_capacity(html.len());
@@ -423,9 +746,16 @@ pub fn calculate_notification_height(
423746
         0
424747
     };
425748
 
426
-    // Total height: padding + summary + body + padding
427
-    let height = (appearance.padding * 2) as i32 + summary_height + body_height;
749
+    // Account for action buttons if present
750
+    let actions_height = if !notification.actions.is_empty() {
751
+        CONTENT_ACTION_GAP + ACTION_BUTTON_HEIGHT
752
+    } else {
753
+        0
754
+    };
755
+
756
+    // Total height: padding + summary + body + actions + padding
757
+    let height = (appearance.padding * 2) as i32 + summary_height + body_height + actions_height as i32;
428758
 
429759
     // Ensure minimum height and cap at reasonable maximum
430
-    (height as u32).max(DEFAULT_HEIGHT).min(200)
760
+    (height as u32).max(DEFAULT_HEIGHT).min(250)
431761
 }