git.lucas.co / cce-notifier
notification daemon
git clone https://git.lucas.co/cce-notifier.git

src/main.rs (30.8K)

  1 use std::collections::HashMap;
  2 
  3 use zbus::zvariant::Value;
  4 use zbus::{connection, interface};
  5 
  6 use wayland_client::QueueHandle;
  7 
  8 use cce_ui::engine::{
  9     Application, EngineState, LayerAnchor, LayerKeyboardInteractivity, LayerKind, LayerSettings,
 10     LogicalPosition, WindowSettings,
 11 };
 12 use cce_ui::widget::{ElementState, KeyEvent, MouseButton, MouseScrollDelta};
 13 
 14 const NOTIF_WIDTH: u32 = 360;
 15 const CARD_H: u32 = 100;
 16 const CARD_GAP: u32 = 8;
 17 
 18 /// How many notifications are on screen at once. Beyond this they queue: a
 19 /// notification only starts its timer once it is displayed, so a burst is read
 20 /// in order rather than scrolling past unseen.
 21 const MAX_VISIBLE: usize = 5;
 22 
 23 /// A layer surface's size is fixed at creation (the cce-ui engine has no
 24 /// runtime resize for one), so the surface is always tall enough for a full
 25 /// stack and the unused part is left transparent — and click-through, via the
 26 /// per-card `input_regions` below.
 27 const NOTIF_HEIGHT: u32 = MAX_VISIBLE as u32 * (CARD_H + CARD_GAP) - CARD_GAP;
 28 
 29 // Image previews (the freedesktop `image-path` hint, e.g. screenshots) fit
 30 // this box, aspect-preserved, left of the text.
 31 const THUMB_MAX_W: f32 = 100.0;
 32 const THUMB_MAX_H: f32 = 76.0;
 33 
 34 // The text column's right and bottom padding, and where the body starts. The
 35 // body gets whatever is left of the card below `BODY_TOP`.
 36 const CARD_PAD: f32 = 14.0;
 37 const CARD_PAD_B: f32 = 6.0;
 38 const BODY_TOP: f32 = 46.0;
 39 const BODY_SIZE: f32 = 11.0;
 40 
 41 /// Body lines a card has room for: `box_height` (48) over the engine's 1.4 line
 42 /// height at [`BODY_SIZE`] (15.4) — three lines, 46.2. Kept as a number because
 43 /// the ellipsis fit has to reason about it, and asserted against the geometry
 44 /// in the tests below so the two cannot drift.
 45 const BODY_LINES: usize = 3;
 46 
 47 /// Where the text column starts: the thumbnail pushes it right of the image box.
 48 fn text_origin(has_image: bool) -> f32 {
 49     if has_image {
 50         14.0 + THUMB_MAX_W + 12.0
 51     } else {
 52         18.0
 53     }
 54 }
 55 
 56 /// The column the body wraps into — origin to the card's right padding.
 57 fn body_wrap_width(has_image: bool) -> f32 {
 58     NOTIF_WIDTH as f32 - text_origin(has_image) - CARD_PAD
 59 }
 60 
 61 #[derive(Debug, Clone)]
 62 enum UserEvent {
 63     NewNotification {
 64         /// Server-assigned, or the client's `replaces_id`: an arriving id that
 65         /// matches a live card updates it in place instead of stacking.
 66         id: u32,
 67         app_name: String,
 68         summary: String,
 69         body: String,
 70         image_path: Option<String>,
 71         /// Seconds, already resolved from the client's `expire_timeout`.
 72         duration: f32,
 73     },
 74     CloseNotification {
 75         id: u32,
 76     },
 77 }
 78 
 79 // ── Config accessors ──────────────────────────────────────────────────────
 80 
 81 fn read_duration() -> f32 {
 82     cce_ui::config::get_i64("/notifications/duration", 5) as f32
 83 }
 84 
 85 /// The notification plate style: per-app `plate { }` keys from
 86 /// `~/.config/cce/cce-notifier/config.kdl` (merged over the global config by
 87 /// `parse_kdl_to_json`), falling back to the shared `style.surface.plate`
 88 /// values for anything unset.
 89 struct PlateStyle {
 90     fill: [f32; 4],
 91     border: Option<([f32; 4], f32)>,
 92     radius: f32,
 93     blur: bool,
 94     opacity: f32,
 95 }
 96 
 97 fn read_plate_style() -> PlateStyle {
 98     let cfg = cce_ui::config::cached_config();
 99     let key = |name: &str| cfg.pointer(&format!("/plate/{name}"));
100     let f32_key = |name: &str, default: f32| {
101         key(name).and_then(|v| v.as_f64()).map(|f| f as f32).unwrap_or(default)
102     };
103     // Shared plate colors are stored linear (color.rs gamma-corrects on load),
104     // so the override keys parse linear too.
105     let linear = |name: &str| {
106         key(name)
107             .and_then(|v| v.as_str())
108             .and_then(cce_ui::color::parse_hex_rgba_linear)
109     };
110     let fill = linear("color")
111         .or_else(cce_ui::colors::plate_color)
112         .unwrap_or([
113             cce_ui::colors::srgb_to_linear(0.08),
114             cce_ui::colors::srgb_to_linear(0.08),
115             cce_ui::colors::srgb_to_linear(0.12),
116             1.0,
117         ]);
118     let border = linear("border_color")
119         .or_else(cce_ui::colors::plate_border_color)
120         .map(|c| (c, f32_key("border_thickness", cce_ui::colors::plate_border_thickness())))
121         .filter(|&(_, t)| t > 0.0);
122     PlateStyle {
123         fill,
124         border,
125         radius: f32_key("corner_radius", cce_ui::layout::plate_corner_radius()),
126         blur: key("blur").and_then(|v| v.as_bool()).unwrap_or_else(cce_ui::colors::plate_blur),
127         opacity: f32_key("opacity", cce_ui::layout::plate_opacity()),
128     }
129 }
130 
131 fn play_bell_if_configured() {
132     let sound_type = cce_ui::config::get_string("/notifications/bell").unwrap_or_default();
133     let sound_event = match sound_type.as_str() {
134         "bell" => Some("bell"),
135         "dialog" => Some("dialog-information"),
136         "message" => Some("message"),
137         _ => None,
138     };
139     if let Some(event) = sound_event {
140         log::info!("Playing notification sound ({})...", sound_type);
141         let _ = std::process::Command::new("canberra-gtk-play")
142             .arg("-i")
143             .arg(event)
144             .spawn();
145     }
146 }
147 
148 /// Decode a PNG and nearest-neighbor downscale it to fit the thumbnail box.
149 /// Returns RGBA8 pixels plus dimensions; None for unreadable/non-PNG files.
150 fn load_thumbnail(path: &str) -> Option<(Vec<u8>, u32, u32)> {
151     let file = std::fs::File::open(path).ok()?;
152     let mut decoder = png::Decoder::new(std::io::BufReader::new(file));
153     decoder.set_transformations(png::Transformations::normalize_to_color8());
154     let mut reader = decoder.read_info().ok()?;
155     let mut buf = vec![0u8; reader.output_buffer_size()];
156     let info = reader.next_frame(&mut buf).ok()?;
157     let (w, h) = (info.width as usize, info.height as usize);
158     let rgba: Vec<u8> = match info.color_type {
159         png::ColorType::Rgba => buf[..w * h * 4].to_vec(),
160         png::ColorType::Rgb => buf[..w * h * 3]
161             .chunks_exact(3)
162             .flat_map(|px| [px[0], px[1], px[2], 255])
163             .collect(),
164         png::ColorType::Grayscale => buf[..w * h].iter().flat_map(|&g| [g, g, g, 255]).collect(),
165         png::ColorType::GrayscaleAlpha => buf[..w * h * 2]
166             .chunks_exact(2)
167             .flat_map(|px| [px[0], px[0], px[0], px[1]])
168             .collect(),
169         _ => return None,
170     };
171 
172     let scale = (THUMB_MAX_W / w as f32).min(THUMB_MAX_H / h as f32).min(1.0);
173     let (tw, th) = (
174         ((w as f32 * scale) as usize).max(1),
175         ((h as f32 * scale) as usize).max(1),
176     );
177     let mut thumb = Vec::with_capacity(tw * th * 4);
178     for ty in 0..th {
179         let sy = ty * h / th;
180         for tx in 0..tw {
181             let sx = tx * w / tw;
182             let i = (sy * w + sx) * 4;
183             thumb.extend_from_slice(&rgba[i..i + 4]);
184         }
185     }
186     Some((thumb, tw as u32, th as u32))
187 }
188 
189 /// How many lines `text` wraps to in a column `wrap_w` wide.
190 ///
191 /// Measured through the engine's OWN boxed-text layout — the same call the
192 /// renderer makes for a `text_boxed` prim — so the count cannot drift from what
193 /// is actually drawn (its 1.4 line height and the scale factor are applied
194 /// inside). The height is left effectively unbounded on purpose: pass the card's
195 /// real `box_height` and cosmic-text stops shaping at the lines that fit, which
196 /// is exactly the overflow this needs to detect.
197 fn wrapped_line_count(text: &str, wrap_w: f32, font: Option<&str>) -> usize {
198     let Ok(mut fs) = cce_ui::geometry_font_system().lock() else {
199         return 1;
200     };
201     let (buffer, _) = cce_ui::engine::get_text_buffer_laid_out(
202         &mut fs,
203         text,
204         BODY_SIZE,
205         font,
206         cce_ui::scene::paint::TextAttrs::default(),
207         cce_ui::scene::paint::TextLayout {
208             wrap_width: Some(wrap_w),
209             box_height: 10_000.0,
210             align_h: cce_ui::scene::paint::AlignH::Left,
211             align_v: cce_ui::scene::paint::AlignV::Top,
212         },
213     );
214     buffer.layout_runs().count()
215 }
216 
217 /// The body as it should be drawn: unchanged when it fits, else cut to the most
218 /// text that still fits [`BODY_LINES`] lines *with* an ellipsis appended.
219 ///
220 /// A card is a fixed height, so a long body is truncated either way — this only
221 /// decides whether the reader can see that it was. Binary search over the char
222 /// prefix, re-measuring each candidate, because where the text wraps (and so
223 /// how much fits) depends on the words themselves, not the character count.
224 /// Called once per notification on arrival, never per frame.
225 fn fit_body(body: &str, wrap_w: f32, font: Option<&str>) -> String {
226     if body.is_empty() || wrapped_line_count(body, wrap_w, font) <= BODY_LINES {
227         return body.to_string();
228     }
229     let chars: Vec<char> = body.chars().collect();
230     let with_ellipsis = |n: usize| -> String {
231         let mut s: String = chars[..n].iter().collect();
232         // Trim first so the ellipsis follows the word, not the space after it.
233         while s.ends_with(char::is_whitespace) {
234             s.pop();
235         }
236         s.push('…');
237         s
238     };
239     // Largest prefix that still fits. `fits(0)` is just the ellipsis, so the
240     // search always has an answer to fall back on.
241     let (mut lo, mut hi) = (0usize, chars.len());
242     while lo < hi {
243         let mid = (lo + hi).div_ceil(2);
244         if wrapped_line_count(&with_ellipsis(mid), wrap_w, font) <= BODY_LINES {
245             lo = mid;
246         } else {
247             hi = mid - 1;
248         }
249     }
250     with_ellipsis(lo)
251 }
252 
253 fn srgb_u8(linear: [f32; 4]) -> [u8; 3] {
254     let srgb = cce_ui::colors::to_srgb(linear);
255     [
256         (srgb[0] * 255.0) as u8,
257         (srgb[1] * 255.0) as u8,
258         (srgb[2] * 255.0) as u8,
259     ]
260 }
261 
262 /// Paint one notification card with its top edge at `top` in surface-local
263 /// logical px. Everything is offset from there, so a card's position in the
264 /// stack is the only thing that changes between slots.
265 fn draw_card(
266     pc: &mut cce_ui::scene::paint::PaintCtx,
267     plate: &PlateStyle,
268     notification: &Notification,
269     top: f32,
270 ) {
271     use cce_ui::scene::layout::Rect;
272     let card_h = CARD_H as f32;
273     // The plate (per-app `plate { }` keys over the shared plate style;
274     // blur via the negative-alpha marker), replacing the old clear-color background.
275     let surface = Rect { x: 0.0, y: top, width: NOTIF_WIDTH as f32, height: card_h };
276     let radius = plate.radius;
277     let mut fill = plate.fill;
278     fill[3] *= plate.opacity;
279     if plate.blur {
280         fill[3] = -fill[3].abs();
281     }
282     match plate.border {
283         Some((border, thickness)) => {
284             pc.border(surface, (radius, radius, radius, radius), fill, border, thickness)
285         }
286         None => {
287             let on = radius > 0.0;
288             pc.rounded_rect(surface, radius, (on, on, on, on), fill);
289         }
290     }
291     // Preview thumbnail (screenshots etc.) centered in its box left of
292     // the text, which shifts right to make room.
293     let text_x = text_origin(notification.image.is_some());
294     if let Some((id, w, h)) = notification.image {
295         let ix = 14.0 + (THUMB_MAX_W - w) / 2.0;
296         let iy = top + (card_h - h) / 2.0;
297         pc.image(id, Rect { x: ix, y: iy, width: w, height: h }, 1.0);
298     }
299     // Use a configured (bundled) font family so glyph font-ids resolve in the
300     // engine's render FontSystem — a bare default can pick a system font absent
301     // from the engine's bundled-only database.
302     let family = cce_ui::layout::statusbar_font_parsed().0;
303     let font = Some(family);
304     // The text column: from `text_x` (which the thumbnail may have pushed right)
305     // to the card's right padding. Every label is bounded by it, so nothing runs
306     // out over the plate's edge and rounded corner.
307     let text_w = body_wrap_width(notification.image.is_some());
308     let column = |t: f32, b: f32| Some([text_x, top + t, text_x + text_w, top + b]);
309     pc.text_with(&notification.app_name, text_x, top + 12.0, 10.0, srgb_u8(cce_ui::colors::TEXT_DIM), font.clone(), column(8.0, BODY_TOP));
310     pc.text_with(&notification.summary, text_x, top + 28.0, 13.0, srgb_u8(cce_ui::colors::TEXT_HEADER), font.clone(), column(24.0, BODY_TOP));
311     // The body word-wraps within that column instead of running off the card.
312     // `box_height` is what bounds it: the engine lays boxed text out at a 1.4
313     // line height, so this admits BODY_LINES 11px lines (46.2 of 48) and shapes
314     // away the rest — a card is a fixed height and cannot grow to fit. Anything
315     // longer was already cut to fit with an ellipsis by `fit_body` on arrival,
316     // so this bound is a backstop, not the truncation.
317     pc.text_boxed(
318         &notification.body,
319         text_x,
320         top + BODY_TOP,
321         BODY_SIZE,
322         srgb_u8(cce_ui::colors::TEXT_FG),
323         font,
324         column(BODY_TOP, card_h - CARD_PAD_B),
325         cce_ui::scene::paint::TextAttrs::default(),
326         cce_ui::scene::paint::TextLayout {
327             wrap_width: Some(text_w),
328             box_height: card_h - BODY_TOP - CARD_PAD_B,
329             align_h: cce_ui::scene::paint::AlignH::Left,
330             align_v: cce_ui::scene::paint::AlignV::Top,
331         },
332     );
333 }
334 
335 // ── Application ───────────────────────────────────────────────────────────
336 //
337 // Phase 6 shape: the whole frame — plate and text — is one display list
338 // (`display_list` + `display_list_text`); the engine shapes the text through the shared
339 // buffer cache. No app-side FontSystem, TextItem cache, or rebuild bookkeeping.
340 
341 struct Notification {
342     id: u32,
343     app_name: String,
344     summary: String,
345     /// Already fitted to the card by [`fit_body`] on arrival — ellipsis and all.
346     body: String,
347     /// Uploaded preview image (id from `vk::upload_rgba`, logical w, h).
348     image: Option<(u32, f32, f32)>,
349     /// Where [`image`] was decoded from, kept so the card can be re-uploaded
350     /// into a replacement renderer — see `NotifierApp::renderer_init`. `None`
351     /// when the notification carried no image, or when its file would not
352     /// decode.
353     ///
354     /// [`image`]: Notification::image
355     image_path: Option<String>,
356     /// Seconds the card is asked to stay up once it is displayed.
357     duration: f32,
358     /// When the card comes down, set on the first tick it is displayed — so a
359     /// queued notification does not expire before it is ever shown. A wall
360     /// clock, deliberately, not an accumulation of the runner's `dt`: `dt` is
361     /// animation time, clamped to one frame after an idle sleep, and a card
362     /// whose only reason to redraw is its own expiry never leaves that sleep.
363     /// Ageing it by `dt` ran the countdown at 1/60 speed — a 5 s toast sat on
364     /// screen for five minutes.
365     expires_at: Option<std::time::Instant>,
366 }
367 
368 impl Notification {
369     fn free_image(&mut self) {
370         if let Some((id, _, _)) = self.image.take() {
371             cce_ui::vk::free_image(id);
372         }
373     }
374 
375     /// Re-decode and re-upload the thumbnail against the current renderer.
376     ///
377     /// The stale id is freed first (a free for an id the new renderer never
378     /// had is a no-op), and a file that has since gone away simply leaves the
379     /// card imageless rather than drawing nothing in a reserved box.
380     fn reupload_image(&mut self) {
381         self.free_image();
382         let Some(path) = self.image_path.clone() else { return };
383         match load_thumbnail(&path) {
384             Some((pixels, w, h)) => {
385                 self.image = Some((cce_ui::vk::upload_rgba(pixels, w, h), w as f32, h as f32));
386             }
387             None => {
388                 log::warn!("[notifier] {path} no longer decodes; the card keeps its text");
389                 self.image_path = None;
390             }
391         }
392     }
393 
394     fn top(index: usize) -> f32 {
395         index as f32 * (CARD_H + CARD_GAP) as f32
396     }
397 }
398 
399 struct NotifierApp {
400     /// Whether a renderer has been handed over yet — the first one is the
401     /// process's own, any later one is a replacement after a reconnect. See
402     /// `renderer_init`.
403     seen_renderer: bool,
404     /// Live notifications, oldest first. The first `MAX_VISIBLE` are drawn top-down
405     /// (so a new one appears below the ones already being read, and cards below an
406     /// expiring one slide up); the rest wait their turn.
407     stack: Vec<Notification>,
408     plate: PlateStyle,
409     sender: calloop::channel::Sender<UserEvent>,
410 }
411 
412 impl NotifierApp {
413     fn visible_count(&self) -> usize {
414         self.stack.len().min(MAX_VISIBLE)
415     }
416 }
417 
418 impl Application for NotifierApp {
419     type Message = UserEvent;
420 
421     fn new(_qh: &QueueHandle<EngineState<Self>>, sender: calloop::channel::Sender<Self::Message>) -> Self {
422         Self {
423             seen_renderer: false,
424             stack: Vec::new(),
425             plate: read_plate_style(),
426             sender,
427         }
428     }
429 
430     fn settings(&self) -> WindowSettings {
431         WindowSettings {
432             title: "cce-notifier".to_string(),
433             app_id: "cce-notifier".to_string(),
434             width: NOTIF_WIDTH,
435             height: NOTIF_HEIGHT,
436             fullscreen: false,
437             min_size: None,
438         }
439     }
440 
441     fn layer(&self) -> Option<LayerSettings> {
442         // Sit below the status modules with one bar-spacing of gap, right edge aligned
443         // with the rightmost top-right module (the clock): the compositor lays the bar
444         // out at y=0, height `layout.bar_height`, flush to `output_width - MARGIN`
445         // with SPACING between segments (cce-window-manager arrange.rs, both 12).
446         let bar_h = cce_ui::config::get_i64("/layout/bar_height", 24) as i32;
447         Some(LayerSettings {
448             layer: LayerKind::Overlay,
449             anchor: LayerAnchor::TOP | LayerAnchor::RIGHT,
450             exclusive_zone: 0,
451             keyboard_interactivity: LayerKeyboardInteractivity::None,
452             margin: (bar_h + 12, 12, 0, 0),
453             namespace: "cce-notifier".to_string(),
454         })
455     }
456 
457     fn update(&mut self, msg: Self::Message, needs_rebuild: &mut bool, _exit: &mut bool) {
458         match msg {
459             UserEvent::NewNotification { id, app_name, summary, body, image_path, duration } => {
460                 play_bell_if_configured();
461                 let image = image_path
462                     .as_deref()
463                     .and_then(load_thumbnail)
464                     .map(|(pixels, w, h)| {
465                         (cce_ui::vk::upload_rgba(pixels, w, h), w as f32, h as f32)
466                     });
467                 // Fit the body to the card once, here, rather than per frame:
468                 // the column it wraps into depends on whether a thumbnail
469                 // pushed the text right, which is settled by now.
470                 let family = cce_ui::layout::statusbar_font_parsed().0;
471                 let body = fit_body(&body, body_wrap_width(image.is_some()), Some(&family));
472                 let fresh = Notification {
473                     id,
474                     app_name,
475                     summary,
476                     body,
477                     // Only a path that actually produced a texture: an
478                     // unreadable one must not make the re-upload retry it on
479                     // every reconnect.
480                     image_path: image.is_some().then_some(image_path).flatten(),
481                     image,
482                     duration,
483                     expires_at: None,
484                 };
485                 // A repeat of a live id (volume steps, download progress) refreshes that
486                 // card where it sits rather than growing the stack.
487                 match self.stack.iter().position(|n| n.id == id) {
488                     Some(i) => {
489                         self.stack[i].free_image();
490                         self.stack[i] = fresh;
491                     }
492                     None => self.stack.push(fresh),
493                 }
494                 self.plate = read_plate_style();
495                 *needs_rebuild = true;
496             }
497             UserEvent::CloseNotification { id } => {
498                 if let Some(i) = self.stack.iter().position(|n| n.id == id) {
499                     self.stack.remove(i).free_image();
500                     *needs_rebuild = true;
501                 }
502             }
503         }
504     }
505 
506     fn tick(&mut self, _dt: f32, needs_rebuild: &mut bool) {
507         // Only displayed cards age; queued ones keep their full duration and
508         // start counting on the first tick after a slot frees up.
509         let now = std::time::Instant::now();
510         let visible = self.visible_count();
511         for n in &mut self.stack[..visible] {
512             let deadline = now + std::time::Duration::from_secs_f32(n.duration);
513             n.expires_at.get_or_insert(deadline);
514         }
515         let before = self.stack.len();
516         let mut i = 0;
517         while i < self.stack.len() {
518             if self.stack[i].expires_at.is_some_and(|t| now >= t) {
519                 self.stack.remove(i).free_image();
520             } else {
521                 i += 1;
522             }
523         }
524         if self.stack.len() != before {
525             *needs_rebuild = true;
526         }
527     }
528 
529     /// Re-upload every live card's thumbnail when the renderer is replaced.
530     ///
531     /// A card holds a **renderer** image id, and a renderer does not outlive
532     /// its session: `cce-ui`'s `window_runner` repairs a lost Wayland
533     /// transport by opening a new session around the same `Application`, which
534     /// rebuilds the renderer and with it the image table. The cached id then
535     /// names an image that no longer exists, and a draw for an unknown id is
536     /// skipped rather than reported — so a card that was on screen across the
537     /// reconnect came back with its text and an empty thumbnail box, and would
538     /// stay that way for the rest of its life, since a card's image is
539     /// uploaded once on arrival and never again.
540     ///
541     /// The layer surface outlives every card, so this is not a
542     /// once-at-startup concern: the stack is whatever happened to be up when
543     /// the transport broke.
544     ///
545     /// Not on the first renderer: no card can exist yet — the D-Bus server is
546     /// only started after the app is built, and its uploads are queued for
547     /// precisely that renderer.
548     fn renderer_init(&mut self, _renderer: &mut cce_ui::vk::VkRenderer) {
549         if !std::mem::replace(&mut self.seen_renderer, true) {
550             return;
551         }
552         let with_images = self.stack.iter().filter(|n| n.image.is_some()).count();
553         if with_images > 0 {
554             log::info!("[notifier] renderer replaced; re-uploading {with_images} card thumbnail(s)");
555         }
556         for n in &mut self.stack {
557             n.reupload_image();
558         }
559     }
560 
561     /// A live card's only pending work is its own expiry, which the runner
562     /// cannot see: nothing redraws, so the loop parks on the default idle
563     /// sleep and the card outstays its welcome by up to a second. Poll while
564     /// the stack is occupied, and go fully idle the moment it empties.
565     fn idle_poll_interval(&self) -> Option<std::time::Duration> {
566         (!self.stack.is_empty()).then(|| std::time::Duration::from_millis(100))
567     }
568 
569     /// The whole frame as one display list (Phase 6): the plate plus the three
570     /// text lines. Coordinates are logical px; the engine applies HiDPI scale and shapes the
571     /// text through its shared buffer cache.
572     // style-audit: opt-out a transparent surface; each notification card is its own plate
573     fn display_list(&mut self, _size: cce_ui::engine::LogicalSize, _scale: f64) -> Option<cce_ui::scene::paint::DisplayList> {
574         use cce_ui::scene::paint::PaintCtx;
575         let mut pc = PaintCtx::new();
576         for (i, notification) in self.stack.iter().take(MAX_VISIBLE).enumerate() {
577             draw_card(&mut pc, &self.plate, notification, Notification::top(i));
578         }
579         Some(pc.finish())
580     }
581 
582     fn display_list_text(&self) -> bool {
583         true
584     }
585 
586     /// Always transparent: the background is the plate drawn in `display_list`, so the
587     /// surface itself stays clear (and rounded plate corners show through).
588     fn clear_color(&self) -> [f32; 4] {
589         [0.0, 0.0, 0.0, 0.0]
590     }
591 
592     /// One region per displayed card, so the gaps between them and the unused
593     /// tail of the tall surface stay click-through. Always `Some`: the engine
594     /// only touches the input region when this returns one, so a `None` here
595     /// would leave the last region set — including the empty one that makes an
596     /// idle stack transparent to clicks.
597     fn input_regions(&self) -> Option<Vec<(i32, i32, i32, i32)>> {
598         Some(
599             (0..self.visible_count())
600                 .map(|i| (0, Notification::top(i) as i32, NOTIF_WIDTH as i32, CARD_H as i32))
601                 .collect(),
602         )
603     }
604 
605     fn register_sources(&mut self, _handle: &calloop::LoopHandle<'_, EngineState<Self>>) {
606         // Run the org.freedesktop.Notifications D-Bus server on a background
607         // thread; incoming Notify calls are forwarded to update() via the channel.
608         let sender = self.sender.clone();
609         std::thread::spawn(move || {
610             let rt = match tokio::runtime::Runtime::new() {
611                 Ok(rt) => rt,
612                 Err(e) => {
613                     log::error!("cce-notifier: failed to start tokio runtime: {e}");
614                     return;
615                 }
616             };
617             rt.block_on(async move {
618                 let dbus_impl = DbusInterface {
619                     sender,
620                     next_id: std::sync::atomic::AtomicU32::new(1),
621                 };
622                 match connection::Builder::session()
623                     .and_then(|b| b.name("org.freedesktop.Notifications"))
624                     .and_then(|b| b.serve_at("/org/freedesktop/Notifications", dbus_impl))
625                 {
626                     Ok(builder) => match builder.build().await {
627                         Ok(_conn) => {
628                             log::info!("cce-notifier: D-Bus listener registered.");
629                             std::future::pending::<()>().await;
630                         }
631                         Err(e) => log::error!("cce-notifier: failed to build D-Bus connection: {e}"),
632                     },
633                     Err(e) => log::error!("cce-notifier: failed to register D-Bus name/path: {e}"),
634                 }
635             });
636         });
637     }
638 
639     // Notifications are non-interactive.
640     fn handle_pointer_move(&mut self, _pos: LogicalPosition, _needs_rebuild: &mut bool) {}
641     fn handle_mouse_input(
642         &mut self,
643         _button: MouseButton,
644         _state: ElementState,
645         _pos: LogicalPosition,
646         _needs_rebuild: &mut bool,
647     ) -> Option<Self::Message> {
648         None
649     }
650     fn handle_mouse_wheel(&mut self, _delta: &MouseScrollDelta, _pos: LogicalPosition, _needs_rebuild: &mut bool) {}
651     fn handle_key_input(&mut self, _event: &KeyEvent, _needs_rebuild: &mut bool) -> Option<Self::Message> {
652         None
653     }
654 }
655 
656 // ── D-Bus (org.freedesktop.Notifications) ─────────────────────────────────
657 
658 struct DbusInterface {
659     sender: calloop::channel::Sender<UserEvent>,
660     /// Ids handed back to clients, so they can later replace or close a
661     /// specific notification. Lives here, not in the app, because `Notify`
662     /// has to return the id synchronously to its caller.
663     next_id: std::sync::atomic::AtomicU32,
664 }
665 
666 #[interface(name = "org.freedesktop.Notifications")]
667 impl DbusInterface {
668     async fn get_capabilities(&self) -> Vec<String> {
669         vec!["body".to_string(), "actions".to_string(), "icon-static".to_string()]
670     }
671 
672     #[allow(clippy::too_many_arguments)]
673     async fn notify(
674         &self,
675         app_name: String,
676         replaces_id: u32,
677         app_icon: String,
678         summary: String,
679         body: String,
680         _actions: Vec<String>,
681         hints: HashMap<String, Value<'_>>,
682         expire_timeout: i32,
683     ) -> u32 {
684         use std::sync::atomic::Ordering;
685         // A client-chosen `replaces_id` is honored as-is (the spec requires the
686         // same id back), so the counter is pushed past it to keep a later
687         // server-assigned id from colliding with a card that is still live.
688         let id = if replaces_id != 0 {
689             self.next_id.fetch_max(replaces_id + 1, Ordering::Relaxed);
690             replaces_id
691         } else {
692             self.next_id.fetch_add(1, Ordering::Relaxed)
693         };
694         // `expire_timeout` is ms; -1 means "server decides". 0 means "never
695         // expire" in the spec, but these cards cannot be clicked away, so it
696         // is treated as the default rather than pinning a slot forever.
697         let duration = match expire_timeout {
698             ms if ms > 0 => ms as f32 / 1000.0,
699             _ => read_duration(),
700         }
701         .max(1.0);
702         // Preview image: the standard `image-path` hint (spec 1.2; `image_path`
703         // is the 1.1 spelling), else an absolute-path app_icon.
704         let hint_str = |key: &str| -> Option<String> {
705             match hints.get(key) {
706                 Some(Value::Str(s)) => Some(s.to_string()),
707                 _ => None,
708             }
709         };
710         let image_path = hint_str("image-path")
711             .or_else(|| hint_str("image_path"))
712             .or_else(|| app_icon.starts_with('/').then(|| app_icon.clone()));
713         let _ = self.sender.send(UserEvent::NewNotification {
714             id,
715             app_name,
716             summary,
717             body,
718             image_path,
719             duration,
720         });
721         id
722     }
723 
724     async fn close_notification(&self, id: u32) {
725         let _ = self.sender.send(UserEvent::CloseNotification { id });
726     }
727 
728     async fn get_server_information(&self) -> (String, String, String, String) {
729         (
730             "cce-notifier".to_string(),
731             "CCEC Project".to_string(),
732             "0.1.0".to_string(),
733             "1.2".to_string(),
734         )
735     }
736 }
737 
738 fn main() {
739     env_logger::init();
740     cce_ui::engine::run::<NotifierApp>();
741 }
742 
743 #[cfg(test)]
744 mod tests {
745     use super::*;
746 
747     /// `BODY_LINES` restates the card's geometry, and `fit_body` trusts it to
748     /// decide where the ellipsis goes. If the card height, the body's top, or
749     /// its font size moves, this is what says so.
750     #[test]
751     fn body_lines_matches_the_card_geometry() {
752         let box_height = CARD_H as f32 - BODY_TOP - CARD_PAD_B;
753         let line_height = BODY_SIZE * 1.4; // the engine's boxed-text line height
754         assert!(
755             line_height * BODY_LINES as f32 <= box_height,
756             "BODY_LINES={BODY_LINES} needs {} of {box_height}",
757             line_height * BODY_LINES as f32
758         );
759         assert!(
760             line_height * (BODY_LINES + 1) as f32 > box_height,
761             "another line fits in {box_height} — BODY_LINES is too small"
762         );
763     }
764 
765     /// A full stack has to land exactly on the surface the layer shell was
766     /// given, since that height is fixed at creation and cannot be renegotiated.
767     #[test]
768     fn a_full_stack_fits_the_surface() {
769         assert_eq!(
770             Notification::top(MAX_VISIBLE - 1) + CARD_H as f32,
771             NOTIF_HEIGHT as f32
772         );
773     }
774 }