diff --git a/src/notifications/popup.rs b/src/notifications/popup.rs index a45e402..ad2bcad 100644 --- a/src/notifications/popup.rs +++ b/src/notifications/popup.rs @@ -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, 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>` +/// 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> = 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::(), &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) { + let mut child = root.first_child(); + while let Some(w) = child { + if w.is::() || w.is::() { + 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 } diff --git a/src/panel.rs b/src/panel.rs index cca16c5..1c0a67c 100644 --- a/src/panel.rs +++ b/src/panel.rs @@ -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 diff --git a/src/surface.rs b/src/surface.rs index 10adcdc..7080c3b 100644 --- a/src/surface.rs +++ b/src/surface.rs @@ -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 = 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))); } diff --git a/src/theme.rs b/src/theme.rs index 0812a80..2d2f904 100644 --- a/src/theme.rs +++ b/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; }}\