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}