notification daemon
git clone https://git.lucas.co/cce-notifier.git
feat: stack notifications instead of overwriting one card
A second Notify used to overwrite whatever was on screen, so anything
arriving during a burst was lost unread. Notifications now stack: up to
MAX_VISIBLE cards are drawn top-down, each ageing on its own timer, and
the ones below an expiring card slide up into its slot.
A layer surface's size is fixed at creation — the cce-ui engine has no
runtime resize for one — so the surface is always tall enough for a full
stack and the unused part is left transparent. That makes the input
region load-bearing: it is now one rect per displayed card, so the gaps
between cards and the empty tail fall through to whatever is behind.
It is also always Some, because the engine only touches the input region
when this returns one, and a None would strand the last region set.
Stacking is what makes the id bookkeeping matter, so it comes with it:
- Notify returns a real id (and honors replaces_id), so a client that
repeats itself — volume steps, download progress — updates its card in
place instead of growing the stack a card at a time. Every call used to
return 1. The counter is pushed past any client-chosen id so a later
server-assigned one cannot collide with a live card.
- CloseNotification closes that id rather than whatever is showing.
- expire_timeout is honored per notification instead of every card taking
the configured duration. Spec-wise 0 means "never expire", but these
cards cannot be clicked away, so it is treated as the default rather
than pinning a slot forever.
Past MAX_VISIBLE, notifications queue: a card only ages while displayed,
so a burst is read in order rather than scrolling past unseen.
Verified in a headless shadow session at scale 1 and 2: three arrivals
stack, three volume steps collapse onto one card, a burst of seven shows
five and promotes the held-back two as slots free.
src/main.rs | 280 ++++++++++++++++++++++++++++++++++++++++--------------------
1 file changed, 188 insertions(+), 92 deletions(-)
diff --git a/src/main.rs b/src/main.rs
index d193a88..8c02353 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -12,7 +12,19 @@ use cce_ui::engine::{
use cce_ui::widget::{ElementState, KeyEvent, MouseButton, MouseScrollDelta};
const NOTIF_WIDTH: u32 = 360;
-const NOTIF_HEIGHT: u32 = 100;
+const CARD_H: u32 = 100;
+const CARD_GAP: u32 = 8;
+
+/// How many notifications are on screen at once. Beyond this they queue: a
+/// notification only starts its timer once it is displayed, so a burst is read
+/// in order rather than scrolling past unseen.
+const MAX_VISIBLE: usize = 5;
+
+/// A layer surface's size is fixed at creation (the cce-ui engine has no
+/// runtime resize for one), so the surface is always tall enough for a full
+/// stack and the unused part is left transparent — and click-through, via the
+/// per-card `input_regions` below.
+const NOTIF_HEIGHT: u32 = MAX_VISIBLE as u32 * (CARD_H + CARD_GAP) - CARD_GAP;
// Image previews (the freedesktop `image-path` hint, e.g. screenshots) fit
// this box, aspect-preserved, left of the text.
@@ -22,12 +34,19 @@ const THUMB_MAX_H: f32 = 76.0;
#[derive(Debug, Clone)]
enum UserEvent {
NewNotification {
+ /// Server-assigned, or the client's `replaces_id`: an arriving id that
+ /// matches a live card updates it in place instead of stacking.
+ id: u32,
app_name: String,
summary: String,
body: String,
image_path: Option<String>,
+ /// Seconds, already resolved from the client's `expire_timeout`.
+ duration: f32,
+ },
+ CloseNotification {
+ id: u32,
},
- CloseNotification,
}
// ── Config accessors ──────────────────────────────────────────────────────
@@ -153,30 +172,103 @@ fn srgb_u8(linear: [f32; 4]) -> [u8; 3] {
]
}
+/// Paint one notification card with its top edge at `top` in surface-local
+/// logical px. Everything is offset from there, so a card's position in the
+/// stack is the only thing that changes between slots.
+fn draw_card(
+ pc: &mut cce_ui::scene::paint::PaintCtx,
+ plate: &PlateStyle,
+ notification: &Notification,
+ top: f32,
+) {
+ use cce_ui::scene::layout::Rect;
+ let card_h = CARD_H as f32;
+ // The backplate (per-app `backplate { }` keys over the shared plate style;
+ // blur via the negative-alpha marker), replacing the old clear-color background.
+ let surface = Rect { x: 0.0, y: top, width: NOTIF_WIDTH as f32, height: card_h };
+ let radius = plate.radius;
+ let mut fill = plate.fill;
+ fill[3] *= plate.opacity;
+ if plate.blur {
+ fill[3] = -fill[3].abs();
+ }
+ match plate.border {
+ Some((border, thickness)) => {
+ pc.border(surface, (radius, radius, radius, radius), fill, border, thickness)
+ }
+ None => {
+ let on = radius > 0.0;
+ pc.rounded_rect(surface, radius, (on, on, on, on), fill);
+ }
+ }
+ pc.clip_rounded(surface, radius, |pc| {
+ pc.quad(
+ Rect { x: 0.0, y: top, width: 6.0, height: card_h },
+ cce_ui::colors::TOGGLE_ON,
+ );
+ });
+ // Preview thumbnail (screenshots etc.) centered in its box left of
+ // the text, which shifts right to make room.
+ let mut text_x = 18.0;
+ if let Some((id, w, h)) = notification.image {
+ let ix = 14.0 + (THUMB_MAX_W - w) / 2.0;
+ let iy = top + (card_h - h) / 2.0;
+ pc.image(id, Rect { x: ix, y: iy, width: w, height: h }, 1.0);
+ text_x = 14.0 + THUMB_MAX_W + 12.0;
+ }
+ // Use a configured (bundled) font family so glyph font-ids resolve in the
+ // engine's render FontSystem — a bare default can pick a system font absent
+ // from the engine's bundled-only database.
+ let family = cce_ui::layout::statusbar_font_parsed().0;
+ let font = Some(family);
+ pc.text_with(¬ification.app_name, text_x, top + 12.0, 10.0, srgb_u8(cce_ui::colors::TEXT_DIM), font.clone(), None);
+ pc.text_with(¬ification.summary, text_x, top + 28.0, 13.0, srgb_u8(cce_ui::colors::TEXT_HEADER), font.clone(), None);
+ pc.text_with(¬ification.body, text_x, top + 48.0, 11.0, srgb_u8(cce_ui::colors::TEXT_FG), font, None);
+}
+
// ── Application ───────────────────────────────────────────────────────────
//
// Phase 6 shape: the whole frame — accent quad and text — is one display list
// (`display_list` + `display_list_text`); the engine shapes the text through the shared
// buffer cache. No app-side FontSystem, TextItem cache, or rebuild bookkeeping.
-struct NotifierApp {
+struct Notification {
+ id: u32,
app_name: String,
summary: String,
body: String,
/// Uploaded preview image (id from `vk::upload_rgba`, logical w, h).
image: Option<(u32, f32, f32)>,
- visible: bool,
- dismiss_timer: f32,
- plate: PlateStyle,
- sender: calloop::channel::Sender<UserEvent>,
+ /// Seconds left on screen. Only counts down while the card is displayed,
+ /// so a queued notification does not expire before it is ever shown.
+ remaining: f32,
}
-impl NotifierApp {
+impl Notification {
fn free_image(&mut self) {
if let Some((id, _, _)) = self.image.take() {
cce_ui::vk::free_image(id);
}
}
+
+ fn top(index: usize) -> f32 {
+ index as f32 * (CARD_H + CARD_GAP) as f32
+ }
+}
+
+struct NotifierApp {
+ /// Live notifications, oldest first. The first `MAX_VISIBLE` are drawn top-down
+ /// (so a new one appears below the ones already being read, and cards below an
+ /// expiring one slide up); the rest wait their turn.
+ stack: Vec<Notification>,
+ plate: PlateStyle,
+ sender: calloop::channel::Sender<UserEvent>,
+}
+
+impl NotifierApp {
+ fn visible_count(&self) -> usize {
+ self.stack.len().min(MAX_VISIBLE)
+ }
}
impl Application for NotifierApp {
@@ -184,12 +276,7 @@ impl Application for NotifierApp {
fn new(_qh: &QueueHandle<EngineState<Self>>, sender: calloop::channel::Sender<Self::Message>) -> Self {
Self {
- app_name: String::new(),
- summary: String::new(),
- body: String::new(),
- image: None,
- visible: false,
- dismiss_timer: 0.0,
+ stack: Vec::new(),
plate: read_plate_style(),
sender,
}
@@ -224,91 +311,65 @@ impl Application for NotifierApp {
fn update(&mut self, msg: Self::Message, needs_rebuild: &mut bool, _exit: &mut bool) {
match msg {
- UserEvent::NewNotification { app_name, summary, body, image_path } => {
+ UserEvent::NewNotification { id, app_name, summary, body, image_path, duration } => {
play_bell_if_configured();
- self.app_name = app_name;
- self.summary = summary;
- self.body = body;
- self.free_image();
- if let Some(path) = image_path {
- if let Some((pixels, w, h)) = load_thumbnail(&path) {
- let id = cce_ui::vk::upload_rgba(pixels, w, h);
- self.image = Some((id, w as f32, h as f32));
+ let image = image_path
+ .as_deref()
+ .and_then(load_thumbnail)
+ .map(|(pixels, w, h)| {
+ (cce_ui::vk::upload_rgba(pixels, w, h), w as f32, h as f32)
+ });
+ let fresh = Notification { id, app_name, summary, body, image, remaining: duration };
+ // A repeat of a live id (volume steps, download progress) refreshes that
+ // card where it sits rather than growing the stack.
+ match self.stack.iter().position(|n| n.id == id) {
+ Some(i) => {
+ self.stack[i].free_image();
+ self.stack[i] = fresh;
}
+ None => self.stack.push(fresh),
}
self.plate = read_plate_style();
- self.visible = true;
- self.dismiss_timer = read_duration();
*needs_rebuild = true;
}
- UserEvent::CloseNotification => {
- self.visible = false;
- self.free_image();
- *needs_rebuild = true;
+ UserEvent::CloseNotification { id } => {
+ if let Some(i) = self.stack.iter().position(|n| n.id == id) {
+ self.stack.remove(i).free_image();
+ *needs_rebuild = true;
+ }
}
}
}
fn tick(&mut self, dt: f32, needs_rebuild: &mut bool) {
- if self.visible {
- self.dismiss_timer -= dt;
- if self.dismiss_timer <= 0.0 {
- self.visible = false;
- self.free_image();
- *needs_rebuild = true;
+ // Only displayed cards age; queued ones keep their full duration and
+ // start counting when a slot frees up.
+ let visible = self.visible_count();
+ for n in &mut self.stack[..visible] {
+ n.remaining -= dt;
+ }
+ let before = self.stack.len();
+ let mut i = 0;
+ while i < self.stack.len() {
+ if self.stack[i].remaining <= 0.0 {
+ self.stack.remove(i).free_image();
+ } else {
+ i += 1;
}
}
+ if self.stack.len() != before {
+ *needs_rebuild = true;
+ }
}
/// The whole frame as one display list (Phase 6): the green accent border plus the three
/// text lines. Coordinates are logical px; the engine applies HiDPI scale and shapes the
/// text through its shared buffer cache.
fn display_list(&mut self, _size: cce_ui::engine::LogicalSize, _scale: f64) -> Option<cce_ui::scene::paint::DisplayList> {
- use cce_ui::scene::layout::Rect;
use cce_ui::scene::paint::PaintCtx;
let mut pc = PaintCtx::new();
- if self.visible {
- // The backplate (per-app `backplate { }` keys over the shared plate style;
- // blur via the negative-alpha marker), replacing the old clear-color background.
- let surface = Rect { x: 0.0, y: 0.0, width: NOTIF_WIDTH as f32, height: NOTIF_HEIGHT as f32 };
- let radius = self.plate.radius;
- let mut fill = self.plate.fill;
- fill[3] *= self.plate.opacity;
- if self.plate.blur {
- fill[3] = -fill[3].abs();
- }
- match self.plate.border {
- Some((border, thickness)) => {
- pc.border(surface, (radius, radius, radius, radius), fill, border, thickness)
- }
- None => {
- let on = radius > 0.0;
- pc.rounded_rect(surface, radius, (on, on, on, on), fill);
- }
- }
- pc.clip_rounded(surface, radius, |pc| {
- pc.quad(
- Rect { x: 0.0, y: 0.0, width: 6.0, height: NOTIF_HEIGHT as f32 },
- cce_ui::colors::TOGGLE_ON,
- );
- });
- // Preview thumbnail (screenshots etc.) centered in its box left of
- // the text, which shifts right to make room.
- let mut text_x = 18.0;
- if let Some((id, w, h)) = self.image {
- let ix = 14.0 + (THUMB_MAX_W - w) / 2.0;
- let iy = (NOTIF_HEIGHT as f32 - h) / 2.0;
- pc.image(id, Rect { x: ix, y: iy, width: w, height: h }, 1.0);
- text_x = 14.0 + THUMB_MAX_W + 12.0;
- }
- // Use a configured (bundled) font family so glyph font-ids resolve in the
- // engine's render FontSystem — a bare default can pick a system font absent
- // from the engine's bundled-only database.
- let family = cce_ui::layout::statusbar_font_parsed().0;
- let font = Some(family);
- pc.text_with(&self.app_name, text_x, 12.0, 10.0, srgb_u8(cce_ui::colors::TEXT_DIM), font.clone(), None);
- pc.text_with(&self.summary, text_x, 28.0, 13.0, srgb_u8(cce_ui::colors::TEXT_HEADER), font.clone(), None);
- pc.text_with(&self.body, text_x, 48.0, 11.0, srgb_u8(cce_ui::colors::TEXT_FG), font, None);
+ for (i, notification) in self.stack.iter().take(MAX_VISIBLE).enumerate() {
+ draw_card(&mut pc, &self.plate, notification, Notification::top(i));
}
Some(pc.finish())
}
@@ -323,14 +384,17 @@ impl Application for NotifierApp {
[0.0, 0.0, 0.0, 0.0]
}
- /// When hidden, drop the input region so the transparent overlay is
- /// click-through; when visible, take input over the notification area.
+ /// One region per displayed card, so the gaps between them and the unused
+ /// tail of the tall surface stay click-through. Always `Some`: the engine
+ /// only touches the input region when this returns one, so a `None` here
+ /// would leave the last region set — including the empty one that makes an
+ /// idle stack transparent to clicks.
fn input_regions(&self) -> Option<Vec<(i32, i32, i32, i32)>> {
- if self.visible {
- None
- } else {
- Some(Vec::new())
- }
+ Some(
+ (0..self.visible_count())
+ .map(|i| (0, Notification::top(i) as i32, NOTIF_WIDTH as i32, CARD_H as i32))
+ .collect(),
+ )
}
fn register_sources(&mut self, _handle: &calloop::LoopHandle<'_, EngineState<Self>>) {
@@ -346,7 +410,10 @@ impl Application for NotifierApp {
}
};
rt.block_on(async move {
- let dbus_impl = DbusInterface { sender };
+ let dbus_impl = DbusInterface {
+ sender,
+ next_id: std::sync::atomic::AtomicU32::new(1),
+ };
match connection::Builder::session()
.and_then(|b| b.name("org.freedesktop.Notifications"))
.and_then(|b| b.serve_at("/org/freedesktop/Notifications", dbus_impl))
@@ -385,6 +452,10 @@ impl Application for NotifierApp {
struct DbusInterface {
sender: calloop::channel::Sender<UserEvent>,
+ /// Ids handed back to clients, so they can later replace or close a
+ /// specific notification. Lives here, not in the app, because `Notify`
+ /// has to return the id synchronously to its caller.
+ next_id: std::sync::atomic::AtomicU32,
}
#[interface(name = "org.freedesktop.Notifications")]
@@ -397,14 +468,32 @@ impl DbusInterface {
async fn notify(
&self,
app_name: String,
- _replaces_id: u32,
+ replaces_id: u32,
app_icon: String,
summary: String,
body: String,
_actions: Vec<String>,
hints: HashMap<String, Value<'_>>,
- _expire_timeout: i32,
+ expire_timeout: i32,
) -> u32 {
+ use std::sync::atomic::Ordering;
+ // A client-chosen `replaces_id` is honored as-is (the spec requires the
+ // same id back), so the counter is pushed past it to keep a later
+ // server-assigned id from colliding with a card that is still live.
+ let id = if replaces_id != 0 {
+ self.next_id.fetch_max(replaces_id + 1, Ordering::Relaxed);
+ replaces_id
+ } else {
+ self.next_id.fetch_add(1, Ordering::Relaxed)
+ };
+ // `expire_timeout` is ms; -1 means "server decides". 0 means "never
+ // expire" in the spec, but these cards cannot be clicked away, so it
+ // is treated as the default rather than pinning a slot forever.
+ let duration = match expire_timeout {
+ ms if ms > 0 => ms as f32 / 1000.0,
+ _ => read_duration(),
+ }
+ .max(1.0);
// Preview image: the standard `image-path` hint (spec 1.2; `image_path`
// is the 1.1 spelling), else an absolute-path app_icon.
let hint_str = |key: &str| -> Option<String> {
@@ -416,12 +505,19 @@ impl DbusInterface {
let image_path = hint_str("image-path")
.or_else(|| hint_str("image_path"))
.or_else(|| app_icon.starts_with('/').then(|| app_icon.clone()));
- let _ = self.sender.send(UserEvent::NewNotification { app_name, summary, body, image_path });
- 1
+ let _ = self.sender.send(UserEvent::NewNotification {
+ id,
+ app_name,
+ summary,
+ body,
+ image_path,
+ duration,
+ });
+ id
}
- async fn close_notification(&self, _id: u32) {
- let _ = self.sender.send(UserEvent::CloseNotification);
+ async fn close_notification(&self, id: u32) {
+ let _ = self.sender.send(UserEvent::CloseNotification { id });
}
async fn get_server_information(&self) -> (String, String, String, String) {