notifications: click-through-except-buttons input region + push-down entrance

surface::click_through set a completely empty input region on the toast
so every pointer event passed through -- including to the toast's own
action buttons and inline-reply entry. Replace it with
surface::set_hit_region(window, widgets), which sets the input region to
the union of the given widgets' rectangles instead of empty; everywhere
else on the surface stays click-through exactly as before.

popup.rs recomputes this via refresh_hit_region every time the card set
could have changed (shown, dismissed, expired -- routed through the one
dismiss() function) and keeps recomputing every frame for HIT_TRACK_MS
afterward, since a card's own entrance animation or the stack's
push-down reflow can still be moving a button on the frame the change
happens. collect_interactive walks the real widget tree for
GtkButton/GtkEntry rather than tracking a flat list, so it can't drift
out of sync with make_card's structure. build_window's connect_map
handles the first-map race the same way surface::click_through used to.

Also: spring_in_card grows a newly shown card's height from 0 to its
natural size via bread_theme::anim::spring_to (same technique as
main.rs's animate_drawer_height), so the existing stack gets pushed
down smoothly instead of jumping.

KeyboardMode::None is unchanged on the toast; history.rs's OnDemand
mode is untouched.

Still to do: the actual dismiss button in make_card (this commit wires
the mechanism that will hit-test it, but no card has one yet).
This commit is contained in:
Breadway 2026-08-26 19:30:08 +08:00
parent ba4e3480d4
commit 3eada87c7d
4 changed files with 216 additions and 40 deletions

View file

@ -1,5 +1,6 @@
use std::{cell::RefCell, collections::HashMap, rc::Rc};
use std::{cell::RefCell, collections::HashMap, rc::Rc, time::Instant};
use gtk4::glib::ControlFlow;
use gtk4::prelude::*;
use gtk4_layer_shell::{KeyboardMode, LayerShell};
use tokio::sync::mpsc::Receiver;
@ -34,6 +35,23 @@ pub fn build_window() -> (gtk4::Window, gtk4::Box) {
cards_box.set_margin_start(8);
cards_box.set_margin_end(8);
window.set_child(Some(&cards_box));
// NOTIFICATION INTERACTION #B: the surface (and `window.surface()`)
// doesn't exist until map, same constraint `surface::click_through`
// documents — this is the initial-region counterpart of that hook,
// recomputing against whatever's already in `cards_box` at the moment
// the toast becomes visible (empty on the very first map, but this
// window remaps on every reappearance after being fully dismissed —
// see `dismiss`'s `window.set_visible(false)` — and by the time a new
// notification's `Show` handler calls `set_visible(true)` again, its
// card is already a child of `cards_box`). `refresh_hit_region`'s own
// per-event calls below are the steady-state path; this is the
// just-in-case one for the map race itself.
let cbox_for_map = cards_box.clone();
window.connect_map(move |win| {
apply_hit_region(win, &cbox_for_map);
});
(window, cards_box)
}
@ -85,6 +103,15 @@ pub async fn run(
cards_box.prepend(&card);
cards.borrow_mut().insert(id, card.clone());
window.set_visible(true);
// ANIMATION WORK #6: spring the new card's own height in
// from 0 to its natural content height instead of it
// appearing at full size in one frame — since it's
// `prepend`ed (the vertical box's first child), the
// existing cards below get pushed down smoothly as this
// grows, rather than jumping straight to their new
// position.
spring_in_card(&card);
refresh_hit_region(&window, &cards_box);
if let Some(ui) = &history_ui {
history::refresh_if_visible(ui);
}
@ -132,7 +159,13 @@ pub async fn run(
/// Removes `id`'s card if present. Returns whether a card was actually
/// removed, so callers only emit `NotificationClosed` for a real dismissal
/// (not a no-op on an id that's already gone or was never shown).
/// (not a no-op on an id that's already gone or was never shown). Every
/// caller (auto-expire, `CloseNotification`, an action/reply invocation,
/// and the card's own dismiss button) goes through this one function, so
/// this is also the one place that needs to recompute the hit region
/// (NOTIFICATION INTERACTION #B) on removal — a card gone from `cards_box`
/// but still counted in the input region would leave a dead click-through
/// hole where a live button used to be.
fn dismiss(cards_box: &gtk4::Box, window: &gtk4::Window, cards: &Cards, id: u32) -> bool {
let removed = cards.borrow_mut().remove(&id);
let Some(card) = removed else {
@ -142,6 +175,7 @@ fn dismiss(cards_box: &gtk4::Box, window: &gtk4::Window, cards: &Cards, id: u32)
if cards.borrow().is_empty() {
window.set_visible(false);
}
refresh_hit_region(window, cards_box);
true
}
@ -166,27 +200,135 @@ async fn emit_closed(conn: &Option<zbus::Connection>, id: u32, reason: u32) {
}
}
/// ANIMATION WORK #6: springs `card`'s own height from 0 up to its natural
/// content height (`bread_theme::anim::spring_to` + `set_size_request`,
/// same "GTK4 CSS has no height transition" technique `main.rs`'s
/// `animate_drawer_height` already uses for the capsule drawer) instead of
/// it appearing at full size in one frame. Measured AFTER `card` is already
/// a child of `cards_box` (the caller's job), not before: an unparented
/// widget isn't rooted under this window's style provider chain yet, so its
/// `measure()` wouldn't see the real `.notification-card` padding/border —
/// only a widget that's actually in the tree gets an accurate natural size.
///
/// One-shot and self-contained — no cancellation bookkeeping, unlike
/// `animate_drawer_height`/`animate_osd_fill`'s own `Rc<RefCell<..>>`
/// tick-id storage: a card's entrance can't be interrupted by a second one,
/// since a same-id replacement tears the whole card down and builds a
/// fresh one (see the `Show` handler's `cards_box.remove(&old)`) rather
/// than reusing it.
const CARD_GROW_MS: f64 = 380.0;
fn spring_in_card(card: &gtk4::Box) {
let (_, target_h, _, _) = card.measure(gtk4::Orientation::Vertical, -1);
card.set_size_request(-1, 0);
let target = card.clone();
bread_theme::anim::spring_to(card, 0, target_h, CARD_GROW_MS, move |h| {
target.set_size_request(-1, h.max(0));
});
}
thread_local! {
// NOTIFICATION INTERACTION #B: the tick callback that keeps
// `refresh_hit_region` recomputing the toast's input region while a
// card's entrance (`spring_in_card` above) or the stack's push-down
// reflow could still be moving a button. One process-wide toast window
// (this crate registers a single `org.freedesktop.Notifications` name),
// so a thread-local — not a field threaded through every call site — is
// enough, same reasoning as `theme::SHELL_THEME_MONITOR`.
static HIT_TRACKER: RefCell<Option<gtk4::TickCallbackId>> = const { RefCell::new(None) };
}
/// How long after a card set/layout change to keep recomputing the hit
/// region every frame — long enough to cover both `spring_in_card`'s
/// `CARD_GROW_MS` and the CSS `notif-in` keyframe's 0.45s slide-in (see
/// `theme.rs`'s `.notification-card` rule), whichever finishes last.
const HIT_TRACK_MS: f64 = 700.0;
/// Recomputes the toast surface's clickable input region immediately, then
/// keeps recomputing it every frame for `HIT_TRACK_MS` — covering both a
/// newly-shown card's own entrance and the stack's push-down settle, either
/// of which can still be moving a button on the frame this is called.
/// Called any time the card set could have changed: shown, dismissed
/// (including via the new dismiss button — see `dismiss` above), or
/// expired. Cancels any previous tracking run first, so a rapid burst of
/// notifications doesn't accumulate overlapping tick callbacks.
fn refresh_hit_region(window: &gtk4::Window, cards_box: &gtk4::Box) {
apply_hit_region(window, cards_box);
if let Some(id) = HIT_TRACKER.with(|c| c.borrow_mut().take()) {
id.remove();
}
let started = Instant::now();
let win = window.clone();
let cbox = cards_box.clone();
let id = window.add_tick_callback(move |_, _| {
apply_hit_region(&win, &cbox);
if started.elapsed().as_secs_f64() * 1000.0 >= HIT_TRACK_MS {
HIT_TRACKER.with(|c| c.borrow_mut().take());
return ControlFlow::Break;
}
ControlFlow::Continue
});
HIT_TRACKER.with(|c| *c.borrow_mut() = Some(id));
}
/// One frame's worth of `refresh_hit_region`'s work: walk `cards_box` for
/// every currently-interactive widget (action/dismiss buttons, the
/// inline-reply entry) and hand their rectangles to
/// `surface::set_hit_region`. Split out from `refresh_hit_region` so the
/// initial immediate call and the tracking tick callback share the exact
/// same logic.
fn apply_hit_region(window: &gtk4::Window, cards_box: &gtk4::Box) {
let mut widgets = Vec::new();
collect_interactive(cards_box.upcast_ref::<gtk4::Widget>(), &mut widgets);
crate::surface::set_hit_region(window, &widgets);
}
/// Depth-first walk of `root`'s widget tree collecting every `GtkButton`
/// (action buttons, the reply-send button, the dismiss button) and
/// `GtkEntry` (the inline-reply field) — the only things on a card a user
/// should ever be able to click into. Everything else (the summary/body
/// labels, the card's own background) stays click-through, same as the
/// blanket empty region did before NOTIFICATION INTERACTION #B. Walking
/// the real widget tree rather than tracking a flat list as cards/buttons
/// are built means this can't drift out of sync with `make_card`'s own
/// structure (e.g. the dismiss button living inside a `gtk4::Overlay`
/// rather than directly under `card`).
fn collect_interactive(root: &gtk4::Widget, out: &mut Vec<gtk4::Widget>) {
let mut child = root.first_child();
while let Some(w) = child {
if w.is::<gtk4::Button>() || w.is::<gtk4::Entry>() {
out.push(w.clone());
}
collect_interactive(&w, out);
child = w.next_sibling();
}
}
fn create_window() -> gtk4::Window {
let window = gtk4::Window::new();
window.add_css_class("breadbar-notification");
window.init_layer_shell();
window.set_namespace(Some("breadbar-notif"));
crate::surface::apply(&window, "breadbar-notif");
// Toasts are purely informational: they never grab keyboard focus...
// Toasts are purely informational — they never grab keyboard focus,
// full stop, regardless of what's clickable on them (KeyboardMode::None
// stays; do NOT change this — see the NOTIFICATION INTERACTION #B task
// note). Historically ("stop toast popups from stealing focus or
// blocking clicks") that also meant a fully empty input region: every
// pointer event passed straight through to whatever's underneath, but
// that made `make_card`'s own action buttons, its inline-reply
// `GtkEntry`, and the dismiss button below permanently unreachable too.
// `crate::surface::set_hit_region` (called from `build_window`'s
// `connect_map` and from `refresh_hit_region` below, any time the card
// set or layout could have changed) replaces the old blanket
// `surface::click_through` empty region with the union of just those
// widgets' own rectangles — everywhere else on the surface stays
// click-through, same as before. A toast that genuinely has none of
// them yet (`cards_box` empty) still gets the same all-empty region
// `click_through` set, since a rectangle union over zero widgets is
// the empty region.
window.set_keyboard_mode(KeyboardMode::None);
// ...and click through entirely, via an empty input region — every
// pointer event passes straight to whatever's underneath instead of
// hitting the toast. `make_card` below does build action buttons and,
// when a notification carries INLINE_REPLY_KEY, a reply `GtkEntry` —
// but with no input region reaching the toast at all, neither is ever
// clickable or focusable from here regardless of keyboard mode, so
// OnDemand would grant a focus capability nothing can trigger. Those
// controls are only reachable from the history window (history.rs),
// which is opened deliberately and correctly keeps OnDemand + normal
// hit-testing. If the toast itself grows real click interactivity
// later, this needs to become a real (non-empty) input region sized to
// just the interactive rows, not a blanket revert to OnDemand.
crate::surface::click_through(&window);
crate::theme::bind_auto(&window);
window
}

View file

@ -16,7 +16,7 @@ use crate::{bind_layer_monitor, theme};
/// Outside this rectangle's local x/y span, the dismiss window's own real
/// size (Wayland clips an input region to the surface's actual bounds, same
/// as `surface::click_through`'s empty-region trick) — big enough to cover
/// as `surface::set_hit_region`'s empty-region trick) — big enough to cover
/// any realistic monitor layout, including a negative-origin secondary
/// output (`hyprctl layers -j` reported `x: -1080` for this machine's own
/// DVI-I-1). Centered on the origin so it's safe regardless of which way a
@ -52,7 +52,7 @@ pub struct PanelSet {
// exists and how it's computed. `None` = no hole, the plain
// margin-based popover behaviour applies instead. Read inside
// `dismiss`'s own `connect_map` (the input region can only be set once
// the surface is real — see `surface::click_through`'s doc comment for
// the surface is real — see `surface::set_hit_region`'s doc comment for
// the same constraint) and, for the case where `dismiss` is already
// mapped from a prior show, applied immediately too.
dismiss_hole: DismissHole,
@ -263,7 +263,7 @@ fn make_dismiss(monitor: &str, hole: &DismissHole) -> gtk4::Window {
window.set_visible(false);
// The underlying `GdkSurface` (and therefore `window.surface()`, which
// `apply_dismiss_hole` needs) doesn't exist until the window is mapped
// — same constraint `surface::click_through` documents. This surface
// — same constraint `surface::set_hit_region` documents. This surface
// gets hidden/shown repeatedly (every popover open/close, every
// capsule search), and GTK4 unmaps-then-remaps a toplevel each time
// its visibility toggles off then on, so re-applying here on every

View file

@ -87,27 +87,50 @@ pub fn apply(window: &gtk4::Window, namespace: &str) {
}
}
/// Makes `window` fully click-through: an empty layer-shell input region
/// means every pointer event passes to whatever is underneath instead of
/// being consumed by this surface. Deliberately opt-in, not part of
/// `apply()` — like `exclusive` zone and `keyboard` mode (see the module
/// doc comment), this isn't a `[surfaces.*]` schema concept, and most of
/// breadbar's satellites (history, the panel, the dismiss-scrim) genuinely
/// need real hit-testing. Only a purely-informational surface — today just
/// the notification toast — should call this.
/// Sets `window`'s input region to the union of `widgets`' current
/// allocations, each measured relative to `window` itself (the surface's
/// own coordinate space, same as `bar::workspaces::button_geom`'s own
/// `compute_bounds` call relative to its Fixed host) — everywhere else on
/// the surface stays click-through. An empty (or all-invisible/all-
/// unallocated) `widgets` slice is not a special case:
/// `Region::create_rectangles(&[])` is already the fully click-through
/// empty region — passing `&[]` here is exactly the old blanket
/// `Region::create()` this function replaces (see git history around
/// `notifications/popup.rs`'s "stop toast popups from stealing focus or
/// blocking clicks" fix, and NOTIFICATION INTERACTION #B in the current
/// task notes: a toast must never block clicks or steal focus from
/// whatever's underneath it EXCEPT on its own buttons — an all-empty
/// region made those unreachable too).
///
/// Must be applied in a `connect_map` handler: the surface (and therefore
/// `window.surface()`) doesn't exist until the window is mapped.
/// Only meaningful after `window.surface()` exists (i.e. from
/// `connect_map` onward — the surface doesn't exist before the window is
/// mapped) and after `widgets` have a real allocation — a widget with no
/// allocation yet (`compute_bounds` returning `None`) is simply skipped
/// rather than contributing a garbage rectangle, so a call made one frame
/// too early just yields a smaller-than-intended region for that one frame
/// rather than a wrong one.
///
/// This exists so a future migration of a surface's window-setup code
/// (like the one from hand-rolled layer-shell calls to this module) can't
/// silently drop a click-through requirement the way `breadbar-notif` did
/// once already — see the git history of `notifications/popup.rs` around
/// the "stop toast popups from stealing focus or blocking clicks" fix.
pub fn click_through(window: &gtk4::Window) {
window.connect_map(|win| {
if let Some(surface) = win.surface() {
surface.set_input_region(Some(&gtk4::cairo::Region::create()));
}
});
/// Callers are responsible for RECOMPUTING this every time the hittable
/// set could have moved: a widget added or removed, or a layout pass (an
/// entrance animation, a push-down reflow) still in flight. A stale region
/// either swallows clicks meant for the window below or leaves a real
/// button dead.
pub fn set_hit_region(window: &gtk4::Window, widgets: &[gtk4::Widget]) {
let Some(surface) = window.surface() else {
return;
};
let rects: Vec<gtk4::cairo::RectangleInt> = widgets
.iter()
.filter(|w| w.is_visible())
.filter_map(|w| {
let b = w.compute_bounds(window)?;
Some(gtk4::cairo::RectangleInt::new(
b.x().floor() as i32,
b.y().floor() as i32,
b.width().ceil() as i32,
b.height().ceil() as i32,
))
})
.collect();
surface.set_input_region(Some(&gtk4::cairo::Region::create_rectangles(&rects)));
}

View file

@ -341,6 +341,17 @@ fn load_css() -> String {
.notification-action {{ padding: 2px 8px; font-size: 11px; border-radius: {radius_sm}; }}\
.notification-reply {{ margin-top: 6px; }}\
.notification-reply-entry {{ min-width: 0; }}\
/* NOTIFICATION INTERACTION #A: a direct dismiss control, floated\
in the card's top-right corner via an Overlay (see popup.rs's\
`make_card`) rather than a full extra header row, so it doesn't\
add vertical bulk the approved demo's own card never has. */\
.notification-dismiss {{ min-width: 18px; min-height: 18px; padding: 0;\
margin: 2px; border-radius: {radius_pill}; background: transparent;\
color: @on-bg; opacity: 0.45; font-size: 12px; font-weight: bold;\
border: none; outline: none; box-shadow: none;\
transition: background-color 0.18s {spring_settle}, opacity 0.18s ease; }}\
.notification-dismiss:hover {{ opacity: 1; background: alpha(@on-bg, 0.16); }}\
.notification-dismiss:active {{ background: alpha(@on-bg, 0.24); }}\
.history-title {{ font-weight: bold; font-size: 13px; }}\
.history-close {{ padding: 2px 8px; }}\
.history-empty {{ opacity: 0.5; padding: 8px 0; }}\