Skip to main content

agg_gui/widget/
tree_inspector.rs

1//! Inspector + reflection support for the widget tree: flat tree
2//! snapshots for the F12-style inspector panel, reflection-driven field
3//! dumps, id/type widget lookup, and inspector-originated edits.
4//!
5//! Split out of `tree.rs` (traversal + event dispatch) to keep both
6//! files under the 800-line cap.
7
8use super::*;
9
10/// Flat snapshot of one widget for the inspector panel.
11#[derive(Clone)]
12pub struct InspectorNode {
13    pub type_name: &'static str,
14    /// Absolute screen bounds (Y-up), accumulated as the tree is walked.
15    pub screen_bounds: Rect,
16    /// Outer margin in logical units (per-side).  Drawn as the orange band
17    /// outside `screen_bounds` in the Chrome F12-style hover overlay.
18    pub margin: crate::layout_props::Insets,
19    /// Inner padding in logical units (per-side) — only nonzero on container
20    /// widgets that override [`Widget::padding`].  Drawn as the green band
21    /// inset from `screen_bounds`.
22    pub padding: crate::layout_props::Insets,
23    /// Horizontal anchor from the widget's `WidgetBase`, if present.
24    pub h_anchor: crate::layout_props::HAnchor,
25    /// Vertical anchor from the widget's `WidgetBase`, if present.
26    pub v_anchor: crate::layout_props::VAnchor,
27    pub depth: usize,
28    /// Path of child indices from the App root to this widget.  Used by the
29    /// inspector's live-editing pipeline to walk back to the live widget and
30    /// apply a reflected edit.  Empty for the root.
31    pub path: Vec<usize>,
32    /// Type-specific display properties from [`Widget::properties`].
33    pub properties: Vec<(&'static str, String)>,
34}
35
36/// Walk a reflected struct's fields and produce `(name, display)` pairs
37/// suitable for the inspector's property pane.  Public so callers can build
38/// the same typed dump for ad-hoc reflectable values (e.g. a debug hover
39/// inspector outside the widget tree).
40#[cfg(feature = "reflect")]
41pub fn reflect_fields(reflected: &dyn bevy_reflect::Reflect) -> Vec<(&'static str, String)> {
42    use bevy_reflect::{ReflectRef, TypeInfo};
43    let mut out = Vec::new();
44    if let ReflectRef::Struct(s) = reflected.reflect_ref() {
45        // The TypeInfo of the struct gives us field NAMES with `'static`
46        // lifetime — required because `InspectorNode::properties` is
47        // `Vec<(&'static str, String)>`.  Falling back to indexed names
48        // ("field_0") for unrepresented info keeps the dump alive even on
49        // tuple structs that don't carry named fields.
50        let names: Vec<&'static str> =
51            if let Some(TypeInfo::Struct(info)) = reflected.get_represented_type_info() {
52                (0..s.field_len())
53                    .map(|i| info.field_at(i).map(|f| f.name()).unwrap_or(""))
54                    .collect()
55            } else {
56                vec![""; s.field_len()]
57            };
58        for i in 0..s.field_len() {
59            let name = names.get(i).copied().unwrap_or("");
60            if name.is_empty() {
61                continue;
62            }
63            if let Some(field) = s.field_at(i) {
64                out.push((name, format_reflect_value(field)));
65            }
66        }
67    }
68    out
69}
70
71#[cfg(feature = "reflect")]
72fn format_reflect_value(value: &dyn bevy_reflect::PartialReflect) -> String {
73    // Try common primitive types first for clean output, then fall back to
74    // `Debug` via `reflect_short_type_path`.  bevy_reflect's `Debug` impl
75    // for arbitrary reflected values produces verbose "Reflected(..)" style
76    // output — bypass it for the types the inspector sees on a typical frame.
77    if let Some(v) = value.try_downcast_ref::<bool>() {
78        return v.to_string();
79    }
80    if let Some(v) = value.try_downcast_ref::<f64>() {
81        return format!("{v:.3}");
82    }
83    if let Some(v) = value.try_downcast_ref::<f32>() {
84        return format!("{v:.3}");
85    }
86    if let Some(v) = value.try_downcast_ref::<i32>() {
87        return v.to_string();
88    }
89    if let Some(v) = value.try_downcast_ref::<u32>() {
90        return v.to_string();
91    }
92    if let Some(v) = value.try_downcast_ref::<usize>() {
93        return v.to_string();
94    }
95    if let Some(v) = value.try_downcast_ref::<String>() {
96        return format!("\"{v}\"");
97    }
98    if let Some(v) = value.try_downcast_ref::<crate::color::Color>() {
99        return format!("rgba({:.2}, {:.2}, {:.2}, {:.2})", v.r, v.g, v.b, v.a);
100    }
101    // Generic fallback: `Debug`-print the reflected value.
102    format!("{value:?}")
103}
104
105/// Snapshot pushed to the platform render loop so the host can draw a
106/// Chrome F12-style three-band overlay (margin + bounds + padding) around
107/// the widget the inspector is hovering.
108#[derive(Clone, Copy, Debug, PartialEq)]
109pub struct InspectorOverlay {
110    pub bounds: Rect,
111    pub margin: crate::layout_props::Insets,
112    pub padding: crate::layout_props::Insets,
113}
114
115// ── Global mouse-world-pos (for nested drags that can't use widget-
116//    local coords because ancestor layout shifts under them each frame) ─────
117
118thread_local! {
119    static CURRENT_MOUSE_WORLD: std::cell::Cell<Option<Point>> =
120        std::cell::Cell::new(None);
121    static CURRENT_VIEWPORT: std::cell::Cell<Size> =
122        std::cell::Cell::new(Size::new(1.0, 1.0));
123}
124
125/// Record the current mouse cursor position in app-level (world / Y-up
126/// logical) coordinates.  Called by `App`'s mouse entry points.
127pub fn set_current_mouse_world(p: Point) {
128    CURRENT_MOUSE_WORLD.with(|c| c.set(Some(p)));
129}
130
131/// Retrieve the latest world-space mouse position.  Widgets doing a
132/// drag gesture that needs invariance against ancestor-layout shifts
133/// (e.g. a nested `Resize` inside an auto-sized `Window`, where the
134/// window grows/shrinks as the user drags and moves the widget's
135/// ancestor frame) should prefer this over the widget-local `pos`
136/// carried in `Event::Mouse*`.
137pub fn current_mouse_world() -> Option<Point> {
138    CURRENT_MOUSE_WORLD.with(|c| c.get())
139}
140
141/// Record the current app-level viewport in logical Y-up coordinates.
142pub fn set_current_viewport(s: Size) {
143    CURRENT_VIEWPORT.with(|c| c.set(s));
144}
145
146/// Retrieve the latest app-level viewport in logical coordinates.
147pub fn current_viewport() -> Size {
148    CURRENT_VIEWPORT.with(|c| c.get())
149}
150
151/// Depth-first search the subtree rooted at `widget` for one whose
152/// [`Widget::id`] matches `id`.  Returns the first match in paint order,
153/// including `widget` itself.  Used primarily by tests to locate a
154/// specific `Window` by its title without knowing the tree shape.
155pub fn find_widget_by_id<'a>(widget: &'a dyn Widget, id: &str) -> Option<&'a dyn Widget> {
156    if widget.id() == Some(id) {
157        return Some(widget);
158    }
159    for child in widget.children() {
160        if let Some(found) = find_widget_by_id(child.as_ref(), id) {
161            return Some(found);
162        }
163    }
164    None
165}
166
167/// Mutable counterpart to [`find_widget_by_id`].  Required when a test
168/// needs to poke at a sub-widget's mutable state (e.g. calling a
169/// `ScrollView::set_scroll_offset`) after finding it by id.
170pub fn find_widget_by_id_mut<'a>(
171    widget: &'a mut dyn Widget,
172    id: &str,
173) -> Option<&'a mut dyn Widget> {
174    if widget.id() == Some(id) {
175        return Some(widget);
176    }
177    for child in widget.children_mut().iter_mut() {
178        if let Some(found) = find_widget_by_id_mut(child.as_mut(), id) {
179            return Some(found);
180        }
181    }
182    None
183}
184
185/// Absolute (screen-space, Y-up) rectangle of the first widget in the
186/// subtree whose [`Widget::id`] matches `id`.
187///
188/// [`find_widget_by_id`] returns the widget itself, and
189/// [`Widget::bounds`] is **parent-local** — many widgets even reset
190/// their own origin to (0, 0) in `layout`, so a caller that wants "where
191/// on the window is this widget" cannot read `bounds()` directly. This
192/// walks the same accumulated transform chain
193/// [`collect_inspector_nodes`] does (translation by each ancestor's
194/// bounds origin, composed with any [`Widget::inspector_child_transform`]),
195/// so the answer matches the inspector overlay's *transform math*.
196///
197/// It differs from the inspector in which subtrees it walks: the
198/// inspector's `show_in_inspector` / `contributes_children_to_inspector`
199/// opt-outs are presentation concerns and are ignored here, but a subtree
200/// whose root reports `!is_visible()` is skipped entirely — a hidden
201/// widget occupies no pixels, so "where on the window is it" has no
202/// answer.
203///
204/// Useful to any host that has to line window-space pixels up with a
205/// widget: screenshot / thumbnail crops, platform overlays (IME, native
206/// video surfaces), and tests asserting on real placement. Returns
207/// `None` when no *visible* widget carries `id`.
208pub fn find_widget_screen_rect(root: &dyn Widget, id: &str) -> Option<Rect> {
209    // The root's own bounds origin is already screen-absolute, so the
210    // walk starts from the identity transform.
211    let root_to_screen = crate::TransAffine::new();
212    find_widget_screen_rect_inner(root, id, &root_to_screen)
213}
214
215fn find_widget_screen_rect_inner(
216    widget: &dyn Widget,
217    id: &str,
218    parent_to_screen: &crate::TransAffine,
219) -> Option<Rect> {
220    if !widget.is_visible() {
221        return None;
222    }
223    let b = widget.bounds();
224    if widget.id() == Some(id) {
225        return Some(transform_rect_aabb(parent_to_screen, b));
226    }
227    let mut child_to_screen = *parent_to_screen;
228    child_to_screen.translate(b.x, b.y);
229    child_to_screen.premultiply(&widget.inspector_child_transform());
230    for child in widget.children() {
231        if let Some(found) = find_widget_screen_rect_inner(child.as_ref(), id, &child_to_screen) {
232            return Some(found);
233        }
234    }
235    None
236}
237
238/// Depth-first search for a widget by its [`Widget::type_name`].  Returns
239/// the first match in paint order.  Used by tests that want to assert on
240/// a specific widget kind inside an opaque content subtree (e.g.
241/// "find the ScrollView inside this window").
242pub fn find_widget_by_type<'a>(widget: &'a dyn Widget, type_name: &str) -> Option<&'a dyn Widget> {
243    if widget.type_name() == type_name {
244        return Some(widget);
245    }
246    for child in widget.children() {
247        if let Some(found) = find_widget_by_type(child.as_ref(), type_name) {
248            return Some(found);
249        }
250    }
251    None
252}
253
254/// Walk the subtree rooted at `widget` and collect an `InspectorNode` per
255/// widget in DFS paint order (root first).
256///
257/// `screen_origin` is the accumulated parent offset in screen Y-up coords.
258/// Widgets that apply additional transforms between themselves and their
259/// children (NodeEditor's pan/zoom, etc.) opt in via
260/// [`Widget::inspector_child_transform`]; the traversal composes those
261/// transforms so descendant `screen_bounds` reflect what the user sees.
262pub fn collect_inspector_nodes(
263    widget: &dyn Widget,
264    depth: usize,
265    screen_origin: Point,
266    out: &mut Vec<InspectorNode>,
267) {
268    let mut parent_to_screen = crate::TransAffine::new();
269    parent_to_screen.translate(screen_origin.x, screen_origin.y);
270    collect_inspector_nodes_with_path(widget, depth, &parent_to_screen, &[], out);
271}
272
273/// AABB of `rect`'s four corners after passing through `t` — equals
274/// `t(rect)` exactly for translation + uniform scale (no rotation), and
275/// is the right conservative bound otherwise.
276fn transform_rect_aabb(t: &crate::TransAffine, rect: Rect) -> Rect {
277    let corners = [
278        (rect.x, rect.y),
279        (rect.x + rect.width, rect.y),
280        (rect.x, rect.y + rect.height),
281        (rect.x + rect.width, rect.y + rect.height),
282    ];
283    let mut min_x = f64::INFINITY;
284    let mut min_y = f64::INFINITY;
285    let mut max_x = f64::NEG_INFINITY;
286    let mut max_y = f64::NEG_INFINITY;
287    for (mut x, mut y) in corners {
288        t.transform(&mut x, &mut y);
289        if x < min_x {
290            min_x = x;
291        }
292        if x > max_x {
293            max_x = x;
294        }
295        if y < min_y {
296            min_y = y;
297        }
298        if y > max_y {
299            max_y = y;
300        }
301    }
302    Rect::new(
303        min_x,
304        min_y,
305        (max_x - min_x).max(0.0),
306        (max_y - min_y).max(0.0),
307    )
308}
309
310/// Effective uniform scale extracted from `t` — used to scale logical
311/// margin / padding insets so the F12-style overlay bands stay
312/// visually proportional to the (scaled) widget bounds.  For pure
313/// translation + uniform scale this returns the scale exactly; under
314/// non-uniform scale it returns the geometric mean of the axes, which
315/// is the right "single number" for matching the visible band width.
316fn effective_scale(t: &crate::TransAffine) -> f64 {
317    let sx = (t.sx * t.sx + t.shy * t.shy).sqrt();
318    let sy = (t.shx * t.shx + t.sy * t.sy).sqrt();
319    if sx > 0.0 && sy > 0.0 {
320        (sx * sy).sqrt()
321    } else {
322        1.0
323    }
324}
325
326fn collect_inspector_nodes_with_path(
327    widget: &dyn Widget,
328    depth: usize,
329    parent_to_screen: &crate::TransAffine,
330    path_prefix: &[usize],
331    out: &mut Vec<InspectorNode>,
332) {
333    // Invisible widgets (and their entire subtrees) are excluded from the
334    // inspector — they are not part of the live rendered scene.
335    if !widget.is_visible() {
336        return;
337    }
338    // Utility widgets opt out of the inspector entirely.
339    if !widget.show_in_inspector() {
340        return;
341    }
342
343    let b = widget.bounds();
344    // Transform the widget's parent-local bounds rect through the
345    // accumulated parent_to_screen transform.  For the common case
346    // (pure translation, no scale) this collapses to the old
347    // `screen_origin + b.x/y` math; under scale it now correctly
348    // reports the on-screen size.
349    let abs = transform_rect_aabb(parent_to_screen, b);
350    let scale = effective_scale(parent_to_screen);
351    // Build the properties vec — include the universal `backbuffer` flag
352    // first (so every widget shows it in a consistent location), then the
353    // widget-specific properties.
354    let mut props = vec![(
355        "backbuffer",
356        if widget.has_backbuffer() {
357            "true".to_string()
358        } else {
359            "false".to_string()
360        },
361    )];
362    props.extend(widget.properties());
363    // Reflection-driven property dump.  Widgets that opt into the
364    // companion-props pattern (`Widget::as_reflect`) get their reflected
365    // struct fields surfaced here as `(name, formatted)` pairs — typed,
366    // accurate, and free of the hand-maintained `properties()` strings
367    // they would otherwise need.  Fields that aren't a struct, or that
368    // can't be displayed, are silently skipped.
369    #[cfg(feature = "reflect")]
370    if let Some(reflected) = widget.as_reflect() {
371        props.extend(reflect_fields(reflected));
372    }
373    let (h_anchor, v_anchor) = widget
374        .widget_base()
375        .map(|b| (b.h_anchor, b.v_anchor))
376        .unwrap_or((
377            crate::layout_props::HAnchor::FIT,
378            crate::layout_props::VAnchor::FIT,
379        ));
380    let margin_logical = widget.margin();
381    let padding_logical = widget.padding();
382    let scaled_margin = crate::layout_props::Insets {
383        left: margin_logical.left * scale,
384        right: margin_logical.right * scale,
385        top: margin_logical.top * scale,
386        bottom: margin_logical.bottom * scale,
387    };
388    let scaled_padding = crate::layout_props::Insets {
389        left: padding_logical.left * scale,
390        right: padding_logical.right * scale,
391        top: padding_logical.top * scale,
392        bottom: padding_logical.bottom * scale,
393    };
394    out.push(InspectorNode {
395        type_name: widget.type_name(),
396        screen_bounds: abs,
397        margin: scaled_margin,
398        padding: scaled_padding,
399        h_anchor,
400        v_anchor,
401        depth,
402        path: path_prefix.to_vec(),
403        properties: props,
404    });
405
406    // Widgets that are part of the inspector infrastructure opt out of child
407    // recursion to prevent the inspector from growing its own node list every
408    // frame (exponential growth).  Their sub-trees are still visible in the
409    // inspector on the next frame through the normal layout snapshot.
410    if !widget.contributes_children_to_inspector() {
411        return;
412    }
413
414    // Compose the transform for child traversal.  Order matches the
415    // paint pipeline: parent_to_screen ∘ translate(b.x, b.y) ∘
416    // widget.inspector_child_transform().  That is, applied to a point
417    // in child-local space, we first run the widget's own child
418    // transform (e.g. NodeEditor's pan/zoom), then translate by the
419    // widget's bounds offset (its position in its parent), then through
420    // the parent_to_screen chain.
421    let mut child_to_screen = *parent_to_screen;
422    child_to_screen.translate(b.x, b.y);
423    let extra = widget.inspector_child_transform();
424    child_to_screen.premultiply(&extra);
425
426    let mut child_path: Vec<usize> = Vec::with_capacity(path_prefix.len() + 1);
427    child_path.extend_from_slice(path_prefix);
428    child_path.push(0);
429    for (i, child) in widget.children().iter().enumerate() {
430        *child_path.last_mut().unwrap() = i;
431        collect_inspector_nodes_with_path(
432            child.as_ref(),
433            depth + 1,
434            &child_to_screen,
435            &child_path,
436            out,
437        );
438    }
439}
440
441// ---------------------------------------------------------------------------
442// Runaway-repaint diagnostic report
443// ---------------------------------------------------------------------------
444
445/// Recursively collect every *visible* widget whose [`Widget::needs_draw`]
446/// currently returns `true`, recording its child-index path, type name, and
447/// whether it is "self-hot" (no visible child is independently asking for a
448/// draw, so this widget's own state is the driver).
449///
450/// The walk descends into **all** visible children — it does not prune on a
451/// parent whose `needs_draw()` is `false`.  That deliberately catches
452/// propagation gaps (a hot child under a container that forgot to OR its
453/// children into `needs_draw`, the exact class of bug behind the intermittent
454/// runaway), which a prune-on-false walk would hide.
455fn collect_needs_draw(
456    widget: &dyn Widget,
457    path: &mut Vec<usize>,
458    out: &mut Vec<(Vec<usize>, &'static str, bool)>,
459) {
460    // Same visibility gate the host loop uses: invisible subtrees never keep
461    // the app awake, so they are irrelevant to a runaway and are skipped.
462    if !widget.is_visible() {
463        return;
464    }
465    if widget.needs_draw() {
466        let child_hot = widget.children().iter().any(|c| c.needs_draw());
467        out.push((path.clone(), widget.type_name(), !child_hot));
468    }
469    for (i, child) in widget.children().iter().enumerate() {
470        path.push(i);
471        collect_needs_draw(child.as_ref(), path, out);
472        path.pop();
473    }
474}
475
476/// Build a human-readable diagnostic naming everything currently keeping the
477/// reactive host awake, for capturing the intermittent "continuous rendering
478/// never quiesces" runaway on real hardware.
479///
480/// The report contains, in order:
481/// 1. the raw immediate-draw flag (read side-effect-free — see
482///    [`crate::animation::peek_draw_signals`], which unlike `wants_draw`
483///    neither pumps async wakeups nor clears a due deadline),
484/// 2. the next scheduled-draw deadline with remaining time (and a `DUE`
485///    marker when already past),
486/// 3. the drained draw-request provenance tags, deduplicated with counts
487///    (always empty in release builds — the trace is debug-only), and
488/// 4. every visible widget whose `needs_draw()` is `true`, as `[path] Type`,
489///    with a `<- self` marker on the ones whose own state (not a child's)
490///    is the driver.
491///
492/// Takes the tree `root` as a parameter so it can be invoked from a host
493/// shell that holds `&App` (`app.root()`), matching the introspection style
494/// of [`collect_inspector_nodes`].
495#[doc(hidden)]
496pub fn debug_draw_report(root: &dyn Widget) -> String {
497    use std::fmt::Write;
498    let mut s = String::new();
499    let (needs_flag, deadline) = crate::animation::peek_draw_signals();
500    let now = web_time::Instant::now();
501
502    let _ = writeln!(s, "== agg-gui draw report ==");
503    let _ = writeln!(s, "immediate needs_draw flag: {needs_flag}");
504    match deadline {
505        Some(when) => {
506            let remaining_ms = when.saturating_duration_since(now).as_secs_f64() * 1000.0;
507            let due = now >= when;
508            let _ = writeln!(
509                s,
510                "next scheduled deadline: {remaining_ms:.1} ms{}",
511                if due { " (DUE)" } else { "" }
512            );
513        }
514        None => {
515            let _ = writeln!(s, "next scheduled deadline: none");
516        }
517    }
518
519    // (c) Drained provenance tags, deduplicated with counts (most-frequent
520    // first).  Draining is what the quiescence guard already does; doing it
521    // here means a subsequent report starts from a clean trace.
522    let tags = crate::animation::drain_draw_trace();
523    if tags.is_empty() {
524        let _ = writeln!(
525            s,
526            "draw-trace tags: none (empty in release builds — trace is debug-only)"
527        );
528    } else {
529        let mut counts: Vec<(&'static str, usize)> = Vec::new();
530        for t in tags {
531            if let Some(entry) = counts.iter_mut().find(|(name, _)| *name == t) {
532                entry.1 += 1;
533            } else {
534                counts.push((t, 1));
535            }
536        }
537        counts.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
538        let _ = writeln!(s, "draw-trace tags ({} distinct):", counts.len());
539        for (name, count) in counts {
540            let _ = writeln!(s, "  {count:>4} x {name}");
541        }
542    }
543
544    // (d) Every visible widget whose needs_draw() is true.
545    let mut hot: Vec<(Vec<usize>, &'static str, bool)> = Vec::new();
546    collect_needs_draw(root, &mut Vec::new(), &mut hot);
547    if hot.is_empty() {
548        let _ = writeln!(s, "widgets wanting draw: none");
549    } else {
550        let _ = writeln!(s, "widgets wanting draw ({}):", hot.len());
551        for (path, type_name, self_hot) in hot {
552            let path_str = if path.is_empty() {
553                "root".to_string()
554            } else {
555                path.iter()
556                    .map(|i| i.to_string())
557                    .collect::<Vec<_>>()
558                    .join("/")
559            };
560            let _ = writeln!(
561                s,
562                "  [{path_str}] {type_name}{}",
563                if self_hot { "  <- self" } else { "" }
564            );
565        }
566    }
567
568    s
569}
570
571/// Walk the widget tree from `root` along `path` and return the deepest
572/// reachable widget as a mutable reference.  Returns `None` if the path
573/// indexes past the available children at any level — useful when the path
574/// is stale (e.g. the tree shape changed since the inspector snapshot).
575pub fn walk_path_mut<'a>(root: &'a mut dyn Widget, path: &[usize]) -> Option<&'a mut dyn Widget> {
576    let mut node: &mut dyn Widget = root;
577    for &idx in path {
578        let children = node.children_mut();
579        if idx >= children.len() {
580            return None;
581        }
582        node = children[idx].as_mut();
583    }
584    Some(node)
585}
586
587/// A pending inspector edit: navigate to the widget at `path`, look up
588/// `field_path` via reflection, and apply `new_value`.
589///
590/// Edits are queued by the inspector and drained by the host frame loop —
591/// applying them mid-paint or mid-event-dispatch could violate borrow rules
592/// or layout invariants.
593#[cfg(feature = "reflect")]
594pub struct InspectorEdit {
595    pub path: Vec<usize>,
596    /// Reflection path inside the target widget's `as_reflect` value, e.g.
597    /// `"checked"` or `"value"` or `"margin.left"`.
598    pub field_path: String,
599    /// Replacement value, already type-correct for the target field.
600    pub new_value: Box<dyn bevy_reflect::PartialReflect>,
601}
602
603#[cfg(feature = "reflect")]
604impl std::fmt::Debug for InspectorEdit {
605    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
606        f.debug_struct("InspectorEdit")
607            .field("path", &self.path)
608            .field("field_path", &self.field_path)
609            .finish_non_exhaustive()
610    }
611}
612
613// ---------------------------------------------------------------------------
614// WidgetBase live editing (no reflect feature required)
615// ---------------------------------------------------------------------------
616
617/// One field in a widget's [`crate::layout_props::WidgetBase`] that the
618/// inspector can change at runtime.
619#[derive(Clone, Debug)]
620pub enum WidgetBaseField {
621    MarginLeft(f64),
622    MarginRight(f64),
623    MarginTop(f64),
624    MarginBottom(f64),
625    HAnchor(crate::layout_props::HAnchor),
626    VAnchor(crate::layout_props::VAnchor),
627    MinWidth(f64),
628    MinHeight(f64),
629    MaxWidth(f64),
630    MaxHeight(f64),
631}
632
633/// Queued mutation for a widget's `WidgetBase`.  The inspector pushes these;
634/// the host frame loop drains and applies via [`apply_widget_base_edit`].
635#[derive(Clone, Debug)]
636pub struct WidgetBaseEdit {
637    /// Path of child indices from the App root to the target widget.
638    pub path: Vec<usize>,
639    pub field: WidgetBaseField,
640}
641
642/// Apply a single queued `WidgetBaseEdit` against the live widget tree.
643/// Returns `true` when the edit landed, `false` if the path was stale or the
644/// widget does not expose a `WidgetBase`.
645pub fn apply_widget_base_edit(root: &mut dyn Widget, edit: &WidgetBaseEdit) -> bool {
646    let Some(target) = walk_path_mut(root, &edit.path) else {
647        return false;
648    };
649    let Some(base) = target.widget_base_mut() else {
650        return false;
651    };
652    match &edit.field {
653        WidgetBaseField::MarginLeft(v) => base.margin.left = *v,
654        WidgetBaseField::MarginRight(v) => base.margin.right = *v,
655        WidgetBaseField::MarginTop(v) => base.margin.top = *v,
656        WidgetBaseField::MarginBottom(v) => base.margin.bottom = *v,
657        WidgetBaseField::HAnchor(a) => base.h_anchor = *a,
658        WidgetBaseField::VAnchor(a) => base.v_anchor = *a,
659        WidgetBaseField::MinWidth(v) => base.min_size.width = v.max(0.0),
660        WidgetBaseField::MinHeight(v) => base.min_size.height = v.max(0.0),
661        WidgetBaseField::MaxWidth(v) => base.max_size.width = v.max(0.0),
662        WidgetBaseField::MaxHeight(v) => base.max_size.height = v.max(0.0),
663    }
664    target.mark_dirty();
665    crate::animation::request_draw();
666    true
667}
668
669/// Apply a single queued inspector edit against the live widget tree.
670/// Returns `true` if the edit landed; `false` if the path was stale or the
671/// field path didn't resolve.
672#[cfg(feature = "reflect")]
673pub fn apply_inspector_edit(root: &mut dyn Widget, edit: &InspectorEdit) -> bool {
674    use bevy_reflect::{GetPath, PartialReflect};
675    let Some(target) = walk_path_mut(root, &edit.path) else {
676        return false;
677    };
678    let applied;
679    {
680        let Some(reflected) = target.as_reflect_mut() else {
681            return false;
682        };
683        let Ok(field) = reflected.reflect_path_mut(edit.field_path.as_str()) else {
684            return false;
685        };
686        let field: &mut dyn PartialReflect = field;
687        applied = field.try_apply(edit.new_value.as_ref()).is_ok();
688    }
689    // Reflection bypasses the widget's setters, which is where cache
690    // invalidation normally happens (e.g. Label::set_text).  Hand the
691    // widget a single-shot dirty signal so the next paint re-rasterises.
692    if applied {
693        target.mark_dirty();
694        crate::animation::request_draw();
695    }
696    applied
697}