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}