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:
parent
ba4e3480d4
commit
3eada87c7d
4 changed files with 216 additions and 40 deletions
|
|
@ -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: >k4::Box, window: >k4::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: >k4::Box, window: >k4::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: >k4::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: >k4::Window, cards_box: >k4::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: >k4::Window, cards_box: >k4::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: >k4::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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -87,27 +87,50 @@ pub fn apply(window: >k4::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: >k4::Window) {
|
||||
window.connect_map(|win| {
|
||||
if let Some(surface) = win.surface() {
|
||||
surface.set_input_region(Some(>k4::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: >k4::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(>k4::cairo::Region::create_rectangles(&rects)));
|
||||
}
|
||||
|
|
|
|||
11
src/theme.rs
11
src/theme.rs
|
|
@ -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; }}\
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue