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    // Snapshot the registry (cheap `Rc` clones) and drop the borrow before dispatching: a handler may
114    // write signals whose deferred flush registers/unregisters an overlay, which would re-enter the borrow.
115    let (entries, captured) = with_overlays_ref(|r| (r.entries.clone(), r.captured));
116    if entries.is_empty() {
117        return EventResult::Ignored;
118    }
119    match event {
120        Event::PointerPressed { .. } => {
121            let (x, y) = pointer_pos(event).unwrap();
122            for (id, sink) in entries.iter().rev() {
123                if sink.content_rect().contains(x, y) {
124                    let handled = sink.dispatch(event) == EventResult::Handled;
125                    // A modal consumes the press regardless; a click-through overlay only when a child took
126                    // it — otherwise the loop continues to the overlays below and ultimately the tree.
127                    if sink.blocking() || handled {
128                        // Capture the gesture so following moves/releases route here wherever the pointer goes.
129                        with_overlays(|r| r.captured = Some(*id));
130                        return EventResult::Handled;
131                    }
132                }
133            }
134            EventResult::Ignored
135        }
136        Event::PointerMoved { .. } | Event::PointerReleased { .. } => {
137            let is_release = matches!(event, Event::PointerReleased { .. });
138            if let Some(cap_id) = captured {
139                if let Some((_, sink)) = entries.iter().find(|(id, _)| *id == cap_id) {
140                    sink.dispatch(event);
141                    if is_release {
142                        with_overlays(|r| r.captured = None);
143                    }
144                    return EventResult::Handled;
145                }
146                // The capturing overlay is gone (dismissed mid-gesture); drop the stale capture.
147                with_overlays(|r| r.captured = None);
148            }
149            let (x, y) = pointer_pos(event).unwrap();
150            for (_, sink) in entries.iter().rev() {
151                if sink.content_rect().contains(x, y) {
152                    let handled = sink.dispatch(event) == EventResult::Handled;
153                    // Same rule as a press: a modal swallows it over its whole barrier; a click-through one
154                    // only when a child handled it, else the move/release falls through to the layer behind.
155                    if sink.blocking() || handled {
156                        return EventResult::Handled;
157                    }
158                }
159            }
160            EventResult::Ignored
161        }
162        _ => EventResult::Ignored,
163    }
164}
165
166#[cfg(test)]
167fn reset() {
168    with_overlays(|r| *r = OverlayRegistry::new());
169}
170
171#[cfg(test)]
172mod tests {
173    use std::cell::Cell;
174
175    use platform_core::{PointerButton, PointerSource};
176
177    use super::*;
178
179    struct RecordingSink {
180        rect: Rect,
181        hits: Rc<Cell<u32>>,
182        blocking: bool,
183        // What `dispatch` reports: `true` mimics a child consuming the event, `false` a click that missed.
184        child_handles: bool,
185    }
186
187    impl OverlaySink for RecordingSink {
188        fn content_rect(&self) -> Rect {
189            self.rect
190        }
191        fn dispatch(&self, _event: &Event) -> EventResult {
192            self.hits.set(self.hits.get() + 1);
193            if self.child_handles {
194                EventResult::Handled
195            } else {
196                EventResult::Ignored
197            }
198        }
199        fn blocking(&self) -> bool {
200            self.blocking
201        }
202    }
203
204    // A blocking overlay whose children always handle — the default used by the routing/capture tests.
205    fn sink(rect: Rect) -> (Rc<dyn OverlaySink>, Rc<Cell<u32>>) {
206        configured_sink(rect, true, true)
207    }
208
209    fn configured_sink(
210        rect: Rect,
211        blocking: bool,
212        child_handles: bool,
213    ) -> (Rc<dyn OverlaySink>, Rc<Cell<u32>>) {
214        let hits = Rc::new(Cell::new(0));
215        let sink: Rc<dyn OverlaySink> = Rc::new(RecordingSink {
216            rect,
217            hits: Rc::clone(&hits),
218            blocking,
219            child_handles,
220        });
221        (sink, hits)
222    }
223
224    fn press(x: f64, y: f64) -> Event {
225        Event::PointerPressed {
226            x,
227            y,
228            button: PointerButton::Primary,
229            source: PointerSource::Mouse,
230        }
231    }
232    fn moved(x: f64, y: f64) -> Event {
233        Event::PointerMoved {
234            x,
235            y,
236            source: PointerSource::Mouse,
237        }
238    }
239    fn released(x: f64, y: f64) -> Event {
240        Event::PointerReleased {
241            x,
242            y,
243            button: PointerButton::Primary,
244            source: PointerSource::Mouse,
245        }
246    }
247
248    #[test]
249    fn no_overlays_falls_through() {
250        reset();
251        assert_eq!(dispatch_overlays(&press(10.0, 10.0)), EventResult::Ignored);
252    }
253
254    #[test]
255    fn press_inside_is_consumed_outside_falls_through() {
256        reset();
257        let (s, hits) = sink(Rect::new(0.0, 0.0, 100.0, 100.0));
258        let id = register_overlay(s);
259
260        // Inside the overlay: consumed (blocks the content behind) and delivered to the sink.
261        assert_eq!(dispatch_overlays(&press(50.0, 50.0)), EventResult::Handled);
262        assert_eq!(hits.get(), 1);
263        // Release ends the gesture. Outside the overlay: falls through to the tree, sink untouched.
264        dispatch_overlays(&released(50.0, 50.0));
265        assert_eq!(
266            dispatch_overlays(&press(500.0, 500.0)),
267            EventResult::Ignored
268        );
269
270        unregister_overlay(id);
271    }
272
273    #[test]
274    fn topmost_overlay_wins() {
275        reset();
276        let (bottom, bottom_hits) = sink(Rect::new(0.0, 0.0, 100.0, 100.0));
277        let (top, top_hits) = sink(Rect::new(0.0, 0.0, 100.0, 100.0));
278        let b = register_overlay(bottom);
279        let t = register_overlay(top);
280
281        dispatch_overlays(&press(50.0, 50.0));
282        assert_eq!(
283            top_hits.get(),
284            1,
285            "topmost (last registered) receives the press"
286        );
287        assert_eq!(
288            bottom_hits.get(),
289            0,
290            "the overlay below must not also get it"
291        );
292
293        unregister_overlay(t);
294        unregister_overlay(b);
295    }
296
297    #[test]
298    fn capture_routes_moves_and_release_even_outside() {
299        reset();
300        let (s, hits) = sink(Rect::new(0.0, 0.0, 100.0, 100.0));
301        let id = register_overlay(s);
302
303        // Press inside captures the gesture.
304        assert_eq!(dispatch_overlays(&press(50.0, 50.0)), EventResult::Handled);
305        // A move that leaves the overlay still routes to it (a drag started inside keeps tracking).
306        assert_eq!(
307            dispatch_overlays(&moved(500.0, 500.0)),
308            EventResult::Handled
309        );
310        // The release, also outside, reaches the overlay and ends the capture.
311        assert_eq!(
312            dispatch_overlays(&released(500.0, 500.0)),
313            EventResult::Handled
314        );
315        assert_eq!(hits.get(), 3);
316        // After release, an outside press falls through again.
317        assert_eq!(
318            dispatch_overlays(&press(500.0, 500.0)),
319            EventResult::Ignored
320        );
321
322        unregister_overlay(id);
323    }
324
325    #[test]
326    fn unregister_stops_routing_and_clears_capture() {
327        reset();
328        let (s, _hits) = sink(Rect::new(0.0, 0.0, 100.0, 100.0));
329        let id = register_overlay(s);
330        // Capture a gesture, then unregister (as a dismissed modal would on drop) before the release.
331        dispatch_overlays(&press(50.0, 50.0));
332        unregister_overlay(id);
333        // With no overlays left, everything falls through and no stale capture lingers.
334        assert_eq!(
335            dispatch_overlays(&released(50.0, 50.0)),
336            EventResult::Ignored
337        );
338        assert_eq!(dispatch_overlays(&press(50.0, 50.0)), EventResult::Ignored);
339    }
340
341    // A modal (blocking) overlay swallows a press inside its content rect even where no child sits, so the
342    // scrim reads as modal and nothing behind it receives the press.
343    #[test]
344    fn blocking_overlay_consumes_press_in_empty_region() {
345        reset();
346        let (s, hits) = configured_sink(Rect::new(0.0, 0.0, 100.0, 100.0), true, false);
347        let id = register_overlay(s);
348
349        assert_eq!(dispatch_overlays(&press(50.0, 50.0)), EventResult::Handled);
350        assert_eq!(
351            hits.get(),
352            1,
353            "the modal is still asked to dispatch the press"
354        );
355
356        unregister_overlay(id);
357    }
358
359    // A click-through overlay does NOT consume a press its children ignore (the click landed on the
360    // transparent absolute-fill area, not the visible panel): it falls through to the tree behind.
361    #[test]
362    fn click_through_overlay_falls_through_when_child_ignores() {
363        reset();
364        let (s, hits) = configured_sink(Rect::new(0.0, 0.0, 100.0, 100.0), false, false);
365        let id = register_overlay(s);
366
367        assert_eq!(dispatch_overlays(&press(50.0, 50.0)), EventResult::Ignored);
368        assert_eq!(
369            hits.get(),
370            1,
371            "the overlay is offered the press before falling through"
372        );
373        // A move afterwards also falls through (no gesture was captured by the click-through overlay).
374        assert_eq!(dispatch_overlays(&moved(50.0, 50.0)), EventResult::Ignored);
375
376        unregister_overlay(id);
377    }
378
379    // A click-through overlay DOES consume a press when a child handles it (the click hit the panel),
380    // capturing the gesture so the following release routes back to it.
381    #[test]
382    fn click_through_overlay_consumes_when_child_handles() {
383        reset();
384        let (s, hits) = configured_sink(Rect::new(0.0, 0.0, 100.0, 100.0), false, true);
385        let id = register_overlay(s);
386
387        assert_eq!(dispatch_overlays(&press(50.0, 50.0)), EventResult::Handled);
388        // The gesture is captured: a release even outside the rect routes back to the overlay.
389        assert_eq!(
390            dispatch_overlays(&released(500.0, 500.0)),
391            EventResult::Handled
392        );
393        assert_eq!(hits.get(), 2);
394
395        unregister_overlay(id);
396    }
397}