git.lucas.co / cce-notifier
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(&notification.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(&notification.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         &notification.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 }