From c0a013c8edb30f4462942fc1dd7f600b8d6acab8 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 26 Aug 2026 19:31:01 +0800 Subject: [PATCH] notifications: add a dismiss button to the toast card NOTIFICATION INTERACTION #A: a direct way to dismiss a toast, rather than only auto-expiry or the D-Bus CloseNotification path. A small "x" button floats in the card's top-right corner via a gtk4::Overlay wrapping the existing content box, styled via the new .notification-dismiss CSS class, so it doesn't add an extra header row the approved demo's card layout never has. dismiss_card emits NotificationClosed(id, DISMISSED_BY_USER) -- the same freedesktop reason code (2) invoke_action/submit_reply already use for their own user-initiated dismissals -- via the shared dismiss() helper, which (as of the previous commit) already recomputes the toast's clickable input region on every removal. --- src/notifications/popup.rs | 45 +++++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/src/notifications/popup.rs b/src/notifications/popup.rs index ad2bcad..7e2abd4 100644 --- a/src/notifications/popup.rs +++ b/src/notifications/popup.rs @@ -397,7 +397,34 @@ fn make_card(spec: CardSpec<'_>) -> gtk4::Box { content.add_controller(gesture); } - card.append(&content); + // NOTIFICATION INTERACTION #A: a direct dismiss control. Floated in + // the card's top-right corner via an Overlay rather than a full extra + // header row, so it doesn't add vertical bulk the approved demo's own + // card never has (see the `.notification-dismiss` CSS in theme.rs). + // `collect_interactive` (this module) picks it up the same way it + // picks up the action/reply buttons below, by walking the real widget + // tree — it doesn't need to know this button lives one level deeper, + // inside the overlay, than they do. + let overlay = gtk4::Overlay::new(); + overlay.set_child(Some(&content)); + + let dismiss_btn = gtk4::Button::with_label("×"); + dismiss_btn.add_css_class("notification-dismiss"); + dismiss_btn.set_halign(gtk4::Align::End); + dismiss_btn.set_valign(gtk4::Align::Start); + let dismiss_invoke = Invoke { + conn: spec.conn.clone(), + cards: spec.cards.clone(), + cards_box: spec.cards_box.clone(), + window: spec.window.clone(), + id: spec.id, + }; + dismiss_btn.connect_clicked(move |_| { + dismiss_card(dismiss_invoke.clone()); + }); + overlay.add_overlay(&dismiss_btn); + + card.append(&overlay); let visible: Vec<&Action> = spec .actions @@ -500,6 +527,22 @@ fn invoke_action(invoke: Invoke, key: &str) { }); } +/// NOTIFICATION INTERACTION #A: the card's own dismiss button. Unlike +/// `invoke_action`, this never emits `ActionInvoked` — there's no action +/// key here, the user just closed the toast unprompted — only the +/// spec-mandated `NotificationClosed(id, reason)`, with +/// `close_reason::DISMISSED_BY_USER` (freedesktop value 2, "dismissed by +/// the user") so clients are told properly, same reason code +/// `invoke_action` above and `submit_reply` below already use for their +/// own user-initiated dismissals. +fn dismiss_card(invoke: Invoke) { + relm4::spawn_local(async move { + if dismiss(&invoke.cards_box, &invoke.window, &invoke.cards, invoke.id) { + emit_closed(&invoke.conn, invoke.id, close_reason::DISMISSED_BY_USER).await; + } + }); +} + fn submit_reply(entry: >k4::Entry, invoke: Invoke) { let text = entry.text().to_string(); if text.trim().is_empty() {