bevy-inspector-egui (Rust / egui)
The immediate-mode contrast: no node model, no rows, no entries — the tree is a recursive function over a reflected value, run every frame, with all persistent state living in the UI library's id-keyed side table.
| Language / toolkit | Rust / egui (immediate mode), Bevy ECS |
| License | MIT / Apache-2.0 |
| Repository | jakobhellermann/bevy-inspector-egui |
| Revision read | ac672985 (2026-06-20), crate version 0.37.0 |
| Category | Immediate mode, per-frame recursive descent |
| Metadata source | runtime — bevy_reflect TypeInfo + a TypeRegistry of type data |
| Undo | none |
Overview
What it solves
The crate states its own decomposition:
This crate contains
- general purpose machinery for displaying
Reflectvalues inreflect_inspector,- a way of associating arbitrary options with fields and enum variants in
inspector_options- utility functions for displaying bevy resource, entities and assets in
bevy_inspector
The split matters for this survey: reflect_inspector is a toolkit-agnostic value inspector that knows nothing about Bevy, and the Bevy-specific behaviour (following an asset handle, showing an entity) is injected as a short-circuit function.
How it works
InspectorUi (reflect_inspector/mod.rs:175) carries the type registry, a context, and three function pointers — short_circuit, short_circuit_readonly, short_circuit_many. The whole descent is one method:
match value.reflect_mut() {
ReflectMut::Struct(value) => self.ui_for_struct(value, ui, id, options),
ReflectMut::List(value) => self.ui_for_list(value, ui, id, options),
ReflectMut::Enum(value) => self.ui_for_enum(value, ui, id, options),
ReflectMut::Opaque(value) => { errors::reflect_value_no_impl(…); false }
…
}— reflect_inspector/mod.rs:260
Each arm recurses into its fields, and every function returns bool — "did this subtree change?" — which is the entire change-propagation mechanism.
Model & addressing
There is no model. Nothing is retained between frames except:
- the id:
egui::Id, derived structurally asid.with(i)per field index (reflect_inspector/mod.rs:533) — a hashed path from the root id; - whatever egui stores against that id: collapsing-header open state, drag state, text-edit cursor, and the crate's own scratch flags (
ui.data_mut(… get_temp_mut_or_default::<bool>(error_id)),reflect_inspector/mod.rs:886).
Consequences, in both directions:
- Nothing has to survive a rebuild, because everything is a rebuild. No selection restoration, no focus restoration, no entry-diffing: the id is stable as long as the structural path is, so state re-attaches by construction.
- Identity is positional. A list element's id is its index (
reflect_inspector/mod.rs:860), so removing element 0 shifts every later element's expansion, drag and text state up by one. This is the immediate-mode analogue of Godot's paging problem, and it is not addressed in the surveyed code. (INFERENCE from the id derivation; not exercised at runtime.)
Metadata
Two channels, both runtime:
TypeInfo— field names, and (behind thedocumentationfeature) doc comments, shown as hover text (reflect_inspector/mod.rs:527).InspectorOptionsin theTypeRegistry— arbitrary per-field/per-variant option objects, fetched by type id and threaded down the recursion as&dyn Any:rustif options.is::<()>() && let Some(data) = value.try_as_reflect().and_then(|val| { self.type_registry.get_type_data::<ReflectInspectorOptions>(val.type_id()) })—
reflect_inspector/mod.rs:238The leaf impls interpret them (
NumberOptionsforf32, and so on), so the option vocabulary is open-ended rather than a fixed attribute set. The#[derive(InspectorOptions)]macro inbevy-inspector-egui-deriveis the authoring surface.
Recursion
The descent decision is the ReflectRef/ReflectMut discriminant — a structural kind, not a type registry lookup. Ordering of the three dispatch layers is the design's real content (reflect_inspector/mod.rs:246):
- a registered
InspectorEguiImplfor the concrete type (the leaf-editor registry) — checked first, soVec3renders as three drag values rather than a struct; - the short-circuit function (Bevy's: follow a
Handle<T>into its asset, render anEntity, …); - the structural fallback above.
Materialisation is neither lazy nor eager in the usual sense: the recursion runs every frame for everything inside an open region, because a closed CollapsingHeader does not execute its body. Cost is therefore proportional to what is visible, with no retained cost at all — the one architecture in the corpus where a thousand-row object costs nothing when collapsed and nothing when closed again.
Cycles
Structurally impossible for the value graph, and delegated for the reference graph. The recursion holds &mut dyn PartialReflect down the whole path; Rust's aliasing rules mean a value cannot contain a mutable path back to itself, so ui_for_struct cannot re-enter the same value. Anything that would be a cycle — an entity referring to another entity, an asset handle pointing at an asset that holds the same handle — is not a value edge at all; it is resolved by the short-circuit against RestrictedWorldView, which is explicitly a "view into the world which may only access certain resources and components" (restricted_world_view.rs:27) and hands out disjoint borrows.
This is the corpus's most instructive cycle answer: cycle-freedom was bought by the ownership model, not by a visited set — and the price is that every cross-object reference has to leave the reflection walk and go through a mediated world view.
Editing & mutation
- Dispatch —
InspectorEguiImplregistry (per concrete type) → short-circuit → structural fallback. The fallback for an unrepresentable leaf is an inline error message naming the missing impl (ReflectMut::Opaque→errors::reflect_value_no_impl,reflect_inspector/mod.rs:269). - Mutation — direct, through
&mut, in place, immediately. There is no command, no transaction and no undo; the return valueboolonly tells the caller something changed (Bevy uses it to mark change detection). - Commit semantics — whatever the egui widget does: a
DragValuewrites on every pixel of a drag, a text field on every keystroke. There is no transient/committed distinction anywhere in the crate. - Change notification — moot. The next frame re-reads the value, so an external write appears immediately with no plumbing at all. This is the single largest simplification the immediate-mode model buys.
- Validation — none; a leaf impl may clamp, and structural failures render as error text.
Type coverage
Collections —
ui_for_listrenders per-element controls (add, remove, move up, move down —reflect_inspector/mod.rs:457) and applies the resultingListOp. Adding an element needs a default value for the element type; if the registry has none, the crate stashes an error flag in egui temp data and rendersno_default_value(reflect_inspector/mod.rs:886).Polymorphic / sum-typed values — the most thorough treatment in the corpus.
ui_for_enum(reflect_inspector/mod.rs:1470) draws aComboBoxof variant names; each entry is enabled only if the variant is constructable, where constructable means every field type hasReflectDefaultin the registry:rustlet type_id_is_constructable = |type_id: TypeId| { type_registry.get_type_data::<ReflectDefault>(type_id).is_some() };—
reflect_inspector/mod.rs:1889A disabled entry explains itself on hover, listing the field types that blocked it. Choosing a variant builds a
DynamicEnumfrom those defaults andapplys it (reflect_inspector/mod.rs:1786) — so a switch discards the old variant's data entirely, and the subtree that follows is the new variant's fields.Optional / nullable —
Option<T>is a Rust enum, so it goes through exactly the same variant picker; "unset" isNoneand the transition is a variant switch with a constructed default.Opaque types — a leaf with neither an
InspectorEguiImplnor structural reflection renders an error line naming the type and the reason (TypeDataError::NotFullyReflectedand friends).Multi-object editing — supported through a parallel
*_manyAPI:ui_for_reflect_manytakesvalues: &mut [&mut dyn PartialReflect]plus aProjectorReflectclosure (reflect_inspector/mod.rs:344), andui_for_enum_manyfirst checks whether every value is on the same variant before descending.
Presentation & control
- Grouping / ordering — declaration order; a
Gridper struct. No categories. - Conditional visibility — none built in; a caller composes its own
ui_for_*calls. - Search / filter — none in
reflect_inspector; the world inspector filters entities, not fields. - Escape hatches, in the order they are consulted: register an
InspectorEguiImplfor a type → supply a short-circuit for a whole family of values → call theui_for_*functions directly and write the layout yourself. There is no "customize this one field of this one type" seam short of the type-level impl. - Virtualization — unnecessary in the usual sense: closed regions execute no code. But every visible row is laid out from scratch each frame, so a very large open list is re-walked at frame rate.
Strengths
- The smallest architecture in the corpus by a wide margin: one recursive function, one id, one
bool. - No rebuild problem, no stale model, no change-notification plumbing — external mutation is free.
- Cycles are excluded by the borrow checker rather than by a runtime guard.
- The variant picker is honest about what it cannot construct, and says why.
- The short-circuit hook cleanly separates "how do I render a value" from "how do I reach a value that lives elsewhere".
Weaknesses
- No undo, no transactions, no transient edits — every keystroke is a committed mutation.
- Node identity is positional, so mutating a collection shuffles per-element UI state.
- No conditional visibility, categories, filtering or per-field customization seam.
- Requires
&mutaccess to the whole value for the duration of the frame, which is why Bevy needsRestrictedWorldViewmachinery to hand out disjoint borrows. - Everything visible is re-walked every frame; there is no way to cache a subtree's layout.
Key design decisions and trade-offs
| Decision | Rationale | Trade-off |
|---|---|---|
| No node model; recurse per frame | Nothing to invalidate, nothing to synchronise | No place to hang per-node state that is not id-keyed |
egui::Id derived from the structural path | State re-attaches automatically across frames | Positional identity: collection edits shift state |
| Leaf-editor registry consulted before structure | Vec3, Color, Handle render as units, not structs | The registry is global; two crates cannot disagree per site |
| Short-circuit function pointer | Keeps reflect_inspector free of Bevy | Cross-object navigation is invisible to the descent's own rules |
| Variant switch = construct a default | Type-correct by construction | Old variant's data is discarded; unconstructable variants are unofferable |
&mut all the way down | No copies, immediate writes, no notification | Cannot show two views of the same value simultaneously |
Sources
All line numbers are at ac672985.
crates/bevy-inspector-egui/src/reflect_inspector/mod.rs—InspectorUi, dispatch order, struct/list/map/enum descent, variant constructioncrates/bevy-inspector-egui/src/lib.rs— the crate's own decompositioncrates/bevy-inspector-egui/src/restricted_world_view.rs— the mediated world access the short-circuit usescrates/bevy-inspector-egui/src/inspector_options/— the options vocabulary threaded through the recursion