Custom Inspector Fields
Make your own components editable in the Inspector with a #[derive(Inspectable)] and one register_inspectable call — no egui, no closures, just declarative field metadata.
The model: declarative FieldDefs, not egui closures
The Inspector is data-driven. Each component type registers an InspectorEntry — a name, icon, category, and a Vec<FieldDef> — into the renzora::InspectorRegistry resource. A FieldDef is just a label, a FieldType (which widget to show), and a pair of fn(&World, Entity) get/set pointers. The editor's native bevy_ui inspector walks the registry, reads each field through get_fn, draws the matching widget, and writes edits back through set_fn. You almost never build a FieldDef by hand — the #[derive(Inspectable)] macro generates the whole entry from your struct.
These types live in the renzora crate (the shared renzora.dll contract), gated behind its editor feature so non-editor builds carry zero editor surface.
⚠️ egui is gone. There is no
InspectorRegistry::register::<T>(|ui, comp| { ui.add(egui::Slider …) })closure API, noegui::DragValue/ComboBox/CollapsingHeader, and norenzora_editor::InspectorRegistryimport. Any example showing anegui::*widget or a|ui, comp|body is from a dead API — ignore it. DeclarativeFieldDefs drive everything; for genuinely custom UI you register a bevy_ui drawer (last section), not an egui closure.
The fast path: #[derive(Inspectable)]
Annotate a component struct, then register it from your plugin. This is the complete renzora_test_component example that ships in the engine:
use bevy::prelude::*;
use renzora::{AppEditorExt, Inspectable};
/// Health component with current/max HP and a shield flag.
#[derive(Component, Default, Reflect, Inspectable)]
#[inspectable(name = "Health", icon = "HEART", category = "gameplay")]
pub struct Health {
#[field(speed = 1.0, min = 0.0, max = 10000.0)]
pub current: f32,
#[field(speed = 1.0, min = 1.0, max = 10000.0)]
pub max: f32,
#[field(name = "Shield")]
pub has_shield: bool,
}
#[derive(Default)]
pub struct TestComponentPlugin;
impl Plugin for TestComponentPlugin {
fn build(&self, app: &mut App) {
app.register_inspectable::<Health>();
}
}
// Editor-scope: replayed into the app by the renzora_editor bundle.
renzora::add!(TestComponentPlugin, Editor);
register_inspectable::<T>() (an AppEditorExt method on App) does two things: it calls app.register_type::<T>() (so scripts and reflection can reach the component) and inserts T::inspector_entry() into the InspectorRegistry. Select an entity carrying Health and the component appears with a heart icon, two clamped drag values, and a "Shield" checkbox.
Cargo dependencies
Inspectable, AppEditorExt, FieldDef, and friends are behind renzora's editor feature, which is off by default. A crate that derives Inspectable must turn it on:
[dependencies]
bevy = { workspace = true }
renzora = { path = "../renzora", default-features = false, features = ["editor"] }
renzora_macros = { path = "../renzora_macros" }
There is no
renzora::prelude. Import what you need directly —use renzora::{AppEditorExt, Inspectable};— or glob withuse renzora::*;.
#[inspectable(...)] — struct attributes
All optional; each has a sensible default derived from the struct name.
| Attribute | Default | Effect |
|---|---|---|
name = "..." | title-cased struct name | Section header shown in the Inspector |
icon = "..." | "CUBE" | Phosphor icon constant (SCREAMING_SNAKE_CASE); emitted as a kebab-case name (HEART → heart, SNEAKER_MOVE → sneaker-move) that the native inspector resolves to a glyph |
category = "..." | "component" | Grouping key (e.g. "gameplay") |
type_id = "..." | snake_case struct name | Stable string key for the entry in the registry |
#[field(...)] — field attributes
| Attribute | Applies to | Effect |
|---|---|---|
speed = <num> | Float (default 0.01), Vec3 (default 0.1) | Drag sensitivity |
min = <num> | Float (default f32::MIN) | Lower clamp |
max = <num> | Float (default f32::MAX) | Upper clamp |
name = "..." | any field | Override the label (default = title-cased field name) |
skip | any field | Omit the field from the Inspector entirely |
readonly | any field | Force a non-editable {:?} debug display |
default = <num> | (see note) | Parsed but ignored by #[derive(Inspectable)] |
⚠️
#[field(default = ...)]is recognised by the attribute parser but has no effect under#[derive(Inspectable)]— initial values come from your type's ownDefaultimpl (the generated "Add Component" action insertsT::default()).defaultis only consumed by the#[post_process]attribute macro, which no longer has any users in the tree. Don't rely on it to seed inspector fields.
Supported field types
The derive infers the widget from each field's Rust type. Only these map to an editable widget; anything else becomes a read-only debug string:
| Rust field type | FieldType | Widget |
|---|---|---|
f32, f64 | Float { speed, min, max } | Drag value / slider |
bool | Bool | Checkbox / toggle |
Vec3 | Vec3 { speed } | XYZ drag values |
String | String | Text input |
Color (bevy) | Color | RGB color picker |
| everything else | ReadOnly | Non-editable {:?} text |
⚠️ The derive does not auto-handle
i32/u32and other integers,Vec2,Entity, enums, orHandle<_>— they all silently fall through toReadOnly. To make those editable, use the manual field macros or a native drawer below. (This is the big departure from older docs, which claimed automatic integer drags,Vec2, entity pickers, and enum dropdowns from the derive.)
Requirements
A struct deriving Inspectable must:
- Be a struct with named fields (tuple/unit structs and enums are rejected at compile time).
- Derive
ComponentandDefault—register_inspectable::<T>()requiresT: Component + Default, and the generated add action insertsT::default(). - Derive
Reflect—register_inspectablecallsregister_type::<T>(), which needsT: GetTypeRegistration.
Fields named enabled or starting with _p are skipped automatically (a convenience for the post-process layout convention).
Editor-only registration in dual-mode plugins
The TestComponentPlugin above is editor-scope (add!(_, Editor)), so its whole crate is always built with the editor feature and the register_inspectable call needs no guard.
A plugin that must run in both the editor and the shipped game (any runtime component that also wants an inspector) is different: its register_inspectable call must be compiled out when the editor feature is off. The crate declares its own editor feature that forwards to renzora/editor, and gates the call. This is what a dual-mode distribution plugin like renzora_vignette or renzora_lumen does:
[features]
default = ["editor", "dlopen"]
dlopen = []
editor = ["renzora/editor"]
use bevy::prelude::*;
#[cfg(feature = "editor")]
use renzora::AppEditorExt;
impl Plugin for MyPlugin {
fn build(&self, app: &mut App) {
app.register_type::<MySettings>();
// ... runtime systems / plugins that run everywhere ...
#[cfg(feature = "editor")]
app.register_inspectable::<MySettings>();
}
}
renzora::add!(MyPlugin);
When the game is built without the editor feature, the derive's InspectableComponent impl and the registration both vanish, leaving only the plain runtime component.
Beyond the derive: manual FieldDefs
For fields the derive maps to ReadOnly (integers, enum-as-u32, RGBA-with-alpha, Vec3-stored colors), build the FieldDefs yourself with the field-builder macros and hand the entry to register_inspector. The macros are exported at the renzora crate root (under the editor feature):
| Macro | Field shape | Widget |
|---|---|---|
renzora::float_field!(name, Comp, field, speed, min, max) | f32 | Drag value |
renzora::int_field!(name, Comp, field, ty, speed, min, max) | u32/i32/… | Drag value, cast on write |
renzora::bool_field!(name, Comp, field) | bool | Checkbox |
renzora::string_field!(name, Comp, field) | String | Text input |
renzora::color_rgba_field!(name, Comp, field) | Color | RGBA picker (editable alpha) |
renzora::vec3_color_field!(name, Comp, field) | Vec3 (RGB) | Color picker |
renzora::tuple_color_field!(name, Comp, field) | (f32, f32, f32) | Color picker |
renzora::enum_u32_field!(name, Comp, field, ["A", "B", ...]) | u32 index | Dropdown |
use bevy::prelude::*;
use renzora::{
AppEditorExt, FieldDef, FieldType, FieldValue, InspectorEntry,
int_field, enum_u32_field,
};
#[derive(Component, Default, Reflect)]
pub struct SpawnConfig {
pub max_entities: u32, // integer → not handled by the derive
pub mode: u32, // enum-as-index → dropdown
pub spawn_rate: f32,
}
fn register_spawn_config(app: &mut App) {
app.register_type::<SpawnConfig>();
app.register_inspector(InspectorEntry {
type_id: "spawn_config",
display_name: "Spawn Config",
icon: "sparkle", // already kebab-case here, not a constant
category: "gameplay",
has_fn: |w, e| w.get::<SpawnConfig>(e).is_some(),
add_fn: Some(|w, e| { w.entity_mut(e).insert(SpawnConfig::default()); }),
remove_fn: Some(|w, e| { w.entity_mut(e).remove::<SpawnConfig>(); }),
is_enabled_fn: None,
set_enabled_fn: None,
fields: vec![
int_field!("Max Entities", SpawnConfig, max_entities, u32, 1.0, 0.0, 10000.0),
enum_u32_field!("Mode", SpawnConfig, mode, ["Burst", "Stream", "Trickle"]),
FieldDef {
name: "Spawn Rate",
field_type: FieldType::Float { speed: 0.1, min: 0.0, max: 100.0 },
get_fn: |w, e| w.get::<SpawnConfig>(e).map(|c| FieldValue::Float(c.spawn_rate)),
set_fn: |w, e, v| {
if let (FieldValue::Float(f), Some(mut c)) = (v, w.get_mut::<SpawnConfig>(e)) {
c.spawn_rate = f;
}
},
},
],
});
}
Inspector entries sort with
type_id == "name"first,"transform"second,"material_ref"third, and everything else appended in registration order.add_fn/remove_fnbeingSomeis what makes a component show up in the Add Component overlay and gain a trash button; leave themNoneto register a read-only, non-removable card.
Custom bevy_ui drawers
When declarative fields aren't enough — conditional rows, buttons, validation warnings, a bespoke layout — register a native drawer that builds an arbitrary bevy_ui subtree for the component. This is the modern replacement for the old egui closure:
use bevy::prelude::*;
use renzora::AppEditorExt;
// NativeInspectorDrawer = fn(&mut World, Entity) -> Entity
fn draw_spawn_config(world: &mut World, entity: Entity) -> Entity {
let mut q = bevy::ecs::world::CommandQueue::default();
let root;
{
let mut c = Commands::new(&mut q, world);
root = c.spawn(Node { /* ... */ ..default() }).id();
// build + bind ember widgets, conditional rows, buttons, etc.
}
q.apply(world);
root
}
fn register(app: &mut App) {
app.register_native_inspector_ui("spawn_config", draw_spawn_config);
}
The drawer takes &mut World and the selected Entity, builds its UI (typically via a local CommandQueue so it can use ember widgets and two-way bindings), and returns the root entity; the inspector parents that under the component's section header. Read the world through bindings and write it from your own systems or interaction callbacks — see Building Editor Panels for the reactive helpers.
Your drawer runs whenever its section becomes visible, and its subtree is thrown away whenever it stops being — write it idempotent. A drawer is not called once per selection. It is called when its section is expanded and when the section scrolls back into view, and its subtree is despawned when the section is collapsed or scrolls far enough out of view. Two consequences: don't stash state in the returned entities that you can't rebuild (put it in a
Resourceand read it back), and don't do expensive one-time setup in the drawer body — it can run many times per selection.Why it works this way: hiding is not free. Collapsing a section only sets its body to
Display::None, andbevy_uidoes not prune hidden subtrees from its per-frame walk —compute_hidden_layoutclears the cache and recurses, so hidden rows are never cached and pay full layout every frame. Scrolling a section off screen is the same story: still built, still walked, still charged. An entity with a dozen components and two sections open was laying out every row of the other ten, and running all ten drawers to build content nobody could see.So the inspector builds section bodies empty and fills them only while they are both expanded and near the viewport. Both decisions land in one place —
renzora_inspector::native::reconcile_section_bodiesbuilds and tears down;cull_offscreen_sectionsonly sets the flag it reads. See Profiling for what it measured.
Reference
FieldType
pub enum FieldType {
Float { speed: f32, min: f32, max: f32 },
Vec3 { speed: f32 },
Bool,
Color,
ColorRgba, // RGBA with editable alpha
String,
ReadOnly,
Asset { extensions: Vec<String> }, // drag-drop from the asset browser
Enum { options: &'static [&'static str] },
}
FieldValue
pub enum FieldValue {
Float(f32),
Vec3([f32; 3]),
Bool(bool),
Color([f32; 3]),
ColorRgba([f32; 4]),
String(String),
ReadOnly(String),
Asset(Option<String>),
Enum(String),
}
InspectorEntry
pub struct InspectorEntry {
pub type_id: &'static str,
pub display_name: &'static str,
pub icon: &'static str, // kebab-case Phosphor name
pub category: &'static str,
pub has_fn: fn(&World, Entity) -> bool,
pub add_fn: Option<fn(&mut World, Entity)>,
pub remove_fn: Option<fn(&mut World, Entity)>,
pub is_enabled_fn: Option<fn(&World, Entity) -> bool>,
pub set_enabled_fn: Option<fn(&mut World, Entity, bool)>,
pub fields: Vec<FieldDef>,
}
InspectorRegistry::register pins transform first and material_ref second; everything else keeps its registration order.
Not everything belongs in a section. The entity's id, icon, hierarchy label colour and visibility are rendered by the Inspector's fixed entity header, not by registry entries — none of them is a component you can add or remove, so a collapsible card with add/remove/enable affordances was the wrong container for them. If you are adding something that describes the entity rather than a component on it, extend
renzora_inspector::entity_headerinstead of registering an entry. The authored halves (renzora::EntityIcon,renzora::EntityLabelColor) live in the contract crate and are reflect-registered byrenzora_engine, so they serialize into the scene and resolve in the runtime.