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}