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}