gardesk/garnotify / 4fb616b

Browse files

ui: add popup window with Cairo rendering

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
4fb616bc02233bafb8071cc62b133723c7a4ee87
Parents
81eeb91
Tree
1c4bb37

1 changed file

StatusFile+-
A garnotify/src/ui/popup.rs 431 0
garnotify/src/ui/popup.rsadded
@@ -0,0 +1,431 @@
1
+//! Notification popup window
2
+//!
3
+//! Handles X11 window creation, Cairo rendering, and user interaction
4
+//! for individual notification popups.
5
+
6
+use anyhow::{Context, Result};
7
+use cairo::{Context as CairoContext, Format, ImageSurface};
8
+use gartk_core::{Color, Rect};
9
+use gartk_x11::{Connection, Window, WindowConfig};
10
+use tracing::{debug, info};
11
+use x11rb::protocol::xproto::{ConnectionExt, ImageFormat};
12
+
13
+use crate::config::AppearanceConfig;
14
+use crate::notification::{Notification, Urgency};
15
+
16
+/// Default notification height (will be calculated based on content)
17
+const DEFAULT_HEIGHT: u32 = 80;
18
+/// Padding inside the notification
19
+const PADDING: u32 = 12;
20
+/// Border radius for rounded corners
21
+const BORDER_RADIUS: f64 = 8.0;
22
+/// Icon size
23
+const ICON_SIZE: u32 = 48;
24
+/// Gap between icon and text
25
+const ICON_TEXT_GAP: u32 = 12;
26
+
27
+/// A notification popup window
28
+pub struct NotificationPopup {
29
+    /// X11 connection
30
+    conn: Connection,
31
+    /// The X11 window
32
+    window: Window,
33
+    /// Graphics context for drawing
34
+    gc: u32,
35
+    /// Cairo surface for rendering
36
+    surface: ImageSurface,
37
+    /// The notification being displayed
38
+    notification: Notification,
39
+    /// Window geometry
40
+    rect: Rect,
41
+    /// Appearance configuration
42
+    appearance: AppearanceConfig,
43
+    /// Whether the mouse is hovering over the popup
44
+    hovered: bool,
45
+}
46
+
47
+impl NotificationPopup {
48
+    /// Create a new notification popup
49
+    pub fn new(
50
+        conn: Connection,
51
+        notification: Notification,
52
+        rect: Rect,
53
+        appearance: &AppearanceConfig,
54
+    ) -> Result<Self> {
55
+        // Create ARGB window for transparency
56
+        let window = Window::create(
57
+            conn.clone(),
58
+            WindowConfig::default()
59
+                .title(&format!("garnotify-{}", notification.id))
60
+                .class("garnotify")
61
+                .size(rect.width, rect.height)
62
+                .position(rect.x, rect.y)
63
+                .override_redirect(true)
64
+                .transparent(true)
65
+                .map_on_create(false),
66
+        )
67
+        .context("Failed to create notification window")?;
68
+
69
+        // Create graphics context
70
+        let gc = conn.generate_id()?;
71
+        conn.inner()
72
+            .create_gc(gc, window.id(), &Default::default())?;
73
+
74
+        // Create Cairo surface
75
+        let surface = ImageSurface::create(Format::ARgb32, rect.width as i32, rect.height as i32)
76
+            .context("Failed to create Cairo surface")?;
77
+
78
+        info!(
79
+            "Created popup window {} for notification {} at ({}, {})",
80
+            window.id(),
81
+            notification.id,
82
+            rect.x,
83
+            rect.y
84
+        );
85
+
86
+        Ok(Self {
87
+            conn,
88
+            window,
89
+            gc,
90
+            surface,
91
+            notification,
92
+            rect,
93
+            appearance: appearance.clone(),
94
+            hovered: false,
95
+        })
96
+    }
97
+
98
+    /// Get the notification ID
99
+    pub fn id(&self) -> u32 {
100
+        self.notification.id
101
+    }
102
+
103
+    /// Get the window ID
104
+    pub fn window_id(&self) -> u32 {
105
+        self.window.id()
106
+    }
107
+
108
+    /// Get the notification
109
+    pub fn notification(&self) -> &Notification {
110
+        &self.notification
111
+    }
112
+
113
+    /// Update the notification content (for replacements)
114
+    pub fn update_notification(&mut self, notification: Notification) -> Result<()> {
115
+        self.notification = notification;
116
+        self.render()?;
117
+        self.present()?;
118
+        Ok(())
119
+    }
120
+
121
+    /// Show the popup window
122
+    pub fn show(&mut self) -> Result<()> {
123
+        self.render()?;
124
+        self.window.map()?;
125
+        self.present()?;
126
+        self.conn.flush()?;
127
+        debug!("Showed popup {} for notification {}", self.window.id(), self.notification.id);
128
+        Ok(())
129
+    }
130
+
131
+    /// Hide the popup window
132
+    pub fn hide(&self) -> Result<()> {
133
+        self.window.unmap()?;
134
+        self.conn.flush()?;
135
+        debug!("Hid popup {} for notification {}", self.window.id(), self.notification.id);
136
+        Ok(())
137
+    }
138
+
139
+    /// Move the popup to a new position
140
+    pub fn move_to(&mut self, x: i32, y: i32) -> Result<()> {
141
+        self.rect.x = x;
142
+        self.rect.y = y;
143
+        self.conn.inner().configure_window(
144
+            self.window.id(),
145
+            &x11rb::protocol::xproto::ConfigureWindowAux::new().x(x).y(y),
146
+        )?;
147
+        self.conn.flush()?;
148
+        Ok(())
149
+    }
150
+
151
+    /// Render the notification content
152
+    pub fn render(&mut self) -> Result<()> {
153
+        let ctx = CairoContext::new(&self.surface).context("Failed to create Cairo context")?;
154
+
155
+        // Clear with transparent background
156
+        ctx.set_operator(cairo::Operator::Clear);
157
+        ctx.paint()?;
158
+        ctx.set_operator(cairo::Operator::Over);
159
+
160
+        // Get colors based on urgency
161
+        let (bg_color, fg_color, border_color) = self.get_urgency_colors();
162
+
163
+        // Draw rounded rectangle background
164
+        self.draw_rounded_rect(&ctx, bg_color, border_color)?;
165
+
166
+        // Draw content (icon, summary, body)
167
+        self.draw_content(&ctx, fg_color)?;
168
+
169
+        self.surface.flush();
170
+        Ok(())
171
+    }
172
+
173
+    /// Get colors based on notification urgency
174
+    fn get_urgency_colors(&self) -> (Color, Color, Color) {
175
+        let urgency = &self.notification.hints.urgency;
176
+        let colors = &self.appearance.colors;
177
+
178
+        let bg = match urgency {
179
+            Urgency::Low => Color::from_hex(&colors.low_background)
180
+                .unwrap_or(Color::new(0.1, 0.1, 0.1, 0.9)),
181
+            Urgency::Normal => Color::from_hex(&colors.background)
182
+                .unwrap_or(Color::new(0.12, 0.12, 0.14, 0.95)),
183
+            Urgency::Critical => Color::from_hex(&colors.critical_background)
184
+                .unwrap_or(Color::new(0.3, 0.1, 0.1, 0.95)),
185
+        };
186
+
187
+        let fg = match urgency {
188
+            Urgency::Low => Color::from_hex(&colors.low_foreground)
189
+                .unwrap_or(Color::new(0.7, 0.7, 0.7, 1.0)),
190
+            Urgency::Normal => Color::from_hex(&colors.foreground)
191
+                .unwrap_or(Color::new(0.9, 0.9, 0.9, 1.0)),
192
+            Urgency::Critical => Color::from_hex(&colors.critical_foreground)
193
+                .unwrap_or(Color::new(1.0, 0.9, 0.9, 1.0)),
194
+        };
195
+
196
+        let border = match urgency {
197
+            Urgency::Low => Color::from_hex(&colors.low_border)
198
+                .unwrap_or(Color::new(0.3, 0.3, 0.3, 0.5)),
199
+            Urgency::Normal => Color::from_hex(&colors.border)
200
+                .unwrap_or(Color::new(0.4, 0.4, 0.4, 0.5)),
201
+            Urgency::Critical => Color::from_hex(&colors.critical_border)
202
+                .unwrap_or(Color::new(0.8, 0.2, 0.2, 0.8)),
203
+        };
204
+
205
+        (bg, fg, border)
206
+    }
207
+
208
+    /// Draw rounded rectangle background with border
209
+    fn draw_rounded_rect(&self, ctx: &CairoContext, bg: Color, border: Color) -> Result<()> {
210
+        let w = self.rect.width as f64;
211
+        let h = self.rect.height as f64;
212
+        let r = BORDER_RADIUS;
213
+
214
+        // Create rounded rectangle path
215
+        ctx.new_path();
216
+        ctx.arc(w - r, r, r, -std::f64::consts::FRAC_PI_2, 0.0);
217
+        ctx.arc(w - r, h - r, r, 0.0, std::f64::consts::FRAC_PI_2);
218
+        ctx.arc(r, h - r, r, std::f64::consts::FRAC_PI_2, std::f64::consts::PI);
219
+        ctx.arc(r, r, r, std::f64::consts::PI, 3.0 * std::f64::consts::FRAC_PI_2);
220
+        ctx.close_path();
221
+
222
+        // Fill background
223
+        ctx.set_source_rgba(bg.r, bg.g, bg.b, bg.a);
224
+        ctx.fill_preserve()?;
225
+
226
+        // Draw border
227
+        ctx.set_source_rgba(border.r, border.g, border.b, border.a);
228
+        ctx.set_line_width(1.0);
229
+        ctx.stroke()?;
230
+
231
+        Ok(())
232
+    }
233
+
234
+    /// Draw notification content (icon, summary, body)
235
+    fn draw_content(&self, ctx: &CairoContext, fg: Color) -> Result<()> {
236
+        let padding = PADDING as f64;
237
+        let mut x = padding;
238
+        let y = padding;
239
+
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
244
+            x += ICON_SIZE as f64 + ICON_TEXT_GAP as f64;
245
+        }
246
+
247
+        // Create Pango layout for text
248
+        let layout = pangocairo::functions::create_layout(ctx);
249
+
250
+        // Set font (config has Pango-style font strings like "Sans 11")
251
+        let font_desc = pango::FontDescription::from_string(&self.appearance.font);
252
+        let title_font_desc = pango::FontDescription::from_string(&self.appearance.title_font);
253
+        layout.set_font_description(Some(&font_desc));
254
+
255
+        // Set width for wrapping
256
+        let text_width = self.rect.width as f64 - x - padding;
257
+        layout.set_width((text_width * pango::SCALE as f64) as i32);
258
+        layout.set_ellipsize(pango::EllipsizeMode::End);
259
+
260
+        // Draw summary (title font)
261
+        layout.set_font_description(Some(&title_font_desc));
262
+        layout.set_text(&self.notification.summary);
263
+
264
+        ctx.set_source_rgba(fg.r, fg.g, fg.b, fg.a);
265
+        ctx.move_to(x, y);
266
+        pangocairo::functions::show_layout(ctx, &layout);
267
+
268
+        // Get summary height for body positioning
269
+        let (_, summary_height) = layout.pixel_size();
270
+
271
+        // Draw body if present
272
+        if !self.notification.body.is_empty() {
273
+            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);
278
+            layout.set_height(((self.rect.height as f64 - y - summary_height as f64 - padding - 4.0) * pango::SCALE as f64) as i32);
279
+
280
+            // Slightly dimmer for body text
281
+            ctx.set_source_rgba(fg.r * 0.8, fg.g * 0.8, fg.b * 0.8, fg.a);
282
+            ctx.move_to(x, y + summary_height as f64 + 4.0);
283
+            pangocairo::functions::show_layout(ctx, &layout);
284
+        }
285
+
286
+        Ok(())
287
+    }
288
+
289
+    /// Present the rendered surface to the window
290
+    fn present(&mut self) -> Result<()> {
291
+        self.surface.flush();
292
+
293
+        // Get surface data
294
+        let data = self
295
+            .surface
296
+            .data()
297
+            .map_err(|_| anyhow::anyhow!("Failed to get surface data"))?;
298
+
299
+        // Put image to window
300
+        self.conn
301
+            .inner()
302
+            .put_image(
303
+                ImageFormat::Z_PIXMAP,
304
+                self.window.id(),
305
+                self.gc,
306
+                self.rect.width as u16,
307
+                self.rect.height as u16,
308
+                0,
309
+                0,
310
+                0,
311
+                self.window.depth(),
312
+                &data,
313
+            )
314
+            .context("Failed to put image to window")?;
315
+
316
+        self.conn.flush()?;
317
+        Ok(())
318
+    }
319
+
320
+    /// Handle mouse enter event
321
+    pub fn on_enter(&mut self) {
322
+        self.hovered = true;
323
+        debug!("Mouse entered notification {}", self.notification.id);
324
+    }
325
+
326
+    /// Handle mouse leave event
327
+    pub fn on_leave(&mut self) {
328
+        self.hovered = false;
329
+        debug!("Mouse left notification {}", self.notification.id);
330
+    }
331
+
332
+    /// Check if point is inside the popup
333
+    pub fn contains_point(&self, x: i32, y: i32) -> bool {
334
+        x >= self.rect.x
335
+            && x < self.rect.x + self.rect.width as i32
336
+            && y >= self.rect.y
337
+            && y < self.rect.y + self.rect.height as i32
338
+    }
339
+
340
+    /// Check if mouse is hovering
341
+    pub fn is_hovered(&self) -> bool {
342
+        self.hovered
343
+    }
344
+}
345
+
346
+impl Drop for NotificationPopup {
347
+    fn drop(&mut self) {
348
+        // Free graphics context
349
+        let _ = self.conn.inner().free_gc(self.gc);
350
+        debug!("Dropped popup for notification {}", self.notification.id);
351
+    }
352
+}
353
+
354
+/// Strip basic HTML tags from body text (simplified implementation)
355
+fn strip_html_tags(html: &str) -> String {
356
+    let mut result = String::with_capacity(html.len());
357
+    let mut in_tag = false;
358
+
359
+    for c in html.chars() {
360
+        match c {
361
+            '<' => in_tag = true,
362
+            '>' => in_tag = false,
363
+            _ if !in_tag => result.push(c),
364
+            _ => {}
365
+        }
366
+    }
367
+
368
+    // Decode basic HTML entities
369
+    result
370
+        .replace("&lt;", "<")
371
+        .replace("&gt;", ">")
372
+        .replace("&amp;", "&")
373
+        .replace("&quot;", "\"")
374
+        .replace("&apos;", "'")
375
+        .replace("&#39;", "'")
376
+}
377
+
378
+/// Calculate the height needed for a notification based on its content
379
+pub fn calculate_notification_height(
380
+    notification: &Notification,
381
+    width: u32,
382
+    appearance: &AppearanceConfig,
383
+) -> u32 {
384
+    // Create a temporary surface just for measurement
385
+    let surface = match ImageSurface::create(Format::ARgb32, 1, 1) {
386
+        Ok(s) => s,
387
+        Err(_) => return DEFAULT_HEIGHT,
388
+    };
389
+
390
+    let ctx = match CairoContext::new(&surface) {
391
+        Ok(c) => c,
392
+        Err(_) => return DEFAULT_HEIGHT,
393
+    };
394
+
395
+    let layout = pangocairo::functions::create_layout(&ctx);
396
+
397
+    let font_desc = pango::FontDescription::from_string(&appearance.font);
398
+    let title_font_desc = pango::FontDescription::from_string(&appearance.title_font);
399
+    layout.set_font_description(Some(&font_desc));
400
+
401
+    // Calculate text area width
402
+    let text_x = if notification.app_icon.is_empty() {
403
+        appearance.padding
404
+    } else {
405
+        appearance.padding + appearance.icon_size + ICON_TEXT_GAP
406
+    };
407
+    let text_width = width - text_x - appearance.padding;
408
+    layout.set_width((text_width as i32) * pango::SCALE);
409
+
410
+    // Measure summary (using title font)
411
+    layout.set_font_description(Some(&title_font_desc));
412
+    layout.set_text(&notification.summary);
413
+    let (_, summary_height) = layout.pixel_size();
414
+
415
+    // Measure body
416
+    let body_height = if !notification.body.is_empty() {
417
+        layout.set_font_description(Some(&font_desc));
418
+        let body = strip_html_tags(&notification.body);
419
+        layout.set_text(&body);
420
+        let (_, h) = layout.pixel_size();
421
+        h + 4 // gap between summary and body
422
+    } else {
423
+        0
424
+    };
425
+
426
+    // Total height: padding + summary + body + padding
427
+    let height = (appearance.padding * 2) as i32 + summary_height + body_height;
428
+
429
+    // Ensure minimum height and cap at reasonable maximum
430
+    (height as u32).max(DEFAULT_HEIGHT).min(200)
431
+}