ailloli_ui_runtime/input/element_event_router.rs
1//! Event routing for a single retained element and its interaction metadata.
2
3use ailloli_ui_core::event::Event;
4use ailloli_ui_core::ids::ElementId;
5use ailloli_ui_core::Offset;
6use ailloli_ui_core::{ClipShape, Point, Rect};
7use std::sync::Arc;
8
9use crate::app::RuntimeHandle;
10use crate::element::{ElementKind, ElementTree};
11use crate::input::EventCtx;
12use crate::input::HitTestEngine;
13use crate::input::{EventEnvelope, EventMeta};
14
15/// Collects the local paint bounds of every element with committed layout.
16///
17/// Elements without a [`crate::layout::LayoutResult`] are omitted. Returned
18/// rectangles remain in their element's local logical-pixel coordinate space;
19/// use [`absolute_paint_bounds`] when global coordinates are required. Because
20/// the retained tree uses hash-map storage, output order is unspecified.
21///
22/// # Examples
23///
24/// ```
25/// use ailloli_ui_core::Rect;
26/// use ailloli_ui_runtime::{element::{ElementKind, ElementTree}, input::collect_hit_rects, layout::LayoutResult};
27/// let mut tree = ElementTree::<()>::new();
28/// let root = tree.create_element(ElementKind::Empty, None, None);
29/// let mut layout = LayoutResult::empty();
30/// layout.paint_bounds = Rect::new(1.0, 2.0, 30.0, 20.0);
31/// tree.get_mut(root).unwrap().layout = Some(layout);
32/// assert_eq!(collect_hit_rects(&tree), vec![(root, Rect::new(1.0, 2.0, 30.0, 20.0))]);
33/// ```
34pub fn collect_hit_rects<A>(tree: &ElementTree<A>) -> Vec<(ElementId, Rect)> {
35 let mut out = Vec::new();
36 for (id, el) in tree.iter_elements() {
37 let Some(layout) = el.layout.as_ref() else {
38 continue;
39 };
40 out.push((id, layout.paint_bounds));
41 }
42 out
43}
44
45/// Synchronously dispatches an event to one retained widget without bubbling.
46///
47/// Unknown targets, non-widget elements, and elements without committed layout
48/// are silently ignored. Widget callbacks receive absolute logical-pixel paint
49/// bounds and an [`EventCtx`] without host event metadata.
50///
51/// # Examples
52///
53/// ```
54/// use ailloli_ui_core::{event::{Event, FocusEvent}, ElementId};
55/// use ailloli_ui_runtime::{app::RuntimeHandle, element::ElementTree, input::dispatch_event_to_target};
56/// let tree = ElementTree::<()>::new();
57/// // A stale ID is a defined no-op.
58/// dispatch_event_to_target(&tree, RuntimeHandle::new(), ElementId(7), &Event::Focus(FocusEvent::new(true)));
59/// ```
60pub fn dispatch_event_to_target<A: 'static>(
61 tree: &ElementTree<A>,
62 runtime: RuntimeHandle<A>,
63 target: ElementId,
64 event: &Event,
65) {
66 let _ = dispatch_event_to_single_target(tree, runtime, target, event, None);
67}
68
69/// Synchronously dispatches one envelope to one widget without bubbling.
70///
71/// The envelope's [`EventMeta`] is cloned into the callback context. Unknown
72/// targets, non-widget elements, and elements without committed layout are
73/// silently ignored.
74///
75/// # Examples
76///
77/// ```
78/// use std::time::Duration;
79/// use ailloli_ui_core::{event::{Event, FocusEvent}, ElementId};
80/// use ailloli_ui_runtime::{app::{PresentationGeneration, RuntimeHandle}, element::ElementTree, input::{dispatch_event_envelope_to_target, EventEnvelope, EventId, EventMeta, EventTimestamp}};
81/// let envelope = EventEnvelope::new(
82/// EventMeta::new(EventId::new(1), EventTimestamp::new(Duration::ZERO), "main", PresentationGeneration::INITIAL),
83/// Event::Focus(FocusEvent::new(true)),
84/// );
85/// dispatch_event_envelope_to_target(&ElementTree::<()>::new(), RuntimeHandle::new(), ElementId(1), &envelope);
86/// ```
87pub fn dispatch_event_envelope_to_target<A: 'static>(
88 tree: &ElementTree<A>,
89 runtime: RuntimeHandle<A>,
90 target: ElementId,
91 envelope: &EventEnvelope,
92) {
93 let _ = dispatch_event_to_single_target(
94 tree,
95 runtime,
96 target,
97 envelope.event(),
98 Some(Arc::new(envelope.meta().clone())),
99 );
100}
101
102/// Dispatches to one laid-out widget and returns its propagation-stop flag.
103///
104/// `false` also represents an unknown, unlaid-out, or non-widget target.
105fn dispatch_event_to_single_target<A: 'static>(
106 tree: &ElementTree<A>,
107 runtime: RuntimeHandle<A>,
108 target: ElementId,
109 event: &Event,
110 event_meta: Option<Arc<EventMeta>>,
111) -> bool {
112 let Some(el) = tree.get(target) else {
113 return false;
114 };
115 let Some(layout) = el.layout.as_ref() else {
116 return false;
117 };
118 let bounds = absolute_paint_bounds(tree, target).unwrap_or(layout.paint_bounds);
119
120 if let ElementKind::Widget(widget) = &el.kind {
121 let mut ctx = match event_meta {
122 Some(event_meta) => EventCtx::new_with_event_meta(runtime, target, event_meta),
123 None => EventCtx::new(runtime, target),
124 };
125 widget.event(&mut ctx, event, bounds, layout);
126 return ctx.is_propagation_stopped();
127 }
128
129 false
130}
131
132/// Synchronously dispatches an event from a target through its ancestor chain.
133///
134/// The target is visited first. Each widget may stop propagation through its
135/// [`EventCtx`]; otherwise traversal follows retained `parent` links. Non-widget
136/// and unlaid-out elements receive no callback but do not stop traversal. The
137/// generated contexts contain no host event metadata.
138///
139/// # Examples
140///
141/// ```
142/// use ailloli_ui_core::event::{Event, FocusEvent};
143/// use ailloli_ui_runtime::{app::RuntimeHandle, element::{ElementKind, ElementTree}, input::dispatch_event_bubbling};
144/// let mut tree = ElementTree::<()>::new();
145/// let root = tree.create_element(ElementKind::Empty, None, None);
146/// let child = tree.create_element(ElementKind::Empty, None, Some(root));
147/// dispatch_event_bubbling(&tree, RuntimeHandle::new(), child, &Event::Focus(FocusEvent::new(false)));
148/// ```
149pub fn dispatch_event_bubbling<A: 'static>(
150 tree: &ElementTree<A>,
151 runtime: RuntimeHandle<A>,
152 target: ElementId,
153 event: &Event,
154) {
155 dispatch_event_bubbling_impl(tree, runtime, target, event, None);
156}
157
158/// Bubbles an envelope while preserving its host correlation metadata.
159///
160/// The target is visited before ancestors and propagation stops when a widget
161/// calls [`EventCtx::stop_propagation`]. Metadata is reference-counted across
162/// callbacks; the input envelope itself remains borrowed.
163///
164/// # Examples
165///
166/// ```
167/// use std::time::Duration;
168/// use ailloli_ui_core::event::{Event, FocusEvent};
169/// use ailloli_ui_runtime::{app::{PresentationGeneration, RuntimeHandle}, element::{ElementKind, ElementTree}, input::{dispatch_event_envelope_bubbling, EventEnvelope, EventId, EventMeta, EventTimestamp}};
170/// let mut tree = ElementTree::<()>::new();
171/// let target = tree.create_element(ElementKind::Empty, None, None);
172/// let envelope = EventEnvelope::new(
173/// EventMeta::new(EventId::new(3), EventTimestamp::new(Duration::ZERO), "main", PresentationGeneration::INITIAL),
174/// Event::Focus(FocusEvent::new(true)),
175/// );
176/// dispatch_event_envelope_bubbling(&tree, RuntimeHandle::new(), target, &envelope);
177/// ```
178pub fn dispatch_event_envelope_bubbling<A: 'static>(
179 tree: &ElementTree<A>,
180 runtime: RuntimeHandle<A>,
181 target: ElementId,
182 envelope: &EventEnvelope,
183) {
184 dispatch_event_bubbling_impl(
185 tree,
186 runtime,
187 target,
188 envelope.event(),
189 Some(Arc::new(envelope.meta().clone())),
190 );
191}
192
193/// Shared bubbling loop for raw events and metadata-bearing envelopes.
194fn dispatch_event_bubbling_impl<A: 'static>(
195 tree: &ElementTree<A>,
196 runtime: RuntimeHandle<A>,
197 target: ElementId,
198 event: &Event,
199 event_meta: Option<Arc<EventMeta>>,
200) {
201 let mut current = Some(target);
202 while let Some(id) = current {
203 if dispatch_event_to_single_target(tree, runtime.clone(), id, event, event_meta.clone()) {
204 break;
205 }
206 current = tree.parent_of(id);
207 }
208}
209
210/// Resolves one element's retained bounds in tree-global logical pixels.
211///
212/// The root uses its own `paint_bounds`. Descendants use the positional
213/// [`crate::layout::ChildLayout`] entry stored by each parent, accumulating its
214/// `offset` and `size`; parent links are not consulted. `None` means the tree
215/// has no root, a traversed element has no layout, the target is absent, or a
216/// child lacks a matching positional layout entry.
217///
218/// # Examples
219///
220/// ```
221/// use ailloli_ui_core::{Offset, Rect, Size};
222/// use ailloli_ui_runtime::{element::{ElementKind, ElementTree}, input::absolute_paint_bounds, layout::{ChildLayout, LayoutResult}};
223/// let mut tree = ElementTree::<()>::new();
224/// let root = tree.create_element(ElementKind::Empty, None, None);
225/// let child = tree.create_element(ElementKind::Empty, None, Some(root));
226/// tree.set_children(root, vec![child]);
227/// let mut root_layout = LayoutResult::empty();
228/// root_layout.paint_bounds = Rect::new(10.0, 20.0, 100.0, 80.0);
229/// root_layout.children.push(ChildLayout {
230/// offset: Offset::new(4.0, 6.0), size: Size::new(30.0, 12.0),
231/// paint_bounds: Rect::new(4.0, 6.0, 30.0, 12.0),
232/// visual_bounds: Rect::new(4.0, 6.0, 30.0, 12.0),
233/// });
234/// tree.get_mut(root).unwrap().layout = Some(root_layout);
235/// tree.get_mut(child).unwrap().layout = Some(LayoutResult::empty());
236/// assert_eq!(absolute_paint_bounds(&tree, child), Some(Rect::new(14.0, 26.0, 30.0, 12.0)));
237/// ```
238pub fn absolute_paint_bounds<A>(tree: &ElementTree<A>, target: ElementId) -> Option<Rect> {
239 let root = tree.root()?;
240 find_absolute_paint_bounds(tree, root, target, Offset::default())
241}
242
243/// Starts absolute-bound traversal from an element's own translated bounds.
244fn find_absolute_paint_bounds<A>(
245 tree: &ElementTree<A>,
246 element_id: ElementId,
247 target: ElementId,
248 origin: Offset,
249) -> Option<Rect> {
250 let el = tree.get(element_id)?;
251 let layout = el.layout.as_ref()?;
252 let bounds = layout.paint_bounds.translate(origin);
253
254 if element_id == target {
255 return Some(bounds);
256 }
257
258 for (idx, child_id) in el.children.iter().copied().enumerate() {
259 let Some(child_layout) = layout.children.get(idx) else {
260 continue;
261 };
262 let child_bounds = Rect::new(
263 bounds.x + child_layout.offset.x,
264 bounds.y + child_layout.offset.y,
265 child_layout.size.w,
266 child_layout.size.h,
267 );
268 if child_id == target {
269 return Some(child_bounds);
270 }
271 if let Some(bounds) =
272 find_absolute_paint_bounds_with_bounds(tree, child_id, target, child_bounds)
273 {
274 return Some(bounds);
275 }
276 }
277
278 None
279}
280
281/// Continues absolute-bound traversal with already-resolved current bounds.
282fn find_absolute_paint_bounds_with_bounds<A>(
283 tree: &ElementTree<A>,
284 element_id: ElementId,
285 target: ElementId,
286 bounds: Rect,
287) -> Option<Rect> {
288 if element_id == target {
289 return Some(bounds);
290 }
291
292 let el = tree.get(element_id)?;
293 let layout = el.layout.as_ref()?;
294
295 for (idx, child_id) in el.children.iter().copied().enumerate() {
296 let Some(child_layout) = layout.children.get(idx) else {
297 continue;
298 };
299 let child_bounds = Rect::new(
300 bounds.x + child_layout.offset.x,
301 bounds.y + child_layout.offset.y,
302 child_layout.size.w,
303 child_layout.size.h,
304 );
305 if child_id == target {
306 return Some(child_bounds);
307 }
308 if let Some(found) =
309 find_absolute_paint_bounds_with_bounds(tree, child_id, target, child_bounds)
310 {
311 return Some(found);
312 }
313 }
314
315 None
316}
317
318/// Returns the topmost retained widget containing a global logical point.
319///
320/// Overlay hit regions are searched first, in reverse child/paint order. The
321/// normal pass then requires the point to lie inside every ancestor bound and
322/// clip, visits later children first, and returns widgets only. `clip`, when
323/// present, is an additional global clip. The engine parameter is retained for
324/// API compatibility; tree traversal performs the test and records per-element
325/// hit-test diagnostics. Missing roots or layout yield `None`.
326///
327/// # Examples
328///
329/// ```
330/// use ailloli_ui_core::{Point, Rect};
331/// use ailloli_ui_runtime::{element::{ElementKind, ElementTree}, input::{hit_test_target, HitTestEngine}, layout::LayoutResult};
332/// let mut tree = ElementTree::<()>::new();
333/// let root = tree.create_element(ElementKind::Empty, None, None);
334/// let mut layout = LayoutResult::empty();
335/// layout.paint_bounds = Rect::new(0.0, 0.0, 20.0, 20.0);
336/// tree.get_mut(root).unwrap().layout = Some(layout);
337/// // Empty retained nodes are traversed but are not event targets.
338/// assert_eq!(hit_test_target(&tree, &HitTestEngine, Point::new(5.0, 5.0), None), None);
339/// ```
340pub fn hit_test_target<A>(
341 tree: &ElementTree<A>,
342 engine: &HitTestEngine,
343 pos: Point,
344 clip: Option<ClipShape>,
345) -> Option<ElementId> {
346 let _ = engine;
347
348 let root = tree.root()?;
349 if let Some(hit) = hit_test_overlay_bounds(tree, root, pos, Offset::default()) {
350 return Some(hit);
351 }
352
353 let mut clips = Vec::new();
354 if let Some(clip) = clip {
355 clips.push(clip);
356 }
357 hit_test_element(tree, root, pos, Offset::default(), clips)
358}
359
360/// Hit-tests only retained overlay regions, preserving their paint z-order.
361///
362/// Popup routing uses this as backend-confirmed evidence before the first
363/// paint has committed global popup bounds to the semantic portal. Overlay
364/// rectangles are element-local logical pixels translated by retained absolute
365/// bounds. They are searched later-child-first and are not constrained by the
366/// normal bounds/clip pass. Only widget owners can be returned.
367///
368/// # Examples
369///
370/// ```
371/// use ailloli_ui_core::Point;
372/// use ailloli_ui_runtime::{element::{ElementKind, ElementTree}, input::hit_test_overlay_target, layout::LayoutResult};
373/// let mut tree = ElementTree::<()>::new();
374/// let root = tree.create_element(ElementKind::Empty, None, None);
375/// tree.get_mut(root).unwrap().layout = Some(LayoutResult::empty());
376/// assert_eq!(hit_test_overlay_target(&tree, Point::new(0.0, 0.0)), None);
377/// ```
378pub fn hit_test_overlay_target<A>(tree: &ElementTree<A>, pos: Point) -> Option<ElementId> {
379 let root = tree.root()?;
380 hit_test_overlay_bounds(tree, root, pos, Offset::default())
381}
382
383/// Seeds overlay traversal from an element's translated local paint bounds.
384fn hit_test_overlay_bounds<A>(
385 tree: &ElementTree<A>,
386 element_id: ElementId,
387 pos: Point,
388 origin: Offset,
389) -> Option<ElementId> {
390 let el = tree.get(element_id)?;
391 let layout = el.layout.as_ref()?;
392 let bounds = layout.paint_bounds.translate(origin);
393 hit_test_overlay_bounds_with_bounds(tree, element_id, pos, bounds)
394}
395
396/// Searches descendant and current overlay regions in reverse paint order.
397fn hit_test_overlay_bounds_with_bounds<A>(
398 tree: &ElementTree<A>,
399 element_id: ElementId,
400 pos: Point,
401 bounds: Rect,
402) -> Option<ElementId> {
403 tree.record_hit_test(element_id);
404 let el = tree.get(element_id)?;
405 let layout = el.layout.as_ref()?;
406
407 for (idx, child_id) in el.children.iter().copied().enumerate().rev() {
408 let Some(child_layout) = layout.children.get(idx) else {
409 continue;
410 };
411 let child_bounds = Rect::new(
412 bounds.x + child_layout.offset.x,
413 bounds.y + child_layout.offset.y,
414 child_layout.size.w,
415 child_layout.size.h,
416 );
417 if let Some(hit) = hit_test_overlay_bounds_with_bounds(tree, child_id, pos, child_bounds) {
418 return Some(hit);
419 }
420 }
421
422 if matches!(&el.kind, ElementKind::Widget(_))
423 && layout.overlay_hit_bounds.iter().rev().any(|rect| {
424 rect.translate(Offset::new(bounds.x, bounds.y))
425 .contains(pos.x, pos.y)
426 })
427 {
428 return Some(element_id);
429 }
430
431 None
432}
433
434/// Seeds normal retained hit-testing from an element's translated bounds.
435fn hit_test_element<A>(
436 tree: &ElementTree<A>,
437 element_id: ElementId,
438 pos: Point,
439 origin: Offset,
440 clips: Vec<ClipShape>,
441) -> Option<ElementId> {
442 let el = tree.get(element_id)?;
443 let layout = el.layout.as_ref()?;
444 let bounds = layout.paint_bounds.translate(origin);
445 hit_test_element_with_bounds(tree, element_id, pos, bounds, clips)
446}
447
448/// Recursively applies accumulated clips/bounds and reverse-order child hits.
449fn hit_test_element_with_bounds<A>(
450 tree: &ElementTree<A>,
451 element_id: ElementId,
452 pos: Point,
453 bounds: Rect,
454 mut clips: Vec<ClipShape>,
455) -> Option<ElementId> {
456 tree.record_hit_test(element_id);
457 let el = tree.get(element_id)?;
458 let layout = el.layout.as_ref()?;
459
460 if let Some(local_clip) = layout.clip {
461 clips.push(translate_clip_shape(
462 local_clip,
463 Offset::new(bounds.x, bounds.y),
464 ));
465 }
466
467 if !clips
468 .iter()
469 .all(|clip| clip_shape_contains_point(*clip, pos.x, pos.y))
470 {
471 return None;
472 }
473
474 if !bounds.contains(pos.x, pos.y) {
475 return None;
476 }
477
478 for (idx, child_id) in el.children.iter().copied().enumerate().rev() {
479 let Some(child_layout) = layout.children.get(idx) else {
480 continue;
481 };
482 let child_bounds = Rect::new(
483 bounds.x + child_layout.offset.x,
484 bounds.y + child_layout.offset.y,
485 child_layout.size.w,
486 child_layout.size.h,
487 );
488 if let Some(hit) =
489 hit_test_element_with_bounds(tree, child_id, pos, child_bounds, clips.clone())
490 {
491 return Some(hit);
492 }
493 }
494
495 match &el.kind {
496 ElementKind::Widget(_) => Some(element_id),
497 _ => None,
498 }
499}
500
501/// Translates a clip's rectangle while preserving rounded-corner radii.
502fn translate_clip_shape(clip: ClipShape, origin: Offset) -> ClipShape {
503 match clip {
504 ClipShape::Rect(r) => ClipShape::Rect(r.translate(origin)),
505 ClipShape::RoundRect { rect, radius } => ClipShape::RoundRect {
506 rect: rect.translate(origin),
507 radius,
508 },
509 }
510}
511
512/// Tests one logical point using the core clip shape's edge/radius semantics.
513fn clip_shape_contains_point(clip: ClipShape, px: f32, py: f32) -> bool {
514 clip.contains_point(px, py)
515}
516
517#[cfg(test)]
518/// Geometry and clip regression tests for retained hit-testing.
519mod tests {
520 use super::*;
521
522 #[test]
523 /// Verifies that rounded clips reject corners outside their quarter circles.
524 fn round_rect_clip_excludes_transparent_corner() {
525 let clip = ClipShape::RoundRect {
526 rect: Rect::new(0.0, 0.0, 100.0, 100.0),
527 radius: 20.0,
528 };
529 assert!(!clip_shape_contains_point(clip, 2.0, 2.0));
530 assert!(clip_shape_contains_point(clip, 50.0, 50.0));
531 assert!(clip_shape_contains_point(clip, 15.0, 15.0));
532 }
533}