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/// Recursively call `mark_dirty` on `widget` and every visible
21/// descendant.  Used by the host frame loop after an async data
22/// source (image fetch + decode, font load, etc.) finishes outside
23/// the normal event-dispatch path that would otherwise mark widgets
24/// dirty as the event bubbles.  Called explicitly at the top of the
25/// frame so the user-visible "freshly-decoded data lands in stale
26/// FBO contents" bug never opens a one-frame race window.
27pub fn mark_subtree_dirty(widget: &mut dyn Widget) {
28    widget.mark_dirty();
29    for child in widget.children_mut().iter_mut() {
30        mark_subtree_dirty(child.as_mut());
31    }
32}
33
34/// Walk the subtree rooted at `widget` and return the path (list of child
35/// indices) to the deepest widget that passes `hit_test` at `local_pos`.
36///
37/// `local_pos` is expressed in `widget`'s coordinate space (not including
38/// `widget.bounds().x/y` — the caller has already accounted for that).
39///
40/// Returns `Some(vec![])` if `widget` itself is hit but no child is.
41/// Returns `None` if nothing is hit.
42pub fn hit_test_subtree(widget: &dyn Widget, local_pos: Point) -> Option<Vec<usize>> {
43    if !widget.is_visible() || !widget.hit_test(local_pos) {
44        return None;
45    }
46    // Let overlays (e.g. a floating scrollbar) claim the pointer before any
47    // child that happens to cover the same pixels.
48    if widget.claims_pointer_exclusively(local_pos) {
49        return Some(vec![]);
50    }
51    // Check children in reverse order (last drawn = topmost = highest priority).
52    for (i, child) in widget.children().iter().enumerate().rev() {
53        let child_local = Point::new(
54            local_pos.x - child.bounds().x,
55            local_pos.y - child.bounds().y,
56        );
57        if let Some(mut sub_path) = hit_test_subtree(child.as_ref(), child_local) {
58            sub_path.insert(0, i);
59            return Some(sub_path);
60        }
61    }
62    Some(vec![]) // hit this widget, no child claimed it
63}
64
65/// Return the path to the topmost active modal subtree, ignoring normal
66/// hit-testing bounds. Modal overlays paint at app level, so their event
67/// routing must also bypass regular child clipping/window hit regions.
68pub fn active_modal_path(widget: &dyn Widget) -> Option<Vec<usize>> {
69    if !widget.is_visible() {
70        return None;
71    }
72    for (i, child) in widget.children().iter().enumerate().rev() {
73        if let Some(mut sub_path) = active_modal_path(child.as_ref()) {
74            sub_path.insert(0, i);
75            return Some(sub_path);
76        }
77    }
78    if widget.has_active_modal() {
79        Some(vec![])
80    } else {
81        None
82    }
83}
84
85/// Return the topmost widget whose app-level overlay contains `local_pos`.
86///
87/// This intentionally ignores ancestor `hit_test` bounds while descending:
88/// global overlays such as ComboBox popups are painted outside their normal
89/// parent clip/bounds, so their event routing must escape those bounds too.
90pub fn global_overlay_hit_path(widget: &dyn Widget, local_pos: Point) -> Option<Vec<usize>> {
91    if !widget.is_visible() {
92        return None;
93    }
94    for (i, child) in widget.children().iter().enumerate().rev() {
95        let child_local = Point::new(
96            local_pos.x - child.bounds().x,
97            local_pos.y - child.bounds().y,
98        );
99        if let Some(mut sub_path) = global_overlay_hit_path(child.as_ref(), child_local) {
100            sub_path.insert(0, i);
101            return Some(sub_path);
102        }
103    }
104    if widget.hit_test_global_overlay(local_pos) {
105        Some(vec![])
106    } else {
107        None
108    }
109}
110
111/// Dispatch `event` through a path (list of child indices from the root).
112/// The event bubbles leaf → root; returns `Consumed` if any widget consumed it.
113///
114/// `pos_in_root` is the event position in the root widget's coordinate space.
115/// The function translates it down through each level of the path.
116pub fn dispatch_event(
117    root: &mut Box<dyn Widget>,
118    path: &[usize],
119    event: &Event,
120    pos_in_root: Point,
121) -> EventResult {
122    if path.is_empty() {
123        let before = crate::animation::invalidation_epoch();
124        let result = root.on_event(event);
125        if result == EventResult::Consumed || before != crate::animation::invalidation_epoch() {
126            root.mark_dirty();
127        }
128        return result;
129    }
130    let idx = path[0];
131    // Path can become stale between when it was captured (hit-test or
132    // previous-frame hovered/focus) and when it is dispatched — e.g. a
133    // CollapsingHeader collapsed since then and dropped its child.  Rather
134    // than panic, just stop descending and deliver the event at this level.
135    if idx >= root.children().len() {
136        return root.on_event(event);
137    }
138    let child_bounds = root.children()[idx].bounds();
139    let child_pos = Point::new(
140        pos_in_root.x - child_bounds.x,
141        pos_in_root.y - child_bounds.y,
142    );
143    let translated_event = translate_event(event, child_pos);
144
145    let before_child = crate::animation::invalidation_epoch();
146    let child_result = dispatch_event(
147        &mut root.children_mut()[idx],
148        &path[1..],
149        &translated_event,
150        child_pos,
151    );
152    if child_result == EventResult::Consumed {
153        root.mark_dirty();
154        return EventResult::Consumed;
155    }
156    if before_child != crate::animation::invalidation_epoch() {
157        root.mark_dirty();
158    }
159    // Bubble: deliver to this widget too (with original pos_in_root coords).
160    let before_self = crate::animation::invalidation_epoch();
161    let result = root.on_event(event);
162    if result == EventResult::Consumed || before_self != crate::animation::invalidation_epoch() {
163        root.mark_dirty();
164    }
165    result
166}
167
168/// Variant of [`dispatch_event`] that accepts `&mut dyn Widget` as the
169/// root. Useful when a parent owns a sub-tree by concrete type (e.g.
170/// `Window`'s `title_bar: WindowTitleBar`) and wants to route an event
171/// into it via the framework's standard hit-test + bubble dispatch,
172/// instead of running coordinate hit-tests inline.
173pub fn dispatch_event_dyn(
174    root: &mut dyn Widget,
175    path: &[usize],
176    event: &Event,
177    pos_in_root: Point,
178) -> EventResult {
179    if path.is_empty() {
180        let before = crate::animation::invalidation_epoch();
181        let result = root.on_event(event);
182        if result == EventResult::Consumed || before != crate::animation::invalidation_epoch() {
183            root.mark_dirty();
184        }
185        return result;
186    }
187    let idx = path[0];
188    if idx >= root.children().len() {
189        return root.on_event(event);
190    }
191    let child_bounds = root.children()[idx].bounds();
192    let child_pos = Point::new(
193        pos_in_root.x - child_bounds.x,
194        pos_in_root.y - child_bounds.y,
195    );
196    let translated_event = translate_event(event, child_pos);
197
198    let before_child = crate::animation::invalidation_epoch();
199    // After the first hop we're inside the Vec<Box<dyn Widget>>, so we
200    // can fall back to the regular Box-based dispatcher.
201    let child_result = dispatch_event(
202        &mut root.children_mut()[idx],
203        &path[1..],
204        &translated_event,
205        child_pos,
206    );
207    if child_result == EventResult::Consumed {
208        root.mark_dirty();
209        return EventResult::Consumed;
210    }
211    if before_child != crate::animation::invalidation_epoch() {
212        root.mark_dirty();
213    }
214    let before_self = crate::animation::invalidation_epoch();
215    let result = root.on_event(event);
216    if result == EventResult::Consumed || before_self != crate::animation::invalidation_epoch() {
217        root.mark_dirty();
218    }
219    result
220}
221
222/// Give visible widgets a chance to handle a key ignored by the focused path.
223///
224/// Traverses in reverse paint order so topmost windows/menu bars win.
225pub fn dispatch_unconsumed_key(
226    widget: &mut dyn Widget,
227    key: &Key,
228    modifiers: Modifiers,
229) -> EventResult {
230    if !widget.is_visible() {
231        return EventResult::Ignored;
232    }
233    for child in widget.children_mut().iter_mut().rev() {
234        if dispatch_unconsumed_key(child.as_mut(), key, modifiers) == EventResult::Consumed {
235            widget.mark_dirty();
236            return EventResult::Consumed;
237        }
238    }
239    let before = crate::animation::invalidation_epoch();
240    let result = widget.on_unconsumed_key(key, modifiers);
241    if result == EventResult::Consumed || before != crate::animation::invalidation_epoch() {
242        widget.mark_dirty();
243    }
244    result
245}
246
247/// Produce a version of `event` with mouse positions replaced by `new_pos`.
248/// Non-mouse events (key, focus) are returned unchanged.
249fn translate_event(event: &Event, new_pos: Point) -> Event {
250    match event {
251        Event::MouseMove { .. } => Event::MouseMove { pos: new_pos },
252        Event::MouseDown {
253            button, modifiers, ..
254        } => Event::MouseDown {
255            pos: new_pos,
256            button: *button,
257            modifiers: *modifiers,
258        },
259        Event::MouseUp {
260            button, modifiers, ..
261        } => Event::MouseUp {
262            pos: new_pos,
263            button: *button,
264            modifiers: *modifiers,
265        },
266        Event::MouseWheel {
267            delta_y,
268            delta_x,
269            modifiers,
270            ..
271        } => Event::MouseWheel {
272            pos: new_pos,
273            delta_y: *delta_y,
274            delta_x: *delta_x,
275            modifiers: *modifiers,
276        },
277        other => other.clone(),
278    }
279}
280
281// ---------------------------------------------------------------------------
282// Inspector support
283// ---------------------------------------------------------------------------
284
285/// Flat snapshot of one widget for the inspector panel.
286#[derive(Clone)]
287pub struct InspectorNode {
288    pub type_name: &'static str,
289    /// Absolute screen bounds (Y-up), accumulated as the tree is walked.
290    pub screen_bounds: Rect,
291    /// Outer margin in logical units (per-side).  Drawn as the orange band
292    /// outside `screen_bounds` in the Chrome F12-style hover overlay.
293    pub margin: crate::layout_props::Insets,
294    /// Inner padding in logical units (per-side) — only nonzero on container
295    /// widgets that override [`Widget::padding`].  Drawn as the green band
296    /// inset from `screen_bounds`.
297    pub padding: crate::layout_props::Insets,
298    /// Horizontal anchor from the widget's `WidgetBase`, if present.
299    pub h_anchor: crate::layout_props::HAnchor,
300    /// Vertical anchor from the widget's `WidgetBase`, if present.
301    pub v_anchor: crate::layout_props::VAnchor,
302    pub depth: usize,
303    /// Path of child indices from the App root to this widget.  Used by the
304    /// inspector's live-editing pipeline to walk back to the live widget and
305    /// apply a reflected edit.  Empty for the root.
306    pub path: Vec<usize>,
307    /// Type-specific display properties from [`Widget::properties`].
308    pub properties: Vec<(&'static str, String)>,
309}
310
311/// Walk a reflected struct's fields and produce `(name, display)` pairs
312/// suitable for the inspector's property pane.  Public so callers can build
313/// the same typed dump for ad-hoc reflectable values (e.g. a debug hover
314/// inspector outside the widget tree).
315#[cfg(feature = "reflect")]
316pub fn reflect_fields(reflected: &dyn bevy_reflect::Reflect) -> Vec<(&'static str, String)> {
317    use bevy_reflect::{ReflectRef, TypeInfo};
318    let mut out = Vec::new();
319    if let ReflectRef::Struct(s) = reflected.reflect_ref() {
320        // The TypeInfo of the struct gives us field NAMES with `'static`
321        // lifetime — required because `InspectorNode::properties` is
322        // `Vec<(&'static str, String)>`.  Falling back to indexed names
323        // ("field_0") for unrepresented info keeps the dump alive even on
324        // tuple structs that don't carry named fields.
325        let names: Vec<&'static str> =
326            if let Some(TypeInfo::Struct(info)) = reflected.get_represented_type_info() {
327                (0..s.field_len())
328                    .map(|i| info.field_at(i).map(|f| f.name()).unwrap_or(""))
329                    .collect()
330            } else {
331                vec![""; s.field_len()]
332            };
333        for i in 0..s.field_len() {
334            let name = names.get(i).copied().unwrap_or("");
335            if name.is_empty() {
336                continue;
337            }
338            if let Some(field) = s.field_at(i) {
339                out.push((name, format_reflect_value(field)));
340            }
341        }
342    }
343    out
344}
345
346#[cfg(feature = "reflect")]
347fn format_reflect_value(value: &dyn bevy_reflect::PartialReflect) -> String {
348    // Try common primitive types first for clean output, then fall back to
349    // `Debug` via `reflect_short_type_path`.  bevy_reflect's `Debug` impl
350    // for arbitrary reflected values produces verbose "Reflected(..)" style
351    // output — bypass it for the types the inspector sees on a typical frame.
352    if let Some(v) = value.try_downcast_ref::<bool>() {
353        return v.to_string();
354    }
355    if let Some(v) = value.try_downcast_ref::<f64>() {
356        return format!("{v:.3}");
357    }
358    if let Some(v) = value.try_downcast_ref::<f32>() {
359        return format!("{v:.3}");
360    }
361    if let Some(v) = value.try_downcast_ref::<i32>() {
362        return v.to_string();
363    }
364    if let Some(v) = value.try_downcast_ref::<u32>() {
365        return v.to_string();
366    }
367    if let Some(v) = value.try_downcast_ref::<usize>() {
368        return v.to_string();
369    }
370    if let Some(v) = value.try_downcast_ref::<String>() {
371        return format!("\"{v}\"");
372    }
373    if let Some(v) = value.try_downcast_ref::<crate::color::Color>() {
374        return format!("rgba({:.2}, {:.2}, {:.2}, {:.2})", v.r, v.g, v.b, v.a);
375    }
376    // Generic fallback: `Debug`-print the reflected value.
377    format!("{value:?}")
378}
379
380/// Snapshot pushed to the platform render loop so the host can draw a
381/// Chrome F12-style three-band overlay (margin + bounds + padding) around
382/// the widget the inspector is hovering.
383#[derive(Clone, Copy, Debug, PartialEq)]
384pub struct InspectorOverlay {
385    pub bounds: Rect,
386    pub margin: crate::layout_props::Insets,
387    pub padding: crate::layout_props::Insets,
388}
389
390// ── Global mouse-world-pos (for nested drags that can't use widget-
391//    local coords because ancestor layout shifts under them each frame) ─────
392
393thread_local! {
394    static CURRENT_MOUSE_WORLD: std::cell::Cell<Option<Point>> =
395        std::cell::Cell::new(None);
396    static CURRENT_VIEWPORT: std::cell::Cell<Size> =
397        std::cell::Cell::new(Size::new(1.0, 1.0));
398}
399
400/// Record the current mouse cursor position in app-level (world / Y-up
401/// logical) coordinates.  Called by `App`'s mouse entry points.
402pub fn set_current_mouse_world(p: Point) {
403    CURRENT_MOUSE_WORLD.with(|c| c.set(Some(p)));
404}
405
406/// Retrieve the latest world-space mouse position.  Widgets doing a
407/// drag gesture that needs invariance against ancestor-layout shifts
408/// (e.g. a nested `Resize` inside an auto-sized `Window`, where the
409/// window grows/shrinks as the user drags and moves the widget's
410/// ancestor frame) should prefer this over the widget-local `pos`
411/// carried in `Event::Mouse*`.
412pub fn current_mouse_world() -> Option<Point> {
413    CURRENT_MOUSE_WORLD.with(|c| c.get())
414}
415
416/// Record the current app-level viewport in logical Y-up coordinates.
417pub fn set_current_viewport(s: Size) {
418    CURRENT_VIEWPORT.with(|c| c.set(s));
419}
420
421/// Retrieve the latest app-level viewport in logical coordinates.
422pub fn current_viewport() -> Size {
423    CURRENT_VIEWPORT.with(|c| c.get())
424}
425
426/// Depth-first search the subtree rooted at `widget` for one whose
427/// [`Widget::id`] matches `id`.  Returns the first match in paint order,
428/// including `widget` itself.  Used primarily by tests to locate a
429/// specific `Window` by its title without knowing the tree shape.
430pub fn find_widget_by_id<'a>(widget: &'a dyn Widget, id: &str) -> Option<&'a dyn Widget> {
431    if widget.id() == Some(id) {
432        return Some(widget);
433    }
434    for child in widget.children() {
435        if let Some(found) = find_widget_by_id(child.as_ref(), id) {
436            return Some(found);
437        }
438    }
439    None
440}
441
442/// Mutable counterpart to [`find_widget_by_id`].  Required when a test
443/// needs to poke at a sub-widget's mutable state (e.g. calling a
444/// `ScrollView::set_scroll_offset`) after finding it by id.
445pub fn find_widget_by_id_mut<'a>(
446    widget: &'a mut dyn Widget,
447    id: &str,
448) -> Option<&'a mut dyn Widget> {
449    if widget.id() == Some(id) {
450        return Some(widget);
451    }
452    for child in widget.children_mut().iter_mut() {
453        if let Some(found) = find_widget_by_id_mut(child.as_mut(), id) {
454            return Some(found);
455        }
456    }
457    None
458}
459
460/// Depth-first search for a widget by its [`Widget::type_name`].  Returns
461/// the first match in paint order.  Used by tests that want to assert on
462/// a specific widget kind inside an opaque content subtree (e.g.
463/// "find the ScrollView inside this window").
464pub fn find_widget_by_type<'a>(widget: &'a dyn Widget, type_name: &str) -> Option<&'a dyn Widget> {
465    if widget.type_name() == type_name {
466        return Some(widget);
467    }
468    for child in widget.children() {
469        if let Some(found) = find_widget_by_type(child.as_ref(), type_name) {
470            return Some(found);
471        }
472    }
473    None
474}
475
476/// Walk the subtree rooted at `widget` and collect an `InspectorNode` per
477/// widget in DFS paint order (root first).
478///
479/// `screen_origin` is the accumulated parent offset in screen Y-up coords.
480/// Widgets that apply additional transforms between themselves and their
481/// children (NodeEditor's pan/zoom, etc.) opt in via
482/// [`Widget::inspector_child_transform`]; the traversal composes those
483/// transforms so descendant `screen_bounds` reflect what the user sees.
484pub fn collect_inspector_nodes(
485    widget: &dyn Widget,
486    depth: usize,
487    screen_origin: Point,
488    out: &mut Vec<InspectorNode>,
489) {
490    let mut parent_to_screen = crate::TransAffine::new();
491    parent_to_screen.translate(screen_origin.x, screen_origin.y);
492    collect_inspector_nodes_with_path(widget, depth, &parent_to_screen, &[], out);
493}
494
495/// AABB of `rect`'s four corners after passing through `t` — equals
496/// `t(rect)` exactly for translation + uniform scale (no rotation), and
497/// is the right conservative bound otherwise.
498fn transform_rect_aabb(t: &crate::TransAffine, rect: Rect) -> Rect {
499    let corners = [
500        (rect.x, rect.y),
501        (rect.x + rect.width, rect.y),
502        (rect.x, rect.y + rect.height),
503        (rect.x + rect.width, rect.y + rect.height),
504    ];
505    let mut min_x = f64::INFINITY;
506    let mut min_y = f64::INFINITY;
507    let mut max_x = f64::NEG_INFINITY;
508    let mut max_y = f64::NEG_INFINITY;
509    for (mut x, mut y) in corners {
510        t.transform(&mut x, &mut y);
511        if x < min_x {
512            min_x = x;
513        }
514        if x > max_x {
515            max_x = x;
516        }
517        if y < min_y {
518            min_y = y;
519        }
520        if y > max_y {
521            max_y = y;
522        }
523    }
524    Rect::new(
525        min_x,
526        min_y,
527        (max_x - min_x).max(0.0),
528        (max_y - min_y).max(0.0),
529    )
530}
531
532/// Effective uniform scale extracted from `t` — used to scale logical
533/// margin / padding insets so the F12-style overlay bands stay
534/// visually proportional to the (scaled) widget bounds.  For pure
535/// translation + uniform scale this returns the scale exactly; under
536/// non-uniform scale it returns the geometric mean of the axes, which
537/// is the right "single number" for matching the visible band width.
538fn effective_scale(t: &crate::TransAffine) -> f64 {
539    let sx = (t.sx * t.sx + t.shy * t.shy).sqrt();
540    let sy = (t.shx * t.shx + t.sy * t.sy).sqrt();
541    if sx > 0.0 && sy > 0.0 {
542        (sx * sy).sqrt()
543    } else {
544        1.0
545    }
546}
547
548fn collect_inspector_nodes_with_path(
549    widget: &dyn Widget,
550    depth: usize,
551    parent_to_screen: &crate::TransAffine,
552    path_prefix: &[usize],
553    out: &mut Vec<InspectorNode>,
554) {
555    // Invisible widgets (and their entire subtrees) are excluded from the
556    // inspector — they are not part of the live rendered scene.
557    if !widget.is_visible() {
558        return;
559    }
560    // Utility widgets opt out of the inspector entirely.
561    if !widget.show_in_inspector() {
562        return;
563    }
564
565    let b = widget.bounds();
566    // Transform the widget's parent-local bounds rect through the
567    // accumulated parent_to_screen transform.  For the common case
568    // (pure translation, no scale) this collapses to the old
569    // `screen_origin + b.x/y` math; under scale it now correctly
570    // reports the on-screen size.
571    let abs = transform_rect_aabb(parent_to_screen, b);
572    let scale = effective_scale(parent_to_screen);
573    // Build the properties vec — include the universal `backbuffer` flag
574    // first (so every widget shows it in a consistent location), then the
575    // widget-specific properties.
576    let mut props = vec![(
577        "backbuffer",
578        if widget.has_backbuffer() {
579            "true".to_string()
580        } else {
581            "false".to_string()
582        },
583    )];
584    props.extend(widget.properties());
585    // Reflection-driven property dump.  Widgets that opt into the
586    // companion-props pattern (`Widget::as_reflect`) get their reflected
587    // struct fields surfaced here as `(name, formatted)` pairs — typed,
588    // accurate, and free of the hand-maintained `properties()` strings
589    // they would otherwise need.  Fields that aren't a struct, or that
590    // can't be displayed, are silently skipped.
591    #[cfg(feature = "reflect")]
592    if let Some(reflected) = widget.as_reflect() {
593        props.extend(reflect_fields(reflected));
594    }
595    let (h_anchor, v_anchor) = widget
596        .widget_base()
597        .map(|b| (b.h_anchor, b.v_anchor))
598        .unwrap_or((
599            crate::layout_props::HAnchor::FIT,
600            crate::layout_props::VAnchor::FIT,
601        ));
602    let margin_logical = widget.margin();
603    let padding_logical = widget.padding();
604    let scaled_margin = crate::layout_props::Insets {
605        left: margin_logical.left * scale,
606        right: margin_logical.right * scale,
607        top: margin_logical.top * scale,
608        bottom: margin_logical.bottom * scale,
609    };
610    let scaled_padding = crate::layout_props::Insets {
611        left: padding_logical.left * scale,
612        right: padding_logical.right * scale,
613        top: padding_logical.top * scale,
614        bottom: padding_logical.bottom * scale,
615    };
616    out.push(InspectorNode {
617        type_name: widget.type_name(),
618        screen_bounds: abs,
619        margin: scaled_margin,
620        padding: scaled_padding,
621        h_anchor,
622        v_anchor,
623        depth,
624        path: path_prefix.to_vec(),
625        properties: props,
626    });
627
628    // Widgets that are part of the inspector infrastructure opt out of child
629    // recursion to prevent the inspector from growing its own node list every
630    // frame (exponential growth).  Their sub-trees are still visible in the
631    // inspector on the next frame through the normal layout snapshot.
632    if !widget.contributes_children_to_inspector() {
633        return;
634    }
635
636    // Compose the transform for child traversal.  Order matches the
637    // paint pipeline: parent_to_screen ∘ translate(b.x, b.y) ∘
638    // widget.inspector_child_transform().  That is, applied to a point
639    // in child-local space, we first run the widget's own child
640    // transform (e.g. NodeEditor's pan/zoom), then translate by the
641    // widget's bounds offset (its position in its parent), then through
642    // the parent_to_screen chain.
643    let mut child_to_screen = *parent_to_screen;
644    child_to_screen.translate(b.x, b.y);
645    let extra = widget.inspector_child_transform();
646    child_to_screen.premultiply(&extra);
647
648    let mut child_path: Vec<usize> = Vec::with_capacity(path_prefix.len() + 1);
649    child_path.extend_from_slice(path_prefix);
650    child_path.push(0);
651    for (i, child) in widget.children().iter().enumerate() {
652        *child_path.last_mut().unwrap() = i;
653        collect_inspector_nodes_with_path(
654            child.as_ref(),
655            depth + 1,
656            &child_to_screen,
657            &child_path,
658            out,
659        );
660    }
661}
662
663/// Walk the widget tree from `root` along `path` and return the deepest
664/// reachable widget as a mutable reference.  Returns `None` if the path
665/// indexes past the available children at any level — useful when the path
666/// is stale (e.g. the tree shape changed since the inspector snapshot).
667pub fn walk_path_mut<'a>(root: &'a mut dyn Widget, path: &[usize]) -> Option<&'a mut dyn Widget> {
668    let mut node: &mut dyn Widget = root;
669    for &idx in path {
670        let children = node.children_mut();
671        if idx >= children.len() {
672            return None;
673        }
674        node = children[idx].as_mut();
675    }
676    Some(node)
677}
678
679/// A pending inspector edit: navigate to the widget at `path`, look up
680/// `field_path` via reflection, and apply `new_value`.
681///
682/// Edits are queued by the inspector and drained by the host frame loop —
683/// applying them mid-paint or mid-event-dispatch could violate borrow rules
684/// or layout invariants.
685#[cfg(feature = "reflect")]
686pub struct InspectorEdit {
687    pub path: Vec<usize>,
688    /// Reflection path inside the target widget's `as_reflect` value, e.g.
689    /// `"checked"` or `"value"` or `"margin.left"`.
690    pub field_path: String,
691    /// Replacement value, already type-correct for the target field.
692    pub new_value: Box<dyn bevy_reflect::PartialReflect>,
693}
694
695#[cfg(feature = "reflect")]
696impl std::fmt::Debug for InspectorEdit {
697    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
698        f.debug_struct("InspectorEdit")
699            .field("path", &self.path)
700            .field("field_path", &self.field_path)
701            .finish_non_exhaustive()
702    }
703}
704
705// ---------------------------------------------------------------------------
706// WidgetBase live editing (no reflect feature required)
707// ---------------------------------------------------------------------------
708
709/// One field in a widget's [`crate::layout_props::WidgetBase`] that the
710/// inspector can change at runtime.
711#[derive(Clone, Debug)]
712pub enum WidgetBaseField {
713    MarginLeft(f64),
714    MarginRight(f64),
715    MarginTop(f64),
716    MarginBottom(f64),
717    HAnchor(crate::layout_props::HAnchor),
718    VAnchor(crate::layout_props::VAnchor),
719    MinWidth(f64),
720    MinHeight(f64),
721    MaxWidth(f64),
722    MaxHeight(f64),
723}
724
725/// Queued mutation for a widget's `WidgetBase`.  The inspector pushes these;
726/// the host frame loop drains and applies via [`apply_widget_base_edit`].
727#[derive(Clone, Debug)]
728pub struct WidgetBaseEdit {
729    /// Path of child indices from the App root to the target widget.
730    pub path: Vec<usize>,
731    pub field: WidgetBaseField,
732}
733
734/// Apply a single queued `WidgetBaseEdit` against the live widget tree.
735/// Returns `true` when the edit landed, `false` if the path was stale or the
736/// widget does not expose a `WidgetBase`.
737pub fn apply_widget_base_edit(root: &mut dyn Widget, edit: &WidgetBaseEdit) -> bool {
738    let Some(target) = walk_path_mut(root, &edit.path) else {
739        return false;
740    };
741    let Some(base) = target.widget_base_mut() else {
742        return false;
743    };
744    match &edit.field {
745        WidgetBaseField::MarginLeft(v) => base.margin.left = *v,
746        WidgetBaseField::MarginRight(v) => base.margin.right = *v,
747        WidgetBaseField::MarginTop(v) => base.margin.top = *v,
748        WidgetBaseField::MarginBottom(v) => base.margin.bottom = *v,
749        WidgetBaseField::HAnchor(a) => base.h_anchor = *a,
750        WidgetBaseField::VAnchor(a) => base.v_anchor = *a,
751        WidgetBaseField::MinWidth(v) => base.min_size.width = v.max(0.0),
752        WidgetBaseField::MinHeight(v) => base.min_size.height = v.max(0.0),
753        WidgetBaseField::MaxWidth(v) => base.max_size.width = v.max(0.0),
754        WidgetBaseField::MaxHeight(v) => base.max_size.height = v.max(0.0),
755    }
756    target.mark_dirty();
757    crate::animation::request_draw();
758    true
759}
760
761/// Apply a single queued inspector edit against the live widget tree.
762/// Returns `true` if the edit landed; `false` if the path was stale or the
763/// field path didn't resolve.
764#[cfg(feature = "reflect")]
765pub fn apply_inspector_edit(root: &mut dyn Widget, edit: &InspectorEdit) -> bool {
766    use bevy_reflect::{GetPath, PartialReflect};
767    let Some(target) = walk_path_mut(root, &edit.path) else {
768        return false;
769    };
770    let applied;
771    {
772        let Some(reflected) = target.as_reflect_mut() else {
773            return false;
774        };
775        let Ok(field) = reflected.reflect_path_mut(edit.field_path.as_str()) else {
776            return false;
777        };
778        let field: &mut dyn PartialReflect = field;
779        applied = field.try_apply(edit.new_value.as_ref()).is_ok();
780    }
781    // Reflection bypasses the widget's setters, which is where cache
782    // invalidation normally happens (e.g. Label::set_text).  Hand the
783    // widget a single-shot dirty signal so the next paint re-rasterises.
784    if applied {
785        target.mark_dirty();
786        crate::animation::request_draw();
787    }
788    applied
789}