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/// Depth-first search for a widget by its [`Widget::type_name`].  Returns
186/// the first match in paint order.  Used by tests that want to assert on
187/// a specific widget kind inside an opaque content subtree (e.g.
188/// "find the ScrollView inside this window").
189pub fn find_widget_by_type<'a>(widget: &'a dyn Widget, type_name: &str) -> Option<&'a dyn Widget> {
190    if widget.type_name() == type_name {
191        return Some(widget);
192    }
193    for child in widget.children() {
194        if let Some(found) = find_widget_by_type(child.as_ref(), type_name) {
195            return Some(found);
196        }
197    }
198    None
199}
200
201/// Walk the subtree rooted at `widget` and collect an `InspectorNode` per
202/// widget in DFS paint order (root first).
203///
204/// `screen_origin` is the accumulated parent offset in screen Y-up coords.
205/// Widgets that apply additional transforms between themselves and their
206/// children (NodeEditor's pan/zoom, etc.) opt in via
207/// [`Widget::inspector_child_transform`]; the traversal composes those
208/// transforms so descendant `screen_bounds` reflect what the user sees.
209pub fn collect_inspector_nodes(
210    widget: &dyn Widget,
211    depth: usize,
212    screen_origin: Point,
213    out: &mut Vec<InspectorNode>,
214) {
215    let mut parent_to_screen = crate::TransAffine::new();
216    parent_to_screen.translate(screen_origin.x, screen_origin.y);
217    collect_inspector_nodes_with_path(widget, depth, &parent_to_screen, &[], out);
218}
219
220/// AABB of `rect`'s four corners after passing through `t` — equals
221/// `t(rect)` exactly for translation + uniform scale (no rotation), and
222/// is the right conservative bound otherwise.
223fn transform_rect_aabb(t: &crate::TransAffine, rect: Rect) -> Rect {
224    let corners = [
225        (rect.x, rect.y),
226        (rect.x + rect.width, rect.y),
227        (rect.x, rect.y + rect.height),
228        (rect.x + rect.width, rect.y + rect.height),
229    ];
230    let mut min_x = f64::INFINITY;
231    let mut min_y = f64::INFINITY;
232    let mut max_x = f64::NEG_INFINITY;
233    let mut max_y = f64::NEG_INFINITY;
234    for (mut x, mut y) in corners {
235        t.transform(&mut x, &mut y);
236        if x < min_x {
237            min_x = x;
238        }
239        if x > max_x {
240            max_x = x;
241        }
242        if y < min_y {
243            min_y = y;
244        }
245        if y > max_y {
246            max_y = y;
247        }
248    }
249    Rect::new(
250        min_x,
251        min_y,
252        (max_x - min_x).max(0.0),
253        (max_y - min_y).max(0.0),
254    )
255}
256
257/// Effective uniform scale extracted from `t` — used to scale logical
258/// margin / padding insets so the F12-style overlay bands stay
259/// visually proportional to the (scaled) widget bounds.  For pure
260/// translation + uniform scale this returns the scale exactly; under
261/// non-uniform scale it returns the geometric mean of the axes, which
262/// is the right "single number" for matching the visible band width.
263fn effective_scale(t: &crate::TransAffine) -> f64 {
264    let sx = (t.sx * t.sx + t.shy * t.shy).sqrt();
265    let sy = (t.shx * t.shx + t.sy * t.sy).sqrt();
266    if sx > 0.0 && sy > 0.0 {
267        (sx * sy).sqrt()
268    } else {
269        1.0
270    }
271}
272
273fn collect_inspector_nodes_with_path(
274    widget: &dyn Widget,
275    depth: usize,
276    parent_to_screen: &crate::TransAffine,
277    path_prefix: &[usize],
278    out: &mut Vec<InspectorNode>,
279) {
280    // Invisible widgets (and their entire subtrees) are excluded from the
281    // inspector — they are not part of the live rendered scene.
282    if !widget.is_visible() {
283        return;
284    }
285    // Utility widgets opt out of the inspector entirely.
286    if !widget.show_in_inspector() {
287        return;
288    }
289
290    let b = widget.bounds();
291    // Transform the widget's parent-local bounds rect through the
292    // accumulated parent_to_screen transform.  For the common case
293    // (pure translation, no scale) this collapses to the old
294    // `screen_origin + b.x/y` math; under scale it now correctly
295    // reports the on-screen size.
296    let abs = transform_rect_aabb(parent_to_screen, b);
297    let scale = effective_scale(parent_to_screen);
298    // Build the properties vec — include the universal `backbuffer` flag
299    // first (so every widget shows it in a consistent location), then the
300    // widget-specific properties.
301    let mut props = vec![(
302        "backbuffer",
303        if widget.has_backbuffer() {
304            "true".to_string()
305        } else {
306            "false".to_string()
307        },
308    )];
309    props.extend(widget.properties());
310    // Reflection-driven property dump.  Widgets that opt into the
311    // companion-props pattern (`Widget::as_reflect`) get their reflected
312    // struct fields surfaced here as `(name, formatted)` pairs — typed,
313    // accurate, and free of the hand-maintained `properties()` strings
314    // they would otherwise need.  Fields that aren't a struct, or that
315    // can't be displayed, are silently skipped.
316    #[cfg(feature = "reflect")]
317    if let Some(reflected) = widget.as_reflect() {
318        props.extend(reflect_fields(reflected));
319    }
320    let (h_anchor, v_anchor) = widget
321        .widget_base()
322        .map(|b| (b.h_anchor, b.v_anchor))
323        .unwrap_or((
324            crate::layout_props::HAnchor::FIT,
325            crate::layout_props::VAnchor::FIT,
326        ));
327    let margin_logical = widget.margin();
328    let padding_logical = widget.padding();
329    let scaled_margin = crate::layout_props::Insets {
330        left: margin_logical.left * scale,
331        right: margin_logical.right * scale,
332        top: margin_logical.top * scale,
333        bottom: margin_logical.bottom * scale,
334    };
335    let scaled_padding = crate::layout_props::Insets {
336        left: padding_logical.left * scale,
337        right: padding_logical.right * scale,
338        top: padding_logical.top * scale,
339        bottom: padding_logical.bottom * scale,
340    };
341    out.push(InspectorNode {
342        type_name: widget.type_name(),
343        screen_bounds: abs,
344        margin: scaled_margin,
345        padding: scaled_padding,
346        h_anchor,
347        v_anchor,
348        depth,
349        path: path_prefix.to_vec(),
350        properties: props,
351    });
352
353    // Widgets that are part of the inspector infrastructure opt out of child
354    // recursion to prevent the inspector from growing its own node list every
355    // frame (exponential growth).  Their sub-trees are still visible in the
356    // inspector on the next frame through the normal layout snapshot.
357    if !widget.contributes_children_to_inspector() {
358        return;
359    }
360
361    // Compose the transform for child traversal.  Order matches the
362    // paint pipeline: parent_to_screen ∘ translate(b.x, b.y) ∘
363    // widget.inspector_child_transform().  That is, applied to a point
364    // in child-local space, we first run the widget's own child
365    // transform (e.g. NodeEditor's pan/zoom), then translate by the
366    // widget's bounds offset (its position in its parent), then through
367    // the parent_to_screen chain.
368    let mut child_to_screen = *parent_to_screen;
369    child_to_screen.translate(b.x, b.y);
370    let extra = widget.inspector_child_transform();
371    child_to_screen.premultiply(&extra);
372
373    let mut child_path: Vec<usize> = Vec::with_capacity(path_prefix.len() + 1);
374    child_path.extend_from_slice(path_prefix);
375    child_path.push(0);
376    for (i, child) in widget.children().iter().enumerate() {
377        *child_path.last_mut().unwrap() = i;
378        collect_inspector_nodes_with_path(
379            child.as_ref(),
380            depth + 1,
381            &child_to_screen,
382            &child_path,
383            out,
384        );
385    }
386}
387
388// ---------------------------------------------------------------------------
389// Runaway-repaint diagnostic report
390// ---------------------------------------------------------------------------
391
392/// Recursively collect every *visible* widget whose [`Widget::needs_draw`]
393/// currently returns `true`, recording its child-index path, type name, and
394/// whether it is "self-hot" (no visible child is independently asking for a
395/// draw, so this widget's own state is the driver).
396///
397/// The walk descends into **all** visible children — it does not prune on a
398/// parent whose `needs_draw()` is `false`.  That deliberately catches
399/// propagation gaps (a hot child under a container that forgot to OR its
400/// children into `needs_draw`, the exact class of bug behind the intermittent
401/// runaway), which a prune-on-false walk would hide.
402fn collect_needs_draw(
403    widget: &dyn Widget,
404    path: &mut Vec<usize>,
405    out: &mut Vec<(Vec<usize>, &'static str, bool)>,
406) {
407    // Same visibility gate the host loop uses: invisible subtrees never keep
408    // the app awake, so they are irrelevant to a runaway and are skipped.
409    if !widget.is_visible() {
410        return;
411    }
412    if widget.needs_draw() {
413        let child_hot = widget.children().iter().any(|c| c.needs_draw());
414        out.push((path.clone(), widget.type_name(), !child_hot));
415    }
416    for (i, child) in widget.children().iter().enumerate() {
417        path.push(i);
418        collect_needs_draw(child.as_ref(), path, out);
419        path.pop();
420    }
421}
422
423/// Build a human-readable diagnostic naming everything currently keeping the
424/// reactive host awake, for capturing the intermittent "continuous rendering
425/// never quiesces" runaway on real hardware.
426///
427/// The report contains, in order:
428/// 1. the raw immediate-draw flag (read side-effect-free — see
429///    [`crate::animation::peek_draw_signals`], which unlike `wants_draw`
430///    neither pumps async wakeups nor clears a due deadline),
431/// 2. the next scheduled-draw deadline with remaining time (and a `DUE`
432///    marker when already past),
433/// 3. the drained draw-request provenance tags, deduplicated with counts
434///    (always empty in release builds — the trace is debug-only), and
435/// 4. every visible widget whose `needs_draw()` is `true`, as `[path] Type`,
436///    with a `<- self` marker on the ones whose own state (not a child's)
437///    is the driver.
438///
439/// Takes the tree `root` as a parameter so it can be invoked from a host
440/// shell that holds `&App` (`app.root()`), matching the introspection style
441/// of [`collect_inspector_nodes`].
442#[doc(hidden)]
443pub fn debug_draw_report(root: &dyn Widget) -> String {
444    use std::fmt::Write;
445    let mut s = String::new();
446    let (needs_flag, deadline) = crate::animation::peek_draw_signals();
447    let now = web_time::Instant::now();
448
449    let _ = writeln!(s, "== agg-gui draw report ==");
450    let _ = writeln!(s, "immediate needs_draw flag: {needs_flag}");
451    match deadline {
452        Some(when) => {
453            let remaining_ms = when.saturating_duration_since(now).as_secs_f64() * 1000.0;
454            let due = now >= when;
455            let _ = writeln!(
456                s,
457                "next scheduled deadline: {remaining_ms:.1} ms{}",
458                if due { " (DUE)" } else { "" }
459            );
460        }
461        None => {
462            let _ = writeln!(s, "next scheduled deadline: none");
463        }
464    }
465
466    // (c) Drained provenance tags, deduplicated with counts (most-frequent
467    // first).  Draining is what the quiescence guard already does; doing it
468    // here means a subsequent report starts from a clean trace.
469    let tags = crate::animation::drain_draw_trace();
470    if tags.is_empty() {
471        let _ = writeln!(
472            s,
473            "draw-trace tags: none (empty in release builds — trace is debug-only)"
474        );
475    } else {
476        let mut counts: Vec<(&'static str, usize)> = Vec::new();
477        for t in tags {
478            if let Some(entry) = counts.iter_mut().find(|(name, _)| *name == t) {
479                entry.1 += 1;
480            } else {
481                counts.push((t, 1));
482            }
483        }
484        counts.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
485        let _ = writeln!(s, "draw-trace tags ({} distinct):", counts.len());
486        for (name, count) in counts {
487            let _ = writeln!(s, "  {count:>4} x {name}");
488        }
489    }
490
491    // (d) Every visible widget whose needs_draw() is true.
492    let mut hot: Vec<(Vec<usize>, &'static str, bool)> = Vec::new();
493    collect_needs_draw(root, &mut Vec::new(), &mut hot);
494    if hot.is_empty() {
495        let _ = writeln!(s, "widgets wanting draw: none");
496    } else {
497        let _ = writeln!(s, "widgets wanting draw ({}):", hot.len());
498        for (path, type_name, self_hot) in hot {
499            let path_str = if path.is_empty() {
500                "root".to_string()
501            } else {
502                path.iter()
503                    .map(|i| i.to_string())
504                    .collect::<Vec<_>>()
505                    .join("/")
506            };
507            let _ = writeln!(
508                s,
509                "  [{path_str}] {type_name}{}",
510                if self_hot { "  <- self" } else { "" }
511            );
512        }
513    }
514
515    s
516}
517
518/// Walk the widget tree from `root` along `path` and return the deepest
519/// reachable widget as a mutable reference.  Returns `None` if the path
520/// indexes past the available children at any level — useful when the path
521/// is stale (e.g. the tree shape changed since the inspector snapshot).
522pub fn walk_path_mut<'a>(root: &'a mut dyn Widget, path: &[usize]) -> Option<&'a mut dyn Widget> {
523    let mut node: &mut dyn Widget = root;
524    for &idx in path {
525        let children = node.children_mut();
526        if idx >= children.len() {
527            return None;
528        }
529        node = children[idx].as_mut();
530    }
531    Some(node)
532}
533
534/// A pending inspector edit: navigate to the widget at `path`, look up
535/// `field_path` via reflection, and apply `new_value`.
536///
537/// Edits are queued by the inspector and drained by the host frame loop —
538/// applying them mid-paint or mid-event-dispatch could violate borrow rules
539/// or layout invariants.
540#[cfg(feature = "reflect")]
541pub struct InspectorEdit {
542    pub path: Vec<usize>,
543    /// Reflection path inside the target widget's `as_reflect` value, e.g.
544    /// `"checked"` or `"value"` or `"margin.left"`.
545    pub field_path: String,
546    /// Replacement value, already type-correct for the target field.
547    pub new_value: Box<dyn bevy_reflect::PartialReflect>,
548}
549
550#[cfg(feature = "reflect")]
551impl std::fmt::Debug for InspectorEdit {
552    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
553        f.debug_struct("InspectorEdit")
554            .field("path", &self.path)
555            .field("field_path", &self.field_path)
556            .finish_non_exhaustive()
557    }
558}
559
560// ---------------------------------------------------------------------------
561// WidgetBase live editing (no reflect feature required)
562// ---------------------------------------------------------------------------
563
564/// One field in a widget's [`crate::layout_props::WidgetBase`] that the
565/// inspector can change at runtime.
566#[derive(Clone, Debug)]
567pub enum WidgetBaseField {
568    MarginLeft(f64),
569    MarginRight(f64),
570    MarginTop(f64),
571    MarginBottom(f64),
572    HAnchor(crate::layout_props::HAnchor),
573    VAnchor(crate::layout_props::VAnchor),
574    MinWidth(f64),
575    MinHeight(f64),
576    MaxWidth(f64),
577    MaxHeight(f64),
578}
579
580/// Queued mutation for a widget's `WidgetBase`.  The inspector pushes these;
581/// the host frame loop drains and applies via [`apply_widget_base_edit`].
582#[derive(Clone, Debug)]
583pub struct WidgetBaseEdit {
584    /// Path of child indices from the App root to the target widget.
585    pub path: Vec<usize>,
586    pub field: WidgetBaseField,
587}
588
589/// Apply a single queued `WidgetBaseEdit` against the live widget tree.
590/// Returns `true` when the edit landed, `false` if the path was stale or the
591/// widget does not expose a `WidgetBase`.
592pub fn apply_widget_base_edit(root: &mut dyn Widget, edit: &WidgetBaseEdit) -> bool {
593    let Some(target) = walk_path_mut(root, &edit.path) else {
594        return false;
595    };
596    let Some(base) = target.widget_base_mut() else {
597        return false;
598    };
599    match &edit.field {
600        WidgetBaseField::MarginLeft(v) => base.margin.left = *v,
601        WidgetBaseField::MarginRight(v) => base.margin.right = *v,
602        WidgetBaseField::MarginTop(v) => base.margin.top = *v,
603        WidgetBaseField::MarginBottom(v) => base.margin.bottom = *v,
604        WidgetBaseField::HAnchor(a) => base.h_anchor = *a,
605        WidgetBaseField::VAnchor(a) => base.v_anchor = *a,
606        WidgetBaseField::MinWidth(v) => base.min_size.width = v.max(0.0),
607        WidgetBaseField::MinHeight(v) => base.min_size.height = v.max(0.0),
608        WidgetBaseField::MaxWidth(v) => base.max_size.width = v.max(0.0),
609        WidgetBaseField::MaxHeight(v) => base.max_size.height = v.max(0.0),
610    }
611    target.mark_dirty();
612    crate::animation::request_draw();
613    true
614}
615
616/// Apply a single queued inspector edit against the live widget tree.
617/// Returns `true` if the edit landed; `false` if the path was stale or the
618/// field path didn't resolve.
619#[cfg(feature = "reflect")]
620pub fn apply_inspector_edit(root: &mut dyn Widget, edit: &InspectorEdit) -> bool {
621    use bevy_reflect::{GetPath, PartialReflect};
622    let Some(target) = walk_path_mut(root, &edit.path) else {
623        return false;
624    };
625    let applied;
626    {
627        let Some(reflected) = target.as_reflect_mut() else {
628            return false;
629        };
630        let Ok(field) = reflected.reflect_path_mut(edit.field_path.as_str()) else {
631            return false;
632        };
633        let field: &mut dyn PartialReflect = field;
634        applied = field.try_apply(edit.new_value.as_ref()).is_ok();
635    }
636    // Reflection bypasses the widget's setters, which is where cache
637    // invalidation normally happens (e.g. Label::set_text).  Hand the
638    // widget a single-shot dirty signal so the next paint re-rasterises.
639    if applied {
640        target.mark_dirty();
641        crate::animation::request_draw();
642    }
643    applied
644}