Skip to content

Commit b6f97d4

Browse files
vibechoomclaude
andcommitted
feat(notifications): toast body-click invokes the default action; hide the default from the button row (#217)
The freedesktop notification spec reserves the action key `default` for click-to-activate — servers may skip rendering it as a button. Trollshell did neither: body-click on a toast was dismiss-only (an actionable notification, e.g. a chat message, was discarded instead of opened), and `default` survived `parse_actions` unfiltered, so it rendered as a literal button — burning one of the 3 capped slots, or a blank ghost button when the app sends an empty label (`["default", ""]`, common in practice). - `overlays/notifications.rs::build_card`: body-click now invokes the `default` action (if the notification carries one) then dismisses; default-less toasts keep today's plain dismiss-on-click. - The visible actions row now excludes `default`, so `.take(3)` caps real secondary actions only. - Added a small flat `x` dismiss button in the card header — load-bearing, not just symmetry: once body-click can open instead of discard, a default-carrying toast needs an explicit way to close it. - `.ts-toast:hover { cursor: pointer }` so the card reads as clickable. - `panels/notifications.rs::build_history_action_row` (the drawer history consumer) gets the same default-exclusion — it rendered the identical unfiltered actions list. - Both consumers share the partition logic via a new `components/notif_actions.rs` (`default_action` / `visible_actions`) instead of duplicating the `key == "default"` check. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5e8d2f6 commit b6f97d4

5 files changed

Lines changed: 85 additions & 11 deletions

File tree

assets/trollshell/style.css

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -596,6 +596,9 @@
596596
* off `min-width`. */
597597
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.35);
598598
}
599+
.ts-toast:hover {
600+
cursor: pointer;
601+
}
599602
.ts-toast-critical {
600603
border-color: @destructive_color;
601604
}
@@ -624,6 +627,14 @@
624627
border-radius: 0.615em;
625628
font-size: 0.923em;
626629
}
630+
.ts-toast-dismiss {
631+
padding: 0.154em 0.308em;
632+
border-radius: 0.462em;
633+
opacity: 0.55;
634+
}
635+
.ts-toast-dismiss:hover {
636+
opacity: 1;
637+
}
627638

628639
/* ─── Password prompt ───────────────────────────────────────────── */
629640

trollshell/src/components/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,6 @@ pub mod format;
1212
pub mod history_row;
1313
pub mod layout;
1414
pub mod mpris_controls;
15+
pub mod notif_actions;
1516
pub mod power_profile;
1617
pub mod reactive_list;
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
//! Shared partition logic for `Vec<Action>` on a notification or history
2+
//! entry.
3+
//!
4+
//! The freedesktop notification spec reserves the action key `"default"`
5+
//! for click-to-activate — the server is free not to render it as a
6+
//! button. Both the toast overlay (`overlays::notifications::build_card`)
7+
//! and the drawer history panel
8+
//! (`panels::notifications::build_history_action_row`) render a
9+
//! `.take(3)`-capped row of buttons from the same flat `Vec<Action>`
10+
//! shape; without this filter a `default` action (often carrying an
11+
//! empty label, since apps expect it to be invoked rather than shown)
12+
//! burns a slot as a literal or blank "ghost" button. The toast overlay
13+
//! additionally wires the `default` action to the card's body-click
14+
//! gesture via [`default_action`].
15+
16+
use hytte::services::notifications::Action;
17+
18+
/// The reserved action key (freedesktop notification spec) that activates
19+
/// on click rather than rendering as a button.
20+
const DEFAULT_ACTION_KEY: &str = "default";
21+
22+
/// The `default` action, if `actions` carries one.
23+
pub(crate) fn default_action(actions: &[Action]) -> Option<&Action> {
24+
actions.iter().find(|a| a.key == DEFAULT_ACTION_KEY)
25+
}
26+
27+
/// Actions to render as buttons — everything except the reserved
28+
/// `default` key, in original order. Callers still cap the count
29+
/// themselves (e.g. `.take(3)`).
30+
pub(crate) fn visible_actions(actions: &[Action]) -> impl Iterator<Item = &Action> {
31+
actions.iter().filter(|a| a.key != DEFAULT_ACTION_KEY)
32+
}

trollshell/src/overlays/notifications.rs

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ use hytte::services::notifications::{self, Notification, NotificationImage, Urge
4343
use hytte::services::notifications_mute;
4444
use hytte::ui::{Anchor, Margin, layer_window};
4545

46+
use crate::components::notif_actions;
47+
4648
// ── Constants ─────────────────────────────────────────────────────────────────
4749

4850
/// Maximum number of non-critical toasts rendered as individual cards.
@@ -367,6 +369,21 @@ fn build_card(notif: &Notification) -> gtk::Widget {
367369
app_label.set_hexpand(true);
368370
header.append(&app_label);
369371

372+
// Explicit dismiss button. Load-bearing (not just symmetry): once
373+
// body-click is wired to invoke a `default` action instead of
374+
// dismissing (below), a default-carrying toast would otherwise have no
375+
// way to close it without waiting out the timeout.
376+
let dismiss_btn = gtk::Button::from_icon_name("window-close-symbolic");
377+
dismiss_btn.add_css_class("flat");
378+
dismiss_btn.add_css_class("ts-toast-dismiss");
379+
dismiss_btn.set_valign(gtk::Align::Center);
380+
dismiss_btn.set_tooltip_text(Some("Dismiss"));
381+
let dismiss_id = notif.id;
382+
dismiss_btn.connect_clicked(move |_| {
383+
notifications::dismiss(dismiss_id, 2);
384+
});
385+
header.append(&dismiss_btn);
386+
370387
column.append(&header);
371388

372389
// Summary.
@@ -389,14 +406,16 @@ fn build_card(notif: &Notification) -> gtk::Widget {
389406
column.append(&body);
390407
}
391408

392-
// Action buttons (rendered only when actions are present). Cap at 3
393-
// visible buttons so a chatty app (e.g. an "snooze 1m / 5m / 15m / 1h"
394-
// calendar reminder) can't blow out the toast width — the rest stay
395-
// accessible from the drawer history page.
396-
if !notif.actions.is_empty() {
409+
// Action buttons (rendered only when visible actions are present —
410+
// the reserved `default` action is excluded, see `notif_actions`).
411+
// Cap at 3 visible buttons so a chatty app (e.g. an "snooze 1m / 5m /
412+
// 15m / 1h" calendar reminder) can't blow out the toast width — the
413+
// rest stay accessible from the drawer history page.
414+
let mut visible = notif_actions::visible_actions(&notif.actions).peekable();
415+
if visible.peek().is_some() {
397416
let actions_row = gtk::Box::new(gtk::Orientation::Horizontal, 4);
398417
actions_row.add_css_class("ts-toast-actions");
399-
for action in notif.actions.iter().take(3) {
418+
for action in visible.take(3) {
400419
let btn = gtk::Button::with_label(&action.label);
401420
btn.add_css_class("ts-toast-action");
402421
let id = notif.id;
@@ -412,12 +431,19 @@ fn build_card(notif: &Notification) -> gtk::Widget {
412431

413432
card.append(&column);
414433

415-
// Click anywhere on the card → dismiss (reason 2 = dismissed by user).
416-
// Action buttons consume their own click events before it bubbles here.
434+
// Click anywhere on the card → invoke the reserved `default` action
435+
// (if the notification carries one) then dismiss (reason 2 =
436+
// dismissed by user); with no `default` action this is a plain
437+
// dismiss, same as before. Action/dismiss buttons consume their own
438+
// click events before it bubbles here.
417439
let id = notif.id;
440+
let default_key = notif_actions::default_action(&notif.actions).map(|a| a.key.clone());
418441
let gesture = gtk::GestureClick::new();
419442
gesture.connect_pressed(move |gesture, _, _, _| {
420443
gesture.set_state(gtk::EventSequenceState::Claimed);
444+
if let Some(key) = &default_key {
445+
notifications::invoke_action(id, key);
446+
}
421447
notifications::dismiss(id, 2);
422448
});
423449
card.add_controller(gesture);

trollshell/src/panels/notifications.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ use hytte::prelude::*;
1414
use hytte::services::{dnd, notifications, notifications_mute};
1515

1616
use crate::components::layout::{finish_page, page_box};
17+
use crate::components::notif_actions;
1718

1819
pub fn panel_notifications() -> gtk::Widget {
1920
let column = page_box();
@@ -188,11 +189,14 @@ fn build_history_action_row(entry: &notifications::HistoryEntry) -> adw::ActionR
188189
time_label.set_valign(gtk::Align::Center);
189190
row.add_prefix(&time_label);
190191

191-
// Action buttons (cap at 3 — same as toasts).
192-
if !entry.actions.is_empty() {
192+
// Action buttons (cap at 3 — same as toasts). The reserved `default`
193+
// action is excluded — it's not meant to render as a button (see
194+
// `notif_actions`).
195+
let mut visible = notif_actions::visible_actions(&entry.actions).peekable();
196+
if visible.peek().is_some() {
193197
let actions_box = gtk::Box::new(gtk::Orientation::Horizontal, 4);
194198
actions_box.set_valign(gtk::Align::Center);
195-
for action in entry.actions.iter().take(3) {
199+
for action in visible.take(3) {
196200
let btn = gtk::Button::with_label(&action.label);
197201
btn.add_css_class("flat");
198202
let id = entry.id;

0 commit comments

Comments
 (0)