Skip to main content

agg_gui/widget/
tree.rs

1//! Widget-tree traversal helpers: dirty marking and hit-test path lookup.
2//!
3//! [`mark_subtree_dirty`] is called by the frame loop after off-event-path
4//! async work finishes (font load, image decode). The hit-test family —
5//! [`hit_test_subtree`], [`active_modal_path`], [`global_overlay_hit_path`]
6//! — returns a `Vec<usize>` path of child indices identifying the deepest
7//! widget that claims a position; the App event router uses that path to
8//! dispatch mouse events to the right subtree.
9//!
10//! # Coordinate system
11//!
12//! All positions in this module are **logical Y-up**, origin at the
13//! bottom-left, expressed in the receiving widget's own local space (the
14//! caller has already subtracted ancestor `bounds().x/y`). The App
15//! pre-converts platform Y-down input coordinates via `App::flip_y`
16//! before calling in — do not flip Y here.
17
18use super::*;
19
20/// Map a point from a parent's local space into the local space of its child
21/// at index `idx`, honouring the parent's optional
22/// [`Widget::child_transform`](crate::widget::Widget::child_transform).
23///
24/// The child transform (if any) is inverted first — it maps child→parent, so
25/// its inverse maps the incoming parent-local point back into child space —
26/// and only then is the child's `bounds()` offset removed.  Bounds are thus
27/// interpreted *inside* the transform, matching how [`paint_subtree`] applies
28/// the transform to the whole child group before translating per child.
29fn child_local_pos(parent: &dyn Widget, child_bounds: Rect, pos_in_parent: Point) -> Point {
30    let mut x = pos_in_parent.x;
31    let mut y = pos_in_parent.y;
32    if let Some(t) = parent.child_transform() {
33        t.inverse_transform(&mut x, &mut y);
34    }
35    Point::new(x - child_bounds.x, y - child_bounds.y)
36}
37
38/// Recursively call `mark_dirty` on `widget` and every visible
39/// descendant.  Used by the host frame loop after an async data
40/// source (image fetch + decode, font load, etc.) finishes outside
41/// the normal event-dispatch path that would otherwise mark widgets
42/// dirty as the event bubbles.  Called explicitly at the top of the
43/// frame so the user-visible "freshly-decoded data lands in stale
44/// FBO contents" bug never opens a one-frame race window.
45pub fn mark_subtree_dirty(widget: &mut dyn Widget) {
46    widget.mark_dirty();
47    for child in widget.children_mut().iter_mut() {
48        mark_subtree_dirty(child.as_mut());
49    }
50}
51
52/// Walk the subtree rooted at `widget` and return the path (list of child
53/// indices) to the deepest widget that passes `hit_test` at `local_pos`.
54///
55/// `local_pos` is expressed in `widget`'s coordinate space (not including
56/// `widget.bounds().x/y` — the caller has already accounted for that).
57///
58/// Returns `Some(vec![])` if `widget` itself is hit but no child is.
59/// Returns `None` if nothing is hit.
60pub fn hit_test_subtree(widget: &dyn Widget, local_pos: Point) -> Option<Vec<usize>> {
61    if !widget.is_visible() || !widget.hit_test(local_pos) {
62        return None;
63    }
64    // Let overlays (e.g. a floating scrollbar) claim the pointer before any
65    // child that happens to cover the same pixels, and let a disabled scope
66    // swallow the pointer so clicks never reach its (non-interactive) children.
67    if widget.claims_pointer_exclusively(local_pos) || widget.blocks_child_interaction() {
68        return Some(vec![]);
69    }
70    // Check children in reverse order (last drawn = topmost = highest priority).
71    for (i, child) in widget.children().iter().enumerate().rev() {
72        let child_local = child_local_pos(widget, child.bounds(), local_pos);
73        if let Some(mut sub_path) = hit_test_subtree(child.as_ref(), child_local) {
74            sub_path.insert(0, i);
75            return Some(sub_path);
76        }
77    }
78    Some(vec![]) // hit this widget, no child claimed it
79}
80
81/// Return the path to the topmost active modal subtree, ignoring normal
82/// hit-testing bounds. Modal overlays paint at app level, so their event
83/// routing must also bypass regular child clipping/window hit regions.
84pub fn active_modal_path(widget: &dyn Widget) -> Option<Vec<usize>> {
85    if !widget.is_visible() {
86        return None;
87    }
88    for (i, child) in widget.children().iter().enumerate().rev() {
89        if let Some(mut sub_path) = active_modal_path(child.as_ref()) {
90            sub_path.insert(0, i);
91            return Some(sub_path);
92        }
93    }
94    if widget.has_active_modal() {
95        Some(vec![])
96    } else {
97        None
98    }
99}
100
101/// Path to the first visible widget, in depth-first paint order, that
102/// answers `true` to `pred`. Subtrees that own an active modal are skipped
103/// when `skip_modals` is set — the `App` uses that for the root-level
104/// default/cancel action so a showing sheet's buttons (already offered the
105/// key by the sheet itself) are never fired twice.
106fn find_action_path(
107    widget: &dyn Widget,
108    pred: &dyn Fn(&dyn Widget) -> bool,
109    skip_modals: bool,
110) -> Option<Vec<usize>> {
111    if !widget.is_visible() || (skip_modals && widget.has_active_modal()) {
112        return None;
113    }
114    if pred(widget) {
115        return Some(vec![]);
116    }
117    for (i, child) in widget.children().iter().enumerate() {
118        if let Some(mut sub) = find_action_path(child.as_ref(), pred, skip_modals) {
119            sub.insert(0, i);
120            return Some(sub);
121        }
122    }
123    None
124}
125
126/// Path to the first visible widget whose `WidgetBase` has
127/// [`default_action`](crate::layout_props::WidgetBase::default_action)
128/// set, or `None`. See `find_action_path` for `skip_modals`.
129pub fn default_action_path(widget: &dyn Widget, skip_modals: bool) -> Option<Vec<usize>> {
130    find_action_path(
131        widget,
132        &|w| w.widget_base().is_some_and(|b| b.default_action),
133        skip_modals,
134    )
135}
136
137/// Path to the first visible widget whose `WidgetBase` has
138/// [`cancel_action`](crate::layout_props::WidgetBase::cancel_action) set,
139/// or `None`.
140pub fn cancel_action_path(widget: &dyn Widget, skip_modals: bool) -> Option<Vec<usize>> {
141    find_action_path(
142        widget,
143        &|w| w.widget_base().is_some_and(|b| b.cancel_action),
144        skip_modals,
145    )
146}
147
148/// Activate the widget at `path` as a keyboard default/cancel action by
149/// dispatching an `Enter` key-down to it (a `Button` fires its click and
150/// consumes it). Returns the dispatch result — `Ignored` when the target
151/// is disabled or otherwise declined — so the caller can fall back.
152pub fn activate_action_at(root: &mut dyn Widget, path: &[usize]) -> EventResult {
153    let event = Event::KeyDown {
154        key: Key::Enter,
155        modifiers: Modifiers::default(),
156    };
157    dispatch_event_dyn(root, path, &event, Point::ORIGIN)
158}
159
160/// Return the topmost widget whose app-level overlay contains `local_pos`.
161///
162/// This intentionally ignores ancestor `hit_test` bounds while descending:
163/// global overlays such as ComboBox popups are painted outside their normal
164/// parent clip/bounds, so their event routing must escape those bounds too.
165pub fn global_overlay_hit_path(widget: &dyn Widget, local_pos: Point) -> Option<Vec<usize>> {
166    if !widget.is_visible() {
167        return None;
168    }
169    for (i, child) in widget.children().iter().enumerate().rev() {
170        let child_local = child_local_pos(widget, child.bounds(), local_pos);
171        if let Some(mut sub_path) = global_overlay_hit_path(child.as_ref(), child_local) {
172            sub_path.insert(0, i);
173            return Some(sub_path);
174        }
175    }
176    if widget.hit_test_global_overlay(local_pos) {
177        Some(vec![])
178    } else {
179        None
180    }
181}
182
183/// Centralized event delivery: call the widget's `on_event` and apply the
184/// framework's automatic-invalidation policy in ONE place, so no dispatch
185/// path can silently forget it.  A [`EventResult::Consumed`] result schedules
186/// a repaint via [`crate::animation::request_draw`];
187/// [`EventResult::ConsumedQuiet`] and [`EventResult::Ignored`] do not.
188///
189/// This single hook makes "consume ⇒ repaint" the default, fixing the
190/// recurring bug class where a widget mutates paint-affecting state on an
191/// event but forgets to request a draw, leaving part of itself stale.
192fn deliver(widget: &mut dyn Widget, event: &Event) -> EventResult {
193    auto_request_draw(widget.on_event(event))
194}
195
196/// Apply the auto-invalidation policy to an already-computed [`EventResult`].
197/// Shared by [`deliver`] (for `on_event`) and the unconsumed-key path (for
198/// `on_unconsumed_key`) so every delivery route honours the same rule.
199fn auto_request_draw(result: EventResult) -> EventResult {
200    if result.requests_redraw() {
201        crate::animation::request_draw();
202    }
203    result
204}
205
206/// Dispatch `event` through a path (list of child indices from the root).
207/// The event bubbles leaf → root; returns a consuming result if any widget
208/// consumed it (preserving the `Consumed` vs `ConsumedQuiet` distinction).
209///
210/// `pos_in_root` is the event position in the root widget's coordinate space.
211/// The function translates it down through each level of the path.
212pub fn dispatch_event(
213    root: &mut Box<dyn Widget>,
214    path: &[usize],
215    event: &Event,
216    pos_in_root: Point,
217) -> EventResult {
218    if path.is_empty() {
219        let before = crate::animation::invalidation_epoch();
220        let result = deliver(root.as_mut(), event);
221        if result.requests_redraw() || before != crate::animation::invalidation_epoch() {
222            root.mark_dirty();
223        }
224        return result;
225    }
226    let idx = path[0];
227    // Path can become stale between when it was captured (hit-test or
228    // previous-frame hovered/focus) and when it is dispatched — e.g. a
229    // CollapsingHeader collapsed since then and dropped its child.  Rather
230    // than panic, just stop descending and deliver the event at this level.
231    if idx >= root.children().len() {
232        return deliver(root.as_mut(), event);
233    }
234    let child_bounds = root.children()[idx].bounds();
235    let child_pos = child_local_pos(root.as_ref(), child_bounds, pos_in_root);
236    let translated_event = translate_event(event, child_pos);
237
238    let before_child = crate::animation::invalidation_epoch();
239    let child_result = dispatch_event(
240        &mut root.children_mut()[idx],
241        &path[1..],
242        &translated_event,
243        child_pos,
244    );
245    // A child (or descendant) that requested a draw bumped the epoch —
246    // invalidate our own cache so the retained backbuffer re-rasters. A
247    // quiet consume bumps nothing, so it correctly leaves the cache alone.
248    if before_child != crate::animation::invalidation_epoch() {
249        root.mark_dirty();
250    }
251    if child_result.is_consumed() {
252        return child_result;
253    }
254    // Bubble: deliver to this widget too (with original pos_in_root coords).
255    let before_self = crate::animation::invalidation_epoch();
256    let result = deliver(root.as_mut(), event);
257    if result.requests_redraw() || before_self != crate::animation::invalidation_epoch() {
258        root.mark_dirty();
259    }
260    result
261}
262
263/// Variant of [`dispatch_event`] that accepts `&mut dyn Widget` as the
264/// root. Useful when a parent owns a sub-tree by concrete type (e.g.
265/// `Window`'s `title_bar: WindowTitleBar`) and wants to route an event
266/// into it via the framework's standard hit-test + bubble dispatch,
267/// instead of running coordinate hit-tests inline.
268pub fn dispatch_event_dyn(
269    root: &mut dyn Widget,
270    path: &[usize],
271    event: &Event,
272    pos_in_root: Point,
273) -> EventResult {
274    if path.is_empty() {
275        let before = crate::animation::invalidation_epoch();
276        let result = deliver(root, event);
277        if result.requests_redraw() || before != crate::animation::invalidation_epoch() {
278            root.mark_dirty();
279        }
280        return result;
281    }
282    let idx = path[0];
283    if idx >= root.children().len() {
284        return deliver(root, event);
285    }
286    let child_bounds = root.children()[idx].bounds();
287    let child_pos = child_local_pos(root, child_bounds, pos_in_root);
288    let translated_event = translate_event(event, child_pos);
289
290    let before_child = crate::animation::invalidation_epoch();
291    // After the first hop we're inside the Vec<Box<dyn Widget>>, so we
292    // can fall back to the regular Box-based dispatcher.
293    let child_result = dispatch_event(
294        &mut root.children_mut()[idx],
295        &path[1..],
296        &translated_event,
297        child_pos,
298    );
299    if before_child != crate::animation::invalidation_epoch() {
300        root.mark_dirty();
301    }
302    if child_result.is_consumed() {
303        return child_result;
304    }
305    let before_self = crate::animation::invalidation_epoch();
306    let result = deliver(root, event);
307    if result.requests_redraw() || before_self != crate::animation::invalidation_epoch() {
308        root.mark_dirty();
309    }
310    result
311}
312
313/// Give visible widgets a chance to handle a key ignored by the focused path.
314///
315/// Traverses in reverse paint order so topmost windows/menu bars win.
316pub fn dispatch_unconsumed_key(
317    widget: &mut dyn Widget,
318    key: &Key,
319    modifiers: Modifiers,
320) -> EventResult {
321    if !widget.is_visible() {
322        return EventResult::Ignored;
323    }
324    let mut consumed = None;
325    for child in widget.children_mut().iter_mut().rev() {
326        let r = dispatch_unconsumed_key(child.as_mut(), key, modifiers);
327        if r.is_consumed() {
328            consumed = Some(r);
329            break;
330        }
331    }
332    if let Some(r) = consumed {
333        widget.mark_dirty();
334        return r;
335    }
336    let before = crate::animation::invalidation_epoch();
337    let result = auto_request_draw(widget.on_unconsumed_key(key, modifiers));
338    if result.requests_redraw() || before != crate::animation::invalidation_epoch() {
339        widget.mark_dirty();
340    }
341    result
342}
343
344/// Produce a version of `event` with mouse positions replaced by `new_pos`.
345/// Non-mouse events (key, focus) are returned unchanged.
346fn translate_event(event: &Event, new_pos: Point) -> Event {
347    match event {
348        Event::MouseMove { .. } => Event::MouseMove { pos: new_pos },
349        Event::MouseDown {
350            button, modifiers, ..
351        } => Event::MouseDown {
352            pos: new_pos,
353            button: *button,
354            modifiers: *modifiers,
355        },
356        Event::MouseUp {
357            button, modifiers, ..
358        } => Event::MouseUp {
359            pos: new_pos,
360            button: *button,
361            modifiers: *modifiers,
362        },
363        Event::MouseWheel {
364            delta_y,
365            delta_x,
366            modifiers,
367            ..
368        } => Event::MouseWheel {
369            pos: new_pos,
370            delta_y: *delta_y,
371            delta_x: *delta_x,
372            modifiers: *modifiers,
373        },
374        Event::FileDropped { paths, .. } => Event::FileDropped {
375            pos: new_pos,
376            paths: paths.clone(),
377        },
378        // Localize the centroid exactly like a mouse `pos`, but leave the
379        // deltas untouched: `translation_delta` (and zoom/rotation) are
380        // displacement vectors, invariant under the pure-translation change
381        // of coordinate frame that descending the tree performs.
382        Event::MultiTouch { info } => {
383            let mut info = *info;
384            info.center_pos = new_pos;
385            Event::MultiTouch { info }
386        }
387        other => other.clone(),
388    }
389}
390
391/// Offer `event` to every visible widget in the subtree, depth-first with
392/// the same topmost-last-child priority as [`hit_test_subtree`], stopping
393/// at the first consumer. Positions are translated into each widget's
394/// local space on the way down, exactly like [`dispatch_event`].
395///
396/// This exists for events that must not be lost when the widget under
397/// their position ignores them — file drops foremost: native shells can't
398/// always produce an accurate drop position (winit's Windows backend
399/// discards the OLE drop point and emits no CursorMoved during the drag),
400/// so the position may point at chrome while the only interested handler
401/// (a canvas) sits in a sibling subtree the bubble path never reaches.
402pub fn dispatch_event_broadcast(
403    root: &mut Box<dyn Widget>,
404    event: &Event,
405    pos_in_root: Point,
406) -> EventResult {
407    if !root.is_visible() {
408        return EventResult::Ignored;
409    }
410    for i in (0..root.children().len()).rev() {
411        let child_bounds = root.children()[i].bounds();
412        let child_pos = child_local_pos(root.as_ref(), child_bounds, pos_in_root);
413        let translated = translate_event(event, child_pos);
414        let r = dispatch_event_broadcast(&mut root.children_mut()[i], &translated, child_pos);
415        if r.is_consumed() {
416            root.mark_dirty();
417            return r;
418        }
419    }
420    let result = deliver(root.as_mut(), event);
421    if result.is_consumed() {
422        root.mark_dirty();
423    }
424    result
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430    use crate::touch_state::{MultiTouchInfo, TouchDeviceId};
431
432    /// `translate_event` must localize a `MultiTouch` centroid exactly like a
433    /// mouse `pos`, while leaving the displacement deltas (translation, zoom,
434    /// rotation) untouched — they are frame-invariant vectors.
435    #[test]
436    fn multi_touch_localizes_center_but_not_deltas() {
437        let info = MultiTouchInfo {
438            device_id: TouchDeviceId(0),
439            num_touches: 2,
440            zoom_delta: 1.5,
441            rotation_delta: 0.3,
442            translation_delta: Point::new(10.0, 5.0),
443            force: 0.0,
444            center_pos: Point::new(100.0, 200.0),
445        };
446        let local = Point::new(7.0, 9.0);
447        let out = translate_event(&Event::MultiTouch { info }, local);
448        let Event::MultiTouch { info: out } = out else {
449            panic!("translate_event changed the event kind");
450        };
451        // Centroid follows the localized position.
452        assert_eq!(out.center_pos.x, 7.0);
453        assert_eq!(out.center_pos.y, 9.0);
454        // Displacement vector unchanged — NOT offset by the localization.
455        assert_eq!(out.translation_delta.x, 10.0);
456        assert_eq!(out.translation_delta.y, 5.0);
457        assert_eq!(out.zoom_delta, 1.5);
458        assert_eq!(out.rotation_delta, 0.3);
459    }
460}