notification daemon
git clone https://git.lucas.co/cce-notifier.git
src/main.rs (45.5K)
1 use std::collections::HashMap;
2
3 use zbus::zvariant::Value;
4 use zbus::{connection, interface};
5
6
7 use cce_ui::engine::{
8 Application, EngineState, LayerAnchor, LayerKeyboardInteractivity, LayerKind, LayerSettings,
9 LogicalPosition, WindowSettings,
10 };
11 use cce_ui::widget::{ElementState, KeyEvent, MouseButton, MouseScrollDelta};
12
13 const NOTIF_WIDTH: u32 = 360;
14 const CARD_H: u32 = 100;
15 const CARD_GAP: u32 = 8;
16
17 /// How many notifications are on screen at once. Beyond this they queue: a
18 /// notification only starts its timer once it is displayed, so a burst is read
19 /// in order rather than scrolling past unseen.
20 const MAX_VISIBLE: usize = 5;
21
22 /// A layer surface's size is fixed at creation (the cce-ui engine has no
23 /// runtime resize for one), so the surface is always tall enough for a full
24 /// stack and the unused part is left transparent — and click-through, via the
25 /// per-card `input_regions` below. With no card up there is no surface at all
26 /// (`wants_surface`).
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 /// An app icon (the `app_icon` argument as a theme name or path) is drawn in
35 /// this square instead of the thumbnail box, rasterized at twice its size so
36 /// it stays sharp at output scale 2.
37 const ICON_BOX: f32 = 48.0;
38 const ICON_RASTER_PX: u32 = 96;
39
40 /// Action buttons ride the header row, right-aligned, so a card with actions
41 /// keeps all three body lines.
42 const ACTION_H: f32 = 18.0;
43 const ACTION_TOP: f32 = 6.0;
44 const ACTION_GAP: f32 = 6.0;
45 const ACTION_FONT: f32 = 10.0;
46 const ACTION_PAD_X: f32 = 8.0;
47 /// At most this many buttons, and labels cut to this many chars: the header
48 /// row is the card's width, and the app name needs some of it.
49 const MAX_ACTIONS: usize = 3;
50 const ACTION_LABEL_MAX: usize = 14;
51
52 /// `NotificationClosed` reasons (the spec's numbering).
53 const CLOSED_EXPIRED: u32 = 1;
54 const CLOSED_DISMISSED: u32 = 2;
55 const CLOSED_BY_CALL: u32 = 3;
56
57 // The text column's right and bottom padding, and where the body starts. The
58 // body gets whatever is left of the card below `BODY_TOP`.
59 const CARD_PAD: f32 = 14.0;
60 const CARD_PAD_B: f32 = 6.0;
61 const BODY_TOP: f32 = 46.0;
62 const BODY_SIZE: f32 = 11.0;
63
64 /// Body lines a card has room for: `box_height` (48) over the engine's 1.4 line
65 /// height at [`BODY_SIZE`] (15.4) — three lines, 46.2. Kept as a number because
66 /// the ellipsis fit has to reason about it, and asserted against the geometry
67 /// in the tests below so the two cannot drift.
68 const BODY_LINES: usize = 3;
69
70 /// The image a card shows left of its text, and so how far the text moves.
71 #[derive(Debug, Clone, PartialEq)]
72 enum ImageSource {
73 /// A preview file (`image-path`): PNG, fit to the thumbnail box.
74 Thumbnail(String),
75 /// The app's icon (`app_icon`): a theme name or a path, SVG or PNG,
76 /// resolved by `cce_ui::icon` and drawn in the icon square.
77 Icon(String),
78 }
79
80 impl ImageSource {
81 fn box_w(&self) -> f32 {
82 match self {
83 ImageSource::Thumbnail(_) => THUMB_MAX_W,
84 ImageSource::Icon(_) => ICON_BOX,
85 }
86 }
87
88 /// Decode and upload against the current renderer: (image id, logical
89 /// w, h), fit to this source's box. `None` when nothing decodes.
90 fn upload(&self) -> Option<(u32, f32, f32)> {
91 match self {
92 ImageSource::Thumbnail(path) => load_thumbnail(path)
93 .map(|(pixels, w, h)| (cce_ui::vk::upload_rgba(pixels, w, h), w as f32, h as f32)),
94 ImageSource::Icon(name) => {
95 let (id, w, h) = cce_ui::icon::upload_themed(name, ICON_RASTER_PX)?;
96 let fit = ICON_BOX / w.max(h).max(1) as f32;
97 Some((id, w as f32 * fit, h as f32 * fit))
98 }
99 }
100 }
101 }
102
103 /// Where the text column starts: an image pushes it right of the image's box.
104 fn text_origin(image_box: Option<f32>) -> f32 {
105 match image_box {
106 Some(w) => 14.0 + w + 12.0,
107 None => 18.0,
108 }
109 }
110
111 /// The column the body wraps into — origin to the card's right padding.
112 fn body_wrap_width(image_box: Option<f32>) -> f32 {
113 NOTIF_WIDTH as f32 - text_origin(image_box) - CARD_PAD
114 }
115
116 /// A button's label as drawn: cut with an ellipsis past [`ACTION_LABEL_MAX`].
117 fn action_label(label: &str) -> String {
118 if label.chars().count() <= ACTION_LABEL_MAX {
119 return label.to_string();
120 }
121 let mut s: String = label.chars().take(ACTION_LABEL_MAX - 1).collect();
122 s.truncate(s.trim_end().len());
123 s.push('…');
124 s
125 }
126
127 /// Each button's x and width, right-aligned on the header row, for labels
128 /// measuring `widths` (already label text widths). Right to left in input
129 /// order would reverse them, so the row is laid out left to right from its
130 /// start.
131 fn action_layout(widths: &[f32]) -> Vec<(f32, f32)> {
132 let ws: Vec<f32> = widths.iter().map(|w| w + 2.0 * ACTION_PAD_X).collect();
133 let total: f32 = ws.iter().sum::<f32>() + ACTION_GAP * ws.len().saturating_sub(1) as f32;
134 let mut x = NOTIF_WIDTH as f32 - CARD_PAD - total;
135 ws.iter()
136 .map(|&w| {
137 let at = (x, w);
138 x += w + ACTION_GAP;
139 at
140 })
141 .collect()
142 }
143
144 #[derive(Debug, Clone)]
145 enum UserEvent {
146 NewNotification {
147 /// Server-assigned, or the client's `replaces_id`: an arriving id that
148 /// matches a live card updates it in place instead of stacking.
149 id: u32,
150 app_name: String,
151 summary: String,
152 body: String,
153 image: Option<ImageSource>,
154 /// `(key, label)` pairs from the `actions` argument. `default` is the
155 /// click on the card itself; the rest become buttons.
156 actions: Vec<(String, String)>,
157 /// Seconds, already resolved from the client's `expire_timeout` and
158 /// urgency; `None` stays until it is clicked or closed.
159 duration: Option<f32>,
160 },
161 CloseNotification {
162 id: u32,
163 },
164 }
165
166 // ── Config accessors ──────────────────────────────────────────────────────
167
168 fn read_duration() -> f32 {
169 cce_ui::config::get_i64("/notifications/duration", 5) as f32
170 }
171
172 /// The notification plate style: per-app `plate { }` keys from
173 /// `~/.config/cce/cce-notifier/config.kdl` (merged over the global config by
174 /// `parse_kdl_to_json`), falling back to the shared `style.surface.plate`
175 /// values for anything unset.
176 struct PlateStyle {
177 fill: [f32; 4],
178 border: Option<([f32; 4], f32)>,
179 radius: f32,
180 blur: bool,
181 opacity: f32,
182 }
183
184 fn read_plate_style() -> PlateStyle {
185 let cfg = cce_ui::config::cached_config();
186 let key = |name: &str| cfg.pointer(&format!("/plate/{name}"));
187 let f32_key = |name: &str, default: f32| {
188 key(name).and_then(|v| v.as_f64()).map(|f| f as f32).unwrap_or(default)
189 };
190 // Shared plate colors are stored linear (color.rs gamma-corrects on load),
191 // so the override keys parse linear too.
192 let linear = |name: &str| {
193 key(name)
194 .and_then(|v| v.as_str())
195 .and_then(cce_ui::color::parse_hex_rgba_linear)
196 };
197 let fill = linear("color")
198 .or_else(cce_ui::colors::plate_color)
199 .unwrap_or([
200 cce_ui::colors::srgb_to_linear(0.08),
201 cce_ui::colors::srgb_to_linear(0.08),
202 cce_ui::colors::srgb_to_linear(0.12),
203 1.0,
204 ]);
205 let border = linear("border_color")
206 .or_else(cce_ui::colors::plate_border_color)
207 .map(|c| (c, f32_key("border_thickness", cce_ui::colors::plate_border_thickness())))
208 .filter(|&(_, t)| t > 0.0);
209 PlateStyle {
210 fill,
211 border,
212 radius: f32_key("corner_radius", cce_ui::layout::plate_corner_radius()),
213 blur: key("blur").and_then(|v| v.as_bool()).unwrap_or_else(cce_ui::colors::plate_blur),
214 opacity: f32_key("opacity", cce_ui::layout::plate_opacity()),
215 }
216 }
217
218 fn play_bell_if_configured() {
219 let sound_type = cce_ui::config::get_string("/notifications/bell").unwrap_or_default();
220 let sound_event = match sound_type.as_str() {
221 "bell" => Some("bell"),
222 "dialog" => Some("dialog-information"),
223 "message" => Some("message"),
224 _ => None,
225 };
226 if let Some(event) = sound_event {
227 log::info!("Playing notification sound ({})...", sound_type);
228 let _ = std::process::Command::new("canberra-gtk-play")
229 .arg("-i")
230 .arg(event)
231 .spawn();
232 }
233 }
234
235 /// Decode a PNG and nearest-neighbor downscale it to fit the thumbnail box.
236 /// Returns RGBA8 pixels plus dimensions; None for unreadable/non-PNG files.
237 fn load_thumbnail(path: &str) -> Option<(Vec<u8>, u32, u32)> {
238 let file = std::fs::File::open(path).ok()?;
239 let mut decoder = png::Decoder::new(std::io::BufReader::new(file));
240 decoder.set_transformations(png::Transformations::normalize_to_color8());
241 let mut reader = decoder.read_info().ok()?;
242 let mut buf = vec![0u8; reader.output_buffer_size()];
243 let info = reader.next_frame(&mut buf).ok()?;
244 let (w, h) = (info.width as usize, info.height as usize);
245 let rgba: Vec<u8> = match info.color_type {
246 png::ColorType::Rgba => buf[..w * h * 4].to_vec(),
247 png::ColorType::Rgb => buf[..w * h * 3]
248 .chunks_exact(3)
249 .flat_map(|px| [px[0], px[1], px[2], 255])
250 .collect(),
251 png::ColorType::Grayscale => buf[..w * h].iter().flat_map(|&g| [g, g, g, 255]).collect(),
252 png::ColorType::GrayscaleAlpha => buf[..w * h * 2]
253 .chunks_exact(2)
254 .flat_map(|px| [px[0], px[0], px[0], px[1]])
255 .collect(),
256 _ => return None,
257 };
258
259 let scale = (THUMB_MAX_W / w as f32).min(THUMB_MAX_H / h as f32).min(1.0);
260 let (tw, th) = (
261 ((w as f32 * scale) as usize).max(1),
262 ((h as f32 * scale) as usize).max(1),
263 );
264 let mut thumb = Vec::with_capacity(tw * th * 4);
265 for ty in 0..th {
266 let sy = ty * h / th;
267 for tx in 0..tw {
268 let sx = tx * w / tw;
269 let i = (sy * w + sx) * 4;
270 thumb.extend_from_slice(&rgba[i..i + 4]);
271 }
272 }
273 Some((thumb, tw as u32, th as u32))
274 }
275
276 /// How many lines `text` wraps to in a column `wrap_w` wide.
277 ///
278 /// Measured through the engine's OWN boxed-text layout — the same call the
279 /// renderer makes for a `text_boxed` prim — so the count cannot drift from what
280 /// is actually drawn (its 1.4 line height and the scale factor are applied
281 /// inside). The height is left effectively unbounded on purpose: pass the card's
282 /// real `box_height` and cosmic-text stops shaping at the lines that fit, which
283 /// is exactly the overflow this needs to detect.
284 fn wrapped_line_count(text: &str, wrap_w: f32, font: Option<&str>) -> usize {
285 let Ok(mut fs) = cce_ui::geometry_font_system().lock() else {
286 return 1;
287 };
288 let (buffer, _) = cce_ui::engine::get_text_buffer_laid_out(
289 &mut fs,
290 text,
291 BODY_SIZE,
292 font,
293 cce_ui::scene::paint::TextAttrs::default(),
294 cce_ui::scene::paint::TextLayout {
295 wrap_width: Some(wrap_w),
296 box_height: 10_000.0,
297 align_h: cce_ui::scene::paint::AlignH::Left,
298 align_v: cce_ui::scene::paint::AlignV::Top,
299 },
300 );
301 buffer.layout_runs().count()
302 }
303
304 /// The body as it should be drawn: unchanged when it fits, else cut to the most
305 /// text that still fits [`BODY_LINES`] lines *with* an ellipsis appended.
306 ///
307 /// A card is a fixed height, so a long body is truncated either way — this only
308 /// decides whether the reader can see that it was. Binary search over the char
309 /// prefix, re-measuring each candidate, because where the text wraps (and so
310 /// how much fits) depends on the words themselves, not the character count.
311 /// Called once per notification on arrival, never per frame.
312 fn fit_body(body: &str, wrap_w: f32, font: Option<&str>) -> String {
313 if body.is_empty() || wrapped_line_count(body, wrap_w, font) <= BODY_LINES {
314 return body.to_string();
315 }
316 let chars: Vec<char> = body.chars().collect();
317 let with_ellipsis = |n: usize| -> String {
318 let mut s: String = chars[..n].iter().collect();
319 // Trim first so the ellipsis follows the word, not the space after it.
320 while s.ends_with(char::is_whitespace) {
321 s.pop();
322 }
323 s.push('…');
324 s
325 };
326 // Largest prefix that still fits. `fits(0)` is just the ellipsis, so the
327 // search always has an answer to fall back on.
328 let (mut lo, mut hi) = (0usize, chars.len());
329 while lo < hi {
330 let mid = (lo + hi).div_ceil(2);
331 if wrapped_line_count(&with_ellipsis(mid), wrap_w, font) <= BODY_LINES {
332 lo = mid;
333 } else {
334 hi = mid - 1;
335 }
336 }
337 with_ellipsis(lo)
338 }
339
340 fn srgb_u8(linear: [f32; 4]) -> [u8; 3] {
341 let srgb = cce_ui::colors::to_srgb(linear);
342 [
343 (srgb[0] * 255.0) as u8,
344 (srgb[1] * 255.0) as u8,
345 (srgb[2] * 255.0) as u8,
346 ]
347 }
348
349 /// Paint one notification card with its top edge at `top` in surface-local
350 /// logical px. Everything is offset from there, so a card's position in the
351 /// stack is the only thing that changes between slots.
352 fn draw_card(
353 pc: &mut cce_ui::scene::paint::PaintCtx,
354 plate: &PlateStyle,
355 notification: &Notification,
356 top: f32,
357 ) {
358 use cce_ui::scene::layout::Rect;
359 let card_h = CARD_H as f32;
360 // The plate (per-app `plate { }` keys over the shared plate style;
361 // blur via the negative-alpha marker), replacing the old clear-color background.
362 let surface = Rect { x: 0.0, y: top, width: NOTIF_WIDTH as f32, height: card_h };
363 let radius = plate.radius;
364 let mut fill = plate.fill;
365 fill[3] *= plate.opacity;
366 if plate.blur {
367 fill[3] = -fill[3].abs();
368 }
369 match plate.border {
370 Some((border, thickness)) => {
371 pc.border(surface, (radius, radius, radius, radius), fill, border, thickness)
372 }
373 None => {
374 let on = radius > 0.0;
375 pc.rounded_rect(surface, radius, (on, on, on, on), fill);
376 }
377 }
378 // Preview thumbnail (screenshots etc.) or app icon, centered in its box
379 // left of the text, which shifts right to make room.
380 let image_box = notification.image_box();
381 let text_x = text_origin(image_box);
382 if let (Some((id, w, h)), Some(box_w)) = (notification.image, image_box) {
383 let ix = 14.0 + (box_w - w) / 2.0;
384 let iy = top + (card_h - h) / 2.0;
385 pc.image(id, Rect { x: ix, y: iy, width: w, height: h }, 1.0);
386 }
387 // Use a configured (bundled) font family so glyph font-ids resolve in the
388 // engine's render FontSystem — a bare default can pick a system font absent
389 // from the engine's bundled-only database.
390 let family = cce_ui::layout::statusbar_font_parsed().0;
391 let font = Some(family);
392 // The text column: from `text_x` (which the thumbnail may have pushed right)
393 // to the card's right padding. Every label is bounded by it, so nothing runs
394 // out over the plate's edge and rounded corner.
395 let text_w = body_wrap_width(image_box);
396 let column = |t: f32, b: f32| Some([text_x, top + t, text_x + text_w, top + b]);
397 // The buttons share the header row with the app name, which stops short
398 // of the first one.
399 let buttons = notification.action_rects(top);
400 let name_right = buttons.first().map_or(text_x + text_w, |(r, _)| r.x - ACTION_GAP);
401 pc.text_with(¬ification.app_name, text_x, top + 12.0, 10.0, srgb_u8(cce_ui::colors::TEXT_DIM), font.clone(), Some([text_x, top + 8.0, name_right, top + BODY_TOP]));
402 for (i, (rect, label)) in buttons.iter().enumerate() {
403 let hovered = notification.hovered_action == Some(i);
404 let wash = if hovered { [1.0, 1.0, 1.0, 0.22] } else { [1.0, 1.0, 1.0, 0.10] };
405 pc.rounded_rect(*rect, ACTION_H / 2.0, (true, true, true, true), wash);
406 pc.text_with(
407 label,
408 rect.x + ACTION_PAD_X,
409 rect.y + (ACTION_H - ACTION_FONT) / 2.0 - 1.0,
410 ACTION_FONT,
411 srgb_u8(cce_ui::colors::TEXT_FG),
412 font.clone(),
413 Some([rect.x, rect.y, rect.x + rect.width, rect.y + rect.height]),
414 );
415 }
416 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));
417 // The body word-wraps within that column instead of running off the card.
418 // `box_height` is what bounds it: the engine lays boxed text out at a 1.4
419 // line height, so this admits BODY_LINES 11px lines (46.2 of 48) and shapes
420 // away the rest — a card is a fixed height and cannot grow to fit. Anything
421 // longer was already cut to fit with an ellipsis by `fit_body` on arrival,
422 // so this bound is a backstop, not the truncation.
423 pc.text_boxed(
424 ¬ification.body,
425 text_x,
426 top + BODY_TOP,
427 BODY_SIZE,
428 srgb_u8(cce_ui::colors::TEXT_FG),
429 font,
430 column(BODY_TOP, card_h - CARD_PAD_B),
431 cce_ui::scene::paint::TextAttrs::default(),
432 cce_ui::scene::paint::TextLayout {
433 wrap_width: Some(text_w),
434 box_height: card_h - BODY_TOP - CARD_PAD_B,
435 align_h: cce_ui::scene::paint::AlignH::Left,
436 align_v: cce_ui::scene::paint::AlignV::Top,
437 },
438 );
439 }
440
441 // ── Application ───────────────────────────────────────────────────────────
442 //
443 // Phase 6 shape: the whole frame — plate and text — is one display list
444 // (`display_list` + `display_list_text`); the engine shapes the text through the shared
445 // buffer cache. No app-side FontSystem, TextItem cache, or rebuild bookkeeping.
446
447 struct Notification {
448 id: u32,
449 app_name: String,
450 summary: String,
451 /// Already fitted to the card by [`fit_body`] on arrival — ellipsis and all.
452 body: String,
453 /// Uploaded preview image (id from `vk::upload_rgba`, logical w, h).
454 image: Option<(u32, f32, f32)>,
455 /// Where [`image`] was decoded from, kept so the card can be re-uploaded
456 /// into a replacement renderer — see `NotifierApp::renderer_init`. `None`
457 /// when the notification carried no image, or when it would not decode.
458 ///
459 /// [`image`]: Notification::image
460 image_source: Option<ImageSource>,
461 /// The click on the card answers `default` when the client offered it.
462 has_default: bool,
463 /// Button `(key, label as drawn)`, at most [`MAX_ACTIONS`].
464 actions: Vec<(String, String)>,
465 /// The button under the pointer, for its hover wash.
466 hovered_action: Option<usize>,
467 /// Seconds the card is asked to stay up once it is displayed; `None`
468 /// stays until clicked or closed (critical urgency, `expire_timeout` 0).
469 duration: Option<f32>,
470 /// When the card comes down, set on the first tick it is displayed — so a
471 /// queued notification does not expire before it is ever shown. A wall
472 /// clock, deliberately, not an accumulation of the runner's `dt`: `dt` is
473 /// animation time, clamped to one frame after an idle sleep, and a card
474 /// whose only reason to redraw is its own expiry never leaves that sleep.
475 /// Ageing it by `dt` ran the countdown at 1/60 speed — a 5 s toast sat on
476 /// screen for five minutes.
477 expires_at: Option<std::time::Instant>,
478 }
479
480 impl Notification {
481 fn free_image(&mut self) {
482 if let Some((id, _, _)) = self.image.take() {
483 cce_ui::vk::free_image(id);
484 }
485 }
486
487 /// Re-decode and re-upload the thumbnail against the current renderer.
488 ///
489 /// The stale id is freed first (a free for an id the new renderer never
490 /// had is a no-op), and a file that has since gone away simply leaves the
491 /// card imageless rather than drawing nothing in a reserved box.
492 fn reupload_image(&mut self) {
493 self.free_image();
494 let Some(source) = self.image_source.clone() else { return };
495 match source.upload() {
496 Some(image) => self.image = Some(image),
497 None => {
498 log::warn!("[notifier] {source:?} no longer decodes; the card keeps its text");
499 self.image_source = None;
500 }
501 }
502 }
503
504 /// The text column's image box, when an image actually decoded.
505 fn image_box(&self) -> Option<f32> {
506 self.image.and(self.image_source.as_ref()).map(ImageSource::box_w)
507 }
508
509 /// Each button's rect on a card whose top is `top`, with its label.
510 /// Paint and the click both come through here, so the drawn button is
511 /// the one a press lands on.
512 fn action_rects(&self, top: f32) -> Vec<(cce_ui::scene::layout::Rect, String)> {
513 if self.actions.is_empty() {
514 return Vec::new();
515 }
516 let family = cce_ui::layout::statusbar_font_parsed().0;
517 let widths: Vec<f32> = self
518 .actions
519 .iter()
520 .map(|(_, label)| cce_ui::widget::display::measure_text_width(label, &family, ACTION_FONT))
521 .collect();
522 action_layout(&widths)
523 .into_iter()
524 .zip(&self.actions)
525 .map(|((x, w), (_, label))| {
526 (cce_ui::scene::layout::Rect { x, y: top + ACTION_TOP, width: w, height: ACTION_H }, label.clone())
527 })
528 .collect()
529 }
530
531 fn top(index: usize) -> f32 {
532 index as f32 * (CARD_H + CARD_GAP) as f32
533 }
534 }
535
536 struct NotifierApp {
537 /// Whether a renderer has been handed over yet — the first one is the
538 /// process's own, any later one is a replacement after a reconnect. See
539 /// `renderer_init`.
540 seen_renderer: bool,
541 /// Live notifications, oldest first. The first `MAX_VISIBLE` are drawn top-down
542 /// (so a new one appears below the ones already being read, and cards below an
543 /// expiring one slide up); the rest wait their turn.
544 stack: Vec<Notification>,
545 plate: PlateStyle,
546 sender: calloop::channel::Sender<UserEvent>,
547 /// To the D-Bus thread, which emits `ActionInvoked` / `NotificationClosed`.
548 /// Set in `register_sources`, where that thread starts.
549 signals: Option<tokio::sync::mpsc::UnboundedSender<Signal>>,
550 }
551
552 /// What the UI tells the D-Bus thread to announce.
553 #[derive(Debug)]
554 enum Signal {
555 ActionInvoked(u32, String),
556 Closed(u32, u32),
557 }
558
559 impl NotifierApp {
560 fn visible_count(&self) -> usize {
561 self.stack.len().min(MAX_VISIBLE)
562 }
563
564 fn signal(&self, signal: Signal) {
565 if let Some(tx) = &self.signals {
566 let _ = tx.send(signal);
567 }
568 }
569
570 /// The displayed card under `y` (surface-local), if any.
571 fn card_at(&self, y: f32) -> Option<usize> {
572 let i = (y / (CARD_H + CARD_GAP) as f32).floor();
573 if i < 0.0 {
574 return None;
575 }
576 let i = i as usize;
577 let within = y - Notification::top(i) <= CARD_H as f32;
578 (i < self.visible_count() && within).then_some(i)
579 }
580
581 /// Take card `i` down, answering `action` first when there is one — the
582 /// spec's order: ActionInvoked, then NotificationClosed.
583 fn take_down(&mut self, i: usize, action: Option<String>) {
584 let mut card = self.stack.remove(i);
585 if let Some(key) = action {
586 self.signal(Signal::ActionInvoked(card.id, key));
587 }
588 self.signal(Signal::Closed(card.id, CLOSED_DISMISSED));
589 card.free_image();
590 }
591 }
592
593 impl Application for NotifierApp {
594 type Message = UserEvent;
595
596 /// A systemd user service the compositor does not restore: it waits for
597 /// the next compositor rather than exiting, so org.freedesktop.Notifications
598 /// stays owned across a logout or a compositor restart.
599 fn outlives_compositor(&self) -> bool {
600 true
601 }
602
603 fn create(sender: cce_ui::engine::AppSender<Self::Message>) -> Self {
604 // The app keeps calloop's sender; `AppSender` converts into it.
605 let sender: calloop::channel::Sender<Self::Message> = sender.into();
606 Self {
607 seen_renderer: false,
608 stack: Vec::new(),
609 plate: read_plate_style(),
610 sender,
611 signals: None,
612 }
613 }
614
615 fn settings(&self) -> WindowSettings {
616 WindowSettings {
617 title: "cce-notifier".to_string(),
618 app_id: "cce-notifier".to_string(),
619 width: NOTIF_WIDTH,
620 height: NOTIF_HEIGHT,
621 fullscreen: false,
622 min_size: None,
623 }
624 }
625
626 fn layer(&self) -> Option<LayerSettings> {
627 // Sit below the status modules with one bar-spacing of gap, right edge aligned
628 // with the rightmost top-right module (the clock): the compositor lays the bar
629 // out at y=0, height `layout.bar_height`, flush to `output_width - MARGIN`
630 // with SPACING between segments (cce-window-manager arrange.rs, both 12).
631 let bar_h = cce_ui::config::get_i64("/layout/bar_height", 24) as i32;
632 Some(LayerSettings {
633 layer: LayerKind::Overlay,
634 anchor: LayerAnchor::TOP | LayerAnchor::RIGHT,
635 exclusive_zone: 0,
636 keyboard_interactivity: LayerKeyboardInteractivity::None,
637 margin: (bar_h + 12, 12, 0, 0),
638 namespace: "cce-notifier".to_string(),
639 })
640 }
641
642 fn update(&mut self, msg: Self::Message, needs_rebuild: &mut bool, _exit: &mut bool) {
643 match msg {
644 UserEvent::NewNotification { id, app_name, summary, body, image, actions, duration } => {
645 play_bell_if_configured();
646 let uploaded = image.as_ref().and_then(ImageSource::upload);
647 // Only a source that actually produced a texture: an
648 // unreadable one must not make the re-upload retry it on
649 // every reconnect.
650 let image_source = uploaded.and(image);
651 // Fit the body to the card once, here, rather than per frame:
652 // the column it wraps into depends on whether an image pushed
653 // the text right, which is settled by now.
654 let family = cce_ui::layout::statusbar_font_parsed().0;
655 let image_box = image_source.as_ref().map(ImageSource::box_w);
656 let body = fit_body(&body, body_wrap_width(image_box), Some(&family));
657 let has_default = actions.iter().any(|(k, _)| k == "default");
658 let buttons = actions
659 .into_iter()
660 .filter(|(k, _)| k != "default")
661 .take(MAX_ACTIONS)
662 .map(|(k, label)| (k, action_label(&label)))
663 .collect();
664 let fresh = Notification {
665 id,
666 app_name,
667 summary,
668 body,
669 image: uploaded,
670 image_source,
671 has_default,
672 actions: buttons,
673 hovered_action: None,
674 duration,
675 expires_at: None,
676 };
677 // A repeat of a live id (volume steps, download progress) refreshes that
678 // card where it sits rather than growing the stack.
679 match self.stack.iter().position(|n| n.id == id) {
680 Some(i) => {
681 self.stack[i].free_image();
682 self.stack[i] = fresh;
683 }
684 None => self.stack.push(fresh),
685 }
686 self.plate = read_plate_style();
687 *needs_rebuild = true;
688 }
689 UserEvent::CloseNotification { id } => {
690 if let Some(i) = self.stack.iter().position(|n| n.id == id) {
691 self.stack.remove(i).free_image();
692 self.signal(Signal::Closed(id, CLOSED_BY_CALL));
693 *needs_rebuild = true;
694 }
695 }
696 }
697 }
698
699 fn tick(&mut self, _dt: f32, needs_rebuild: &mut bool) {
700 // Only displayed cards age; queued ones keep their full duration and
701 // start counting on the first tick after a slot frees up.
702 let now = std::time::Instant::now();
703 let visible = self.visible_count();
704 for n in &mut self.stack[..visible] {
705 if let (Some(secs), None) = (n.duration, n.expires_at) {
706 n.expires_at = Some(now + std::time::Duration::from_secs_f32(secs));
707 }
708 }
709 let before = self.stack.len();
710 let mut i = 0;
711 while i < self.stack.len() {
712 if self.stack[i].expires_at.is_some_and(|t| now >= t) {
713 let mut card = self.stack.remove(i);
714 self.signal(Signal::Closed(card.id, CLOSED_EXPIRED));
715 card.free_image();
716 } else {
717 i += 1;
718 }
719 }
720 if self.stack.len() != before {
721 *needs_rebuild = true;
722 }
723 }
724
725 /// Re-upload every live card's thumbnail when the renderer is replaced.
726 ///
727 /// A card holds a **renderer** image id, and a renderer does not outlive
728 /// its session: `cce-ui`'s `window_runner` repairs a lost Wayland
729 /// transport by opening a new session around the same `Application`, which
730 /// rebuilds the renderer and with it the image table. The cached id then
731 /// names an image that no longer exists, and a draw for an unknown id is
732 /// skipped rather than reported — so a card that was on screen across the
733 /// reconnect came back with its text and an empty thumbnail box, and would
734 /// stay that way for the rest of its life, since a card's image is
735 /// uploaded once on arrival and never again.
736 ///
737 /// The layer surface outlives every card, so this is not a
738 /// once-at-startup concern: the stack is whatever happened to be up when
739 /// the transport broke.
740 ///
741 /// Not on the first renderer: no card can exist yet — the D-Bus server is
742 /// only started after the app is built, and its uploads are queued for
743 /// precisely that renderer.
744 fn renderer_init(&mut self, _renderer: &mut cce_ui::vk::VkRenderer) {
745 if !std::mem::replace(&mut self.seen_renderer, true) {
746 return;
747 }
748 let with_images = self.stack.iter().filter(|n| n.image.is_some()).count();
749 if with_images > 0 {
750 log::info!("[notifier] renderer replaced; re-uploading {with_images} card thumbnail(s)");
751 }
752 for n in &mut self.stack {
753 n.reupload_image();
754 }
755 }
756
757 /// A live card's only pending work is its own expiry, which the runner
758 /// cannot see: nothing redraws, so the loop parks on the default idle
759 /// sleep and the card outstays its welcome by up to a second. Sleep until
760 /// the soonest displayed card is due (it was a flat 100 ms poll until
761 /// 2026-10-05), and go fully idle the moment the stack empties. A card
762 /// not yet stamped by `tick` gets its deadline on the next turn.
763 fn idle_poll_interval(&self) -> Option<std::time::Duration> {
764 if self.stack.is_empty() {
765 return None;
766 }
767 let now = std::time::Instant::now();
768 // A sticky card (no duration) has nothing to wait for; one not yet
769 // stamped is due now, so `tick` stamps it.
770 let soonest = self.stack[..self.visible_count()]
771 .iter()
772 .filter(|n| n.duration.is_some())
773 .map(|n| n.expires_at.map_or(std::time::Duration::ZERO, |t| t.saturating_duration_since(now)))
774 .min()?;
775 Some(soonest.max(std::time::Duration::from_millis(10)))
776 }
777
778 /// Mapped only while a card is up: an empty, transparent overlay still
779 /// made the compositor blur behind it whenever anything under the corner
780 /// changed, and kept fullscreen clients off direct scanout.
781 /// The runner keeps its renderer across the gap and moves it onto the
782 /// next surface, so thumbnail ids stay good and `renderer_init` does not
783 /// run for it.
784 fn wants_surface(&self) -> bool {
785 !self.stack.is_empty()
786 }
787
788 /// The whole frame as one display list (Phase 6): the plate plus the three
789 /// text lines. Coordinates are logical px; the engine applies HiDPI scale and shapes the
790 /// text through its shared buffer cache.
791 // style-audit: opt-out a transparent surface; each notification card is its own plate
792 fn display_list(&mut self, _size: cce_ui::engine::LogicalSize, _scale: f64) -> Option<cce_ui::scene::paint::DisplayList> {
793 use cce_ui::scene::paint::PaintCtx;
794 let mut pc = PaintCtx::new();
795 for (i, notification) in self.stack.iter().take(MAX_VISIBLE).enumerate() {
796 draw_card(&mut pc, &self.plate, notification, Notification::top(i));
797 }
798 Some(pc.finish())
799 }
800
801 fn display_list_text(&self) -> bool {
802 true
803 }
804
805 /// Always transparent: the background is the plate drawn in `display_list`, so the
806 /// surface itself stays clear (and rounded plate corners show through).
807 fn clear_color(&self) -> [f32; 4] {
808 [0.0, 0.0, 0.0, 0.0]
809 }
810
811 /// One region per displayed card, so the gaps between them and the unused
812 /// tail of the tall surface stay click-through. Always `Some`: the engine
813 /// only touches the input region when this returns one, so a `None` here
814 /// would leave the last region set — including the empty one that makes an
815 /// idle stack transparent to clicks.
816 fn input_regions(&self) -> Option<Vec<(i32, i32, i32, i32)>> {
817 Some(
818 (0..self.visible_count())
819 .map(|i| (0, Notification::top(i) as i32, NOTIF_WIDTH as i32, CARD_H as i32))
820 .collect(),
821 )
822 }
823
824 fn register_sources(&mut self, _handle: &calloop::LoopHandle<'_, EngineState<Self>>) {
825 // Run the org.freedesktop.Notifications D-Bus server on a background
826 // thread; incoming Notify calls are forwarded to update() via the channel.
827 let sender = self.sender.clone();
828 let (signals, mut outbox) = tokio::sync::mpsc::unbounded_channel::<Signal>();
829 self.signals = Some(signals);
830 std::thread::spawn(move || {
831 let rt = match tokio::runtime::Runtime::new() {
832 Ok(rt) => rt,
833 Err(e) => {
834 log::error!("cce-notifier: failed to start tokio runtime: {e}");
835 return;
836 }
837 };
838 rt.block_on(async move {
839 let dbus_impl = DbusInterface {
840 sender,
841 next_id: std::sync::atomic::AtomicU32::new(1),
842 };
843 match connection::Builder::session()
844 .and_then(|b| b.name("org.freedesktop.Notifications"))
845 .and_then(|b| b.serve_at("/org/freedesktop/Notifications", dbus_impl))
846 {
847 Ok(builder) => match builder.build().await {
848 Ok(conn) => {
849 log::info!("cce-notifier: D-Bus listener registered.");
850 // The UI's clicks and expiries, as the spec's signals.
851 let Ok(emitter) = zbus::object_server::SignalEmitter::new(&conn, "/org/freedesktop/Notifications") else {
852 std::future::pending::<()>().await;
853 return;
854 };
855 while let Some(signal) = outbox.recv().await {
856 let sent = match &signal {
857 Signal::ActionInvoked(id, key) => DbusInterface::action_invoked(&emitter, *id, key).await,
858 Signal::Closed(id, reason) => DbusInterface::notification_closed(&emitter, *id, *reason).await,
859 };
860 if let Err(e) = sent {
861 log::warn!("cce-notifier: emitting {signal:?}: {e}");
862 }
863 }
864 std::future::pending::<()>().await;
865 }
866 Err(e) => log::error!("cce-notifier: failed to build D-Bus connection: {e}"),
867 },
868 Err(e) => log::error!("cce-notifier: failed to register D-Bus name/path: {e}"),
869 }
870 });
871 });
872 }
873
874 /// The hover wash on a card's buttons.
875 fn handle_pointer_move(&mut self, pos: LogicalPosition, needs_rebuild: &mut bool) {
876 let (x, y) = (pos.x as f32, pos.y as f32);
877 let card = self.card_at(y);
878 for (i, n) in self.stack.iter_mut().take(MAX_VISIBLE).enumerate() {
879 let hit = (card == Some(i))
880 .then(|| n.action_rects(Notification::top(i)).iter().position(|(r, _)| r.contains(x, y)))
881 .flatten();
882 if n.hovered_action != hit {
883 n.hovered_action = hit;
884 *needs_rebuild = true;
885 }
886 }
887 }
888
889 /// A click takes a card down. On a button it answers that action; on the
890 /// card it answers `default` when the client offered one, else it is a
891 /// plain dismissal. A right click only dismisses.
892 fn handle_mouse_input(
893 &mut self,
894 button: MouseButton,
895 state: ElementState,
896 pos: LogicalPosition,
897 needs_rebuild: &mut bool,
898 ) -> Option<Self::Message> {
899 if state != ElementState::Released {
900 return None;
901 }
902 let (x, y) = (pos.x as f32, pos.y as f32);
903 let i = self.card_at(y)?;
904 let action = match button {
905 MouseButton::Left => {
906 let card = &self.stack[i];
907 let on_button = card
908 .action_rects(Notification::top(i))
909 .iter()
910 .position(|(r, _)| r.contains(x, y))
911 .map(|b| card.actions[b].0.clone());
912 on_button.or_else(|| card.has_default.then(|| "default".to_string()))
913 }
914 MouseButton::Right => None,
915 _ => return None,
916 };
917 self.take_down(i, action);
918 *needs_rebuild = true;
919 None
920 }
921 fn handle_mouse_wheel(&mut self, _delta: &MouseScrollDelta, _pos: LogicalPosition, _needs_rebuild: &mut bool) {}
922 fn handle_key_input(&mut self, _event: &KeyEvent, _needs_rebuild: &mut bool) -> Option<Self::Message> {
923 None
924 }
925 }
926
927 // ── D-Bus (org.freedesktop.Notifications) ─────────────────────────────────
928
929 struct DbusInterface {
930 sender: calloop::channel::Sender<UserEvent>,
931 /// Ids handed back to clients, so they can later replace or close a
932 /// specific notification. Lives here, not in the app, because `Notify`
933 /// has to return the id synchronously to its caller.
934 next_id: std::sync::atomic::AtomicU32,
935 }
936
937 #[interface(name = "org.freedesktop.Notifications")]
938 impl DbusInterface {
939 async fn get_capabilities(&self) -> Vec<String> {
940 vec!["body".to_string(), "actions".to_string(), "icon-static".to_string(), "persistence".to_string()]
941 }
942
943 #[allow(clippy::too_many_arguments)]
944 async fn notify(
945 &self,
946 app_name: String,
947 replaces_id: u32,
948 app_icon: String,
949 summary: String,
950 body: String,
951 actions: Vec<String>,
952 hints: HashMap<String, Value<'_>>,
953 expire_timeout: i32,
954 ) -> u32 {
955 use std::sync::atomic::Ordering;
956 // A client-chosen `replaces_id` is honored as-is (the spec requires the
957 // same id back), so the counter is pushed past it to keep a later
958 // server-assigned id from colliding with a card that is still live.
959 let id = if replaces_id != 0 {
960 self.next_id.fetch_max(replaces_id + 1, Ordering::Relaxed);
961 replaces_id
962 } else {
963 self.next_id.fetch_add(1, Ordering::Relaxed)
964 };
965 // `expire_timeout` is ms; -1 means "server decides", 0 "never". A
966 // critical notification left to the server also stays: a card can be
967 // clicked away now, so neither pins a slot for good.
968 let urgency = match hints.get("urgency") {
969 Some(Value::U8(u)) => *u,
970 _ => 1,
971 };
972 let duration = resolve_duration(expire_timeout, urgency, read_duration());
973 // Preview image: the standard `image-path` hint (spec 1.2; `image_path`
974 // is the 1.1 spelling), else an absolute-path app_icon.
975 let hint_str = |key: &str| -> Option<String> {
976 match hints.get(key) {
977 Some(Value::Str(s)) => Some(s.to_string()),
978 _ => None,
979 }
980 };
981 let image = image_source(hint_str("image-path").or_else(|| hint_str("image_path")), &app_icon);
982 let _ = self.sender.send(UserEvent::NewNotification {
983 id,
984 app_name,
985 summary,
986 body,
987 image,
988 actions: action_pairs(&actions),
989 duration,
990 });
991 id
992 }
993
994 async fn close_notification(&self, id: u32) {
995 let _ = self.sender.send(UserEvent::CloseNotification { id });
996 }
997
998 #[zbus(signal)]
999 async fn action_invoked(emitter: &zbus::object_server::SignalEmitter<'_>, id: u32, action_key: &str) -> zbus::Result<()>;
1000
1001 #[zbus(signal)]
1002 async fn notification_closed(emitter: &zbus::object_server::SignalEmitter<'_>, id: u32, reason: u32) -> zbus::Result<()>;
1003
1004 async fn get_server_information(&self) -> (String, String, String, String) {
1005 (
1006 "cce-notifier".to_string(),
1007 "CCEC Project".to_string(),
1008 "0.1.0".to_string(),
1009 "1.2".to_string(),
1010 )
1011 }
1012 }
1013
1014 /// `actions` is a flat `[key, label, key, label, …]` list; an odd tail is
1015 /// dropped.
1016 fn action_pairs(actions: &[String]) -> Vec<(String, String)> {
1017 actions.chunks_exact(2).map(|kv| (kv[0].clone(), kv[1].clone())).collect()
1018 }
1019
1020 /// Seconds on screen, or `None` to stay until clicked: `expire_timeout`
1021 /// (ms; 0 never, -1 the server's choice) first, then urgency 2 (critical)
1022 /// keeps a server's-choice card up.
1023 fn resolve_duration(expire_timeout: i32, urgency: u8, default_secs: f32) -> Option<f32> {
1024 match expire_timeout {
1025 ms if ms > 0 => Some((ms as f32 / 1000.0).max(1.0)),
1026 0 => None,
1027 _ if urgency >= 2 => None,
1028 _ => Some(default_secs.max(1.0)),
1029 }
1030 }
1031
1032 /// The card's image: a preview file wins (screenshots carry one), else the
1033 /// `app_icon` — a theme name or a path, which `cce_ui::icon` resolves either
1034 /// way. An `app_icon` path to a PNG used to be the only icon shown.
1035 fn image_source(image_path: Option<String>, app_icon: &str) -> Option<ImageSource> {
1036 match image_path {
1037 Some(path) if !path.is_empty() => Some(ImageSource::Thumbnail(path.trim_start_matches("file://").to_string())),
1038 _ if !app_icon.is_empty() => Some(ImageSource::Icon(app_icon.trim_start_matches("file://").to_string())),
1039 _ => None,
1040 }
1041 }
1042
1043 fn main() {
1044 env_logger::init();
1045 cce_ui::engine::run::<NotifierApp>();
1046 }
1047
1048 #[cfg(test)]
1049 mod tests {
1050 use super::*;
1051
1052 /// `BODY_LINES` restates the card's geometry, and `fit_body` trusts it to
1053 /// decide where the ellipsis goes. If the card height, the body's top, or
1054 /// its font size moves, this is what says so.
1055 #[test]
1056 fn body_lines_matches_the_card_geometry() {
1057 let box_height = CARD_H as f32 - BODY_TOP - CARD_PAD_B;
1058 let line_height = BODY_SIZE * 1.4; // the engine's boxed-text line height
1059 assert!(
1060 line_height * BODY_LINES as f32 <= box_height,
1061 "BODY_LINES={BODY_LINES} needs {} of {box_height}",
1062 line_height * BODY_LINES as f32
1063 );
1064 assert!(
1065 line_height * (BODY_LINES + 1) as f32 > box_height,
1066 "another line fits in {box_height} — BODY_LINES is too small"
1067 );
1068 }
1069
1070 #[test]
1071 fn actions_pair_up_and_default_is_the_card() {
1072 let raw: Vec<String> = ["default", "Open", "reply", "Reply", "dangling"].iter().map(|s| s.to_string()).collect();
1073 assert_eq!(
1074 action_pairs(&raw),
1075 [("default".to_string(), "Open".to_string()), ("reply".to_string(), "Reply".to_string())]
1076 );
1077 }
1078
1079 #[test]
1080 fn durations_honor_timeout_then_urgency() {
1081 assert_eq!(resolve_duration(3000, 1, 5.0), Some(3.0));
1082 assert_eq!(resolve_duration(200, 1, 5.0), Some(1.0), "floored at a second");
1083 assert_eq!(resolve_duration(0, 1, 5.0), None, "0 never expires");
1084 assert_eq!(resolve_duration(-1, 2, 5.0), None, "critical stays");
1085 assert_eq!(resolve_duration(4000, 2, 5.0), Some(4.0), "an explicit timeout wins");
1086 assert_eq!(resolve_duration(-1, 1, 5.0), Some(5.0));
1087 }
1088
1089 #[test]
1090 fn image_source_prefers_the_preview_then_the_icon() {
1091 assert_eq!(image_source(Some("/tmp/shot.png".into()), "firefox"), Some(ImageSource::Thumbnail("/tmp/shot.png".into())));
1092 assert_eq!(image_source(None, "firefox"), Some(ImageSource::Icon("firefox".into())));
1093 assert_eq!(image_source(Some(String::new()), "file:///x/a.svg"), Some(ImageSource::Icon("/x/a.svg".into())));
1094 assert_eq!(image_source(None, ""), None);
1095 }
1096
1097 #[test]
1098 fn buttons_right_align_inside_the_padding() {
1099 let row = action_layout(&[30.0, 40.0]);
1100 let (last_x, last_w) = row[1];
1101 assert!((last_x + last_w - (NOTIF_WIDTH as f32 - CARD_PAD)).abs() < 0.01);
1102 assert!((row[1].0 - (row[0].0 + row[0].1) - ACTION_GAP).abs() < 0.01);
1103 assert_eq!(action_label("Mark as read and archive"), "Mark as read…");
1104 assert_eq!(action_label("Reply"), "Reply");
1105 }
1106
1107 /// A full stack has to land exactly on the surface the layer shell was
1108 /// given, since that height is fixed at creation and cannot be renegotiated.
1109 #[test]
1110 fn a_full_stack_fits_the_surface() {
1111 assert_eq!(
1112 Notification::top(MAX_VISIBLE - 1) + CARD_H as f32,
1113 NOTIF_HEIGHT as f32
1114 );
1115 }
1116 }