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(¬ification.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(¬ification.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 ¬ification.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 }