Skip to main content

telar_ui_tree/
overlay_dispatch.rs

1//! Priority pointer routing for overlays (portals: modals, dropdowns, toasts).
2//!
3//! Overlays paint on top (their draw commands are hoisted to the end at compose time, see
4//! `segment.rs`), but event dispatch is an in-tree, document-order `on_event` walk. An overlay declared
5//! deep in the tree would therefore be reached *late* in the walk — background content earlier in
6//! document order would hit-test the same point first and steal the click, and nothing would stop a
7//! press from reaching the content *behind* a modal.
8//!
9//! This registry closes that gap by mirroring the compose-time hoist in the event layer: an [`Overlay`]
10//! registers an [`OverlaySink`], and the top-level dispatcher ([`ComponentList::on_event`]) consults the
11//! registry *before* walking the tree. A positioned pointer event whose point falls inside an overlay's
12//! content is dispatched to that overlay (topmost first) and consumed — so the tree walk never runs for
13//! it and the content behind is blocked. A press outside every overlay falls through to the tree as
14//! before, so a scrim that fills the viewport reads as a modal (blocks everything) while a small toast
15//! blocks only clicks that actually land on it — the content rect is the coarse barrier.
16//!
17//! Click-through: an overlay may opt out of blocking ([`OverlaySink::blocking`] = false). Then, even for a
18//! point inside its content rect, the event is consumed only when one of its children actually handles it;
19//! otherwise it falls through to the overlays/tree behind. This is how a full-viewport toast or tooltip
20//! layer stays non-modal — clicks on its transparent area reach the page, only its visible panel captures.
21//!
22//! Capture: the overlay that handles a press captures the gesture, so the following moves/releases route
23//! to it regardless of where the pointer travels (a drag started in an overlay keeps tracking after the
24//! pointer leaves the overlay's box), until the release.
25
26use std::rc::Rc;
27
28use geometry_core::Rect;
29use platform_core::Event;
30
31use crate::component::EventResult;
32
33/// An overlay's hook into priority pointer routing. Implemented in `ui-core` by the `overlay` widget.
34pub trait OverlaySink {
35    /// The overlay content's current bounds, used as the hit-test barrier. A full-viewport scrim returns
36    /// the whole viewport (modal); a corner toast or an anchored dropdown returns just its box (blocks
37    /// only clicks on itself).
38    fn content_rect(&self) -> Rect;
39    /// Routes a positioned pointer event into the overlay's own children (same path its in-tree
40    /// `on_event` would take for non-pointer events). Returns `Handled` when a child consumed it.
41    fn dispatch(&self, event: &Event) -> EventResult;
42    /// Whether the overlay swallows every pointer event inside its [`content_rect`](Self::content_rect)
43    /// (a modal, the default) or only those a child actually handled (a click-through toast/tooltip layer,
44    /// so clicks on its transparent area fall through to the content behind).
45    fn blocking(&self) -> bool {
46        true
47    }
48}
49
50reactive_core::surface_local! {
51    /// A per-surface overlay registry: the modals/toasts/tooltips registered for priority pointer routing
52    /// on this surface. The runner activates each surface's [`OverlayContext`] around its build/event/frame.
53    slot OVERLAYS: OverlayRegistry = OverlayRegistry::new();
54    access with_overlays, with_overlays_ref;
55    context OverlayContext, OverlayGuard;
56}
57
58struct OverlayRegistry {
59    // Registered overlays in document order; the last entry is topmost (drawn on top, so hit-tested first).
60    entries: Vec<(u64, Rc<dyn OverlaySink>)>,
61    // The overlay that captured the current pointer gesture (set on a press it handled, cleared on release).
62    captured: Option<u64>,
63    next_id: u64,
64}
65
66impl OverlayRegistry {
67    fn new() -> Self {
68        Self {
69            entries: Vec::new(),
70            captured: None,
71            next_id: 0,
72        }
73    }
74}
75
76/// Registers an overlay for priority pointer routing; returns an id to pass to [`unregister_overlay`] on
77/// drop. Newly registered overlays sit on top of earlier ones.
78pub fn register_overlay(sink: Rc<dyn OverlaySink>) -> u64 {
79    with_overlays(|r| {
80        let id = r.next_id;
81        r.next_id += 1;
82        r.entries.push((id, sink));
83        id
84    })
85}
86
87/// Removes an overlay from the registry (call from the widget's `Drop`). Also releases the pointer capture
88/// if this overlay held it, so a modal dismissed mid-gesture does not leave a dangling capture.
89pub fn unregister_overlay(id: u64) {
90    with_overlays(|r| {
91        r.entries.retain(|(entry_id, _)| *entry_id != id);
92        if r.captured == Some(id) {
93            r.captured = None;
94        }
95    });
96}
97
98fn pointer_pos(event: &Event) -> Option<(f32, f32)> {
99    match event {
100        Event::PointerPressed { x, y, .. }
101        | Event::PointerMoved { x, y, .. }
102        | Event::PointerReleased { x, y, .. } => Some((*x as f32, *y as f32)),
103        _ => None,
104    }
105}
106
107/// Routes a positioned pointer event to the overlay layer with priority over the main tree. Returns
108/// `Handled` when an overlay consumed the event (the caller then skips the tree walk, blocking content
109/// behind the overlay) and `Ignored` when it should fall through to the tree (no overlays, or the point
110/// is outside every overlay and no gesture is captured). Non-pointer events always return `Ignored` so
111/// keyboard and `CursorLeft` keep broadcasting through the tree.
112pub fn dispatch_overlays(event: &Event) -> EventResult {
113    let press = matches!(event, Event::PointerPressed { .. });
114    let release = matches!(event, Event::PointerReleased { .. });
115    // Only these three reach an overlay, and the snapshot below is not free: taking it for a key press
116    // cloned the whole registry to answer `Ignored`.
117    if !press && !release && !matches!(event, Event::PointerMoved { .. }) {
118        return EventResult::Ignored;
119    }
120    // Snapshot the registry (cheap `Rc` clones) and drop the borrow before dispatching: a handler may
121    // write signals whose deferred flush registers/unregisters an overlay, which would re-enter the borrow.
122    let (entries, captured) = with_overlays_ref(|r| (r.entries.clone(), r.captured));
123    if entries.is_empty() {
124        return EventResult::Ignored;
125    }
126    let (x, y) = pointer_pos(event).unwrap();
127
128    // A gesture that began on an overlay stays there wherever the pointer goes, until it is released.
129    if !press && let Some(cap_id) = captured {
130        if let Some((_, sink)) = entries.iter().find(|(id, _)| *id == cap_id) {
131            sink.dispatch(event);
132            if release {
133                with_overlays(|r| r.captured = None);
134            }
135            return EventResult::Handled;
136        }
137        // The capturing overlay is gone (dismissed mid-gesture); drop the stale capture.
138        with_overlays(|r| r.captured = None);
139    }
140
141    // Topmost first. A modal consumes the event over its whole barrier; a click-through overlay only when a
142    // child took it, otherwise the walk continues to the overlays below and ultimately to the tree.
143    for (id, sink) in entries.iter().rev() {
144        if !sink.content_rect().contains(x, y) {
145            continue;
146        }
147        let handled = sink.dispatch(event) == EventResult::Handled;
148        if sink.blocking() || handled {
149            if press {
150                // Capture the gesture so following moves/releases route here wherever the pointer goes.
151                with_overlays(|r| r.captured = Some(*id));
152            }
153            return EventResult::Handled;
154        }
155    }
156    EventResult::Ignored
157}
158
159#[cfg(test)]
160fn reset() {
161    with_overlays(|r| *r = OverlayRegistry::new());
162}
163
164#[cfg(test)]
165mod tests {
166    use std::cell::Cell;
167
168    use platform_core::{PointerButton, PointerSource};
169
170    use super::*;
171
172    struct RecordingSink {
173        rect: Rect,
174        hits: Rc<Cell<u32>>,
175        blocking: bool,
176        // What `dispatch` reports: `true` mimics a child consuming the event, `false` a click that missed.
177        child_handles: bool,
178    }
179
180    impl OverlaySink for RecordingSink {
181        fn content_rect(&self) -> Rect {
182            self.rect
183        }
184        fn dispatch(&self, _event: &Event) -> EventResult {
185            self.hits.set(self.hits.get() + 1);
186            if self.child_handles {
187                EventResult::Handled
188            } else {
189                EventResult::Ignored
190            }
191        }
192        fn blocking(&self) -> bool {
193            self.blocking
194        }
195    }
196
197    // A blocking overlay whose children always handle — the default used by the routing/capture tests.
198    fn sink(rect: Rect) -> (Rc<dyn OverlaySink>, Rc<Cell<u32>>) {
199        configured_sink(rect, true, true)
200    }
201
202    fn configured_sink(
203        rect: Rect,
204        blocking: bool,
205        child_handles: bool,
206    ) -> (Rc<dyn OverlaySink>, Rc<Cell<u32>>) {
207        let hits = Rc::new(Cell::new(0));
208        let sink: Rc<dyn OverlaySink> = Rc::new(RecordingSink {
209            rect,
210            hits: Rc::clone(&hits),
211            blocking,
212            child_handles,
213        });
214        (sink, hits)
215    }
216
217    fn press(x: f64, y: f64) -> Event {
218        Event::PointerPressed {
219            x,
220            y,
221            button: PointerButton::Primary,
222            source: PointerSource::Mouse,
223        }
224    }
225    fn moved(x: f64, y: f64) -> Event {
226        Event::PointerMoved {
227            x,
228            y,
229            source: PointerSource::Mouse,
230        }
231    }
232    fn released(x: f64, y: f64) -> Event {
233        Event::PointerReleased {
234            x,
235            y,
236            button: PointerButton::Primary,
237            source: PointerSource::Mouse,
238        }
239    }
240
241    #[test]
242    fn no_overlays_falls_through() {
243        reset();
244        assert_eq!(dispatch_overlays(&press(10.0, 10.0)), EventResult::Ignored);
245    }
246
247    #[test]
248    fn press_inside_is_consumed_outside_falls_through() {
249        reset();
250        let (s, hits) = sink(Rect::new(0.0, 0.0, 100.0, 100.0));
251        let id = register_overlay(s);
252
253        // Inside the overlay: consumed (blocks the content behind) and delivered to the sink.
254        assert_eq!(dispatch_overlays(&press(50.0, 50.0)), EventResult::Handled);
255        assert_eq!(hits.get(), 1);
256        // Release ends the gesture. Outside the overlay: falls through to the tree, sink untouched.
257        dispatch_overlays(&released(50.0, 50.0));
258        assert_eq!(
259            dispatch_overlays(&press(500.0, 500.0)),
260            EventResult::Ignored
261        );
262
263        unregister_overlay(id);
264    }
265
266    #[test]
267    fn topmost_overlay_wins() {
268        reset();
269        let (bottom, bottom_hits) = sink(Rect::new(0.0, 0.0, 100.0, 100.0));
270        let (top, top_hits) = sink(Rect::new(0.0, 0.0, 100.0, 100.0));
271        let b = register_overlay(bottom);
272        let t = register_overlay(top);
273
274        dispatch_overlays(&press(50.0, 50.0));
275        assert_eq!(
276            top_hits.get(),
277            1,
278            "topmost (last registered) receives the press"
279        );
280        assert_eq!(
281            bottom_hits.get(),
282            0,
283            "the overlay below must not also get it"
284        );
285
286        unregister_overlay(t);
287        unregister_overlay(b);
288    }
289
290    #[test]
291    fn capture_routes_moves_and_release_even_outside() {
292        reset();
293        let (s, hits) = sink(Rect::new(0.0, 0.0, 100.0, 100.0));
294        let id = register_overlay(s);
295
296        // Press inside captures the gesture.
297        assert_eq!(dispatch_overlays(&press(50.0, 50.0)), EventResult::Handled);
298        // A move that leaves the overlay still routes to it (a drag started inside keeps tracking).
299        assert_eq!(
300            dispatch_overlays(&moved(500.0, 500.0)),
301            EventResult::Handled
302        );
303        // The release, also outside, reaches the overlay and ends the capture.
304        assert_eq!(
305            dispatch_overlays(&released(500.0, 500.0)),
306            EventResult::Handled
307        );
308        assert_eq!(hits.get(), 3);
309        // After release, an outside press falls through again.
310        assert_eq!(
311            dispatch_overlays(&press(500.0, 500.0)),
312            EventResult::Ignored
313        );
314
315        unregister_overlay(id);
316    }
317
318    #[test]
319    fn unregister_stops_routing_and_clears_capture() {
320        reset();
321        let (s, _hits) = sink(Rect::new(0.0, 0.0, 100.0, 100.0));
322        let id = register_overlay(s);
323        // Capture a gesture, then unregister (as a dismissed modal would on drop) before the release.
324        dispatch_overlays(&press(50.0, 50.0));
325        unregister_overlay(id);
326        // With no overlays left, everything falls through and no stale capture lingers.
327        assert_eq!(
328            dispatch_overlays(&released(50.0, 50.0)),
329            EventResult::Ignored
330        );
331        assert_eq!(dispatch_overlays(&press(50.0, 50.0)), EventResult::Ignored);
332    }
333
334    // A modal (blocking) overlay swallows a press inside its content rect even where no child sits, so the
335    // scrim reads as modal and nothing behind it receives the press.
336    #[test]
337    fn blocking_overlay_consumes_press_in_empty_region() {
338        reset();
339        let (s, hits) = configured_sink(Rect::new(0.0, 0.0, 100.0, 100.0), true, false);
340        let id = register_overlay(s);
341
342        assert_eq!(dispatch_overlays(&press(50.0, 50.0)), EventResult::Handled);
343        assert_eq!(
344            hits.get(),
345            1,
346            "the modal is still asked to dispatch the press"
347        );
348
349        unregister_overlay(id);
350    }
351
352    // A click-through overlay does NOT consume a press its children ignore (the click landed on the
353    // transparent absolute-fill area, not the visible panel): it falls through to the tree behind.
354    #[test]
355    fn click_through_overlay_falls_through_when_child_ignores() {
356        reset();
357        let (s, hits) = configured_sink(Rect::new(0.0, 0.0, 100.0, 100.0), false, false);
358        let id = register_overlay(s);
359
360        assert_eq!(dispatch_overlays(&press(50.0, 50.0)), EventResult::Ignored);
361        assert_eq!(
362            hits.get(),
363            1,
364            "the overlay is offered the press before falling through"
365        );
366        // A move afterwards also falls through (no gesture was captured by the click-through overlay).
367        assert_eq!(dispatch_overlays(&moved(50.0, 50.0)), EventResult::Ignored);
368
369        unregister_overlay(id);
370    }
371
372    // A click-through overlay DOES consume a press when a child handles it (the click hit the panel),
373    // capturing the gesture so the following release routes back to it.
374    #[test]
375    fn click_through_overlay_consumes_when_child_handles() {
376        reset();
377        let (s, hits) = configured_sink(Rect::new(0.0, 0.0, 100.0, 100.0), false, true);
378        let id = register_overlay(s);
379
380        assert_eq!(dispatch_overlays(&press(50.0, 50.0)), EventResult::Handled);
381        // The gesture is captured: a release even outside the rect routes back to the overlay.
382        assert_eq!(
383            dispatch_overlays(&released(500.0, 500.0)),
384            EventResult::Handled
385        );
386        assert_eq!(hits.get(), 2);
387
388        unregister_overlay(id);
389    }
390}