Godot EditorInspector (C++ / Godot editor)
A widget-per-property inspector with no node model at all: the tree is the scene graph of the panel, rebuilt wholesale whenever anything structural changes, and every piece of state that must outlive a rebuild is pushed onto the edited object.
| Language / toolkit | C++ / Godot's own Control toolkit |
| License | MIT |
| Repository | godotengine/godot, editor/inspector/ |
| Revision read | 944a3c6c (2026-08-18, master) |
| Category | Retained widget tree, full rebuild on change |
| Metadata source | runtime — Object::get_property_list() → a flat List<PropertyInfo> |
| Undo | EditorUndoRedoManager transactions, with merge |
Overview
What it solves
The editor's object inspector: one panel that edits any Object — a Node, a Resource, a project setting, a remote debugger stand-in — plus the plugin surface (EditorInspectorPlugin) that lets any editor plugin insert or replace rows.
Design philosophy
The design is visible in update_tree()'s first act, which is to save what it is about to destroy:
cpp// Store currently selected and focused elements to restore after the update. // TODO: Can be useful to store more context for the focusable, such as the caret position in LineEdit.
That comment is the whole architecture in two lines: because the presented tree is the widget tree and there is no model behind it, a refresh is a teardown (_clear(false), editor_inspector.cpp:4424) and everything the reader was in the middle of has to be reconstructed by hand — with the caret position acknowledged as not reconstructed at all.
How it works
EditorProperty(editor_inspector.h:70) is aContainerwidget bound to an(Object *object, StringName property)pair. It is the row: label, value editor, and the revert/keying/pin/favourite affordances.EditorInspectorSection(editor_inspector.h:462) is a foldable group header — also a widget, also rebuilt.EditorInspectorownsupdate_tree()(editor_inspector.cpp:4380), which reads the object's property list and instantiates one widget per row.EditorInspectorPluginhooks the walk:parse_begin,parse_category,parse_group,parse_property(returningtrueto replace the default editor),parse_end(editor_inspector.cpp:1848).
Model & addressing
There is no node model. The address of a row is the pair (edited Object, property path string), held on the EditorProperty widget; nested rows use /-separated paths (material/albedo_color). Two consequences:
- Rows are found by string.
editor_property_mapmaps a property path to the list of liveEditorPropertywidgets for it, which is how a value change reaches its editors (editor_inspector.cpp:5821). - Nothing survives a rebuild automatically. Selection and focus are saved and restored explicitly around
_clear(); expansion is not saved at all in the panel — it is read back from the object (below).
Metadata
Runtime, and stringly-typed: Object::get_property_list(&plist, true) (editor_inspector.cpp:4477) yields a flat, ordered List<PropertyInfo> where each entry carries name, type, hint, hint_string and a usage bitfield.
The grouping is inline in that stream, not a tree: entries whose usage has PROPERTY_USAGE_CATEGORY, PROPERTY_USAGE_GROUP or PROPERTY_USAGE_SUBGROUP are pseudo-rows that set the current group and a name prefix (editor_inspector.cpp:4506); subsequent properties join the group if their name begins_with(group_base) (editor_inspector.cpp:4715), and remaining /-separated path components create nested VBoxContainers (editor_inspector.cpp:4814). The presented hierarchy is thus reconstructed from a flat list by string prefix matching, every rebuild.
A script can rewrite this stream per object (_get_property_list) and per property (_validate_property), so metadata is fully dynamic — the price is that it must be re-derived on every update_tree().
Recursion
The descent decision is by Variant type and hint, resolved when the row's editor is chosen. Two mechanisms produce subtrees:
A nested inspector.
EditorPropertyResourceinstantiates an entire childEditorInspectoras its "bottom editor" when the value is a validResourceand the section is unfolded:cppif (res.is_valid() && get_edited_object()->editor_is_section_unfolded(get_edited_property())) { if (!sub_inspector) { sub_inspector = memnew(EditorInspector);So materialization is lazy, and it is lazy precisely because the fold state is consulted first.
A composite editor. Vectors, transforms, arrays and dictionaries are single
EditorPropertywidgets that lay out their own sub-rows (editor_properties_vector.cpp,editor_properties_array_dict.cpp) — they are not inspector nodes at all.
Expansion state lives on the edited object, not the panel: Object::editor_set_section_unfold(section, unfolded) / editor_is_section_unfolded(section) (core/object/object.h:825), keyed by the section's path string. This is the load-bearing trick that makes the full-rebuild architecture survivable — and it also means fold state is serialized with the scene rather than being view state.
Cycles
No visited set, and no depth cap. A self-referencing Resource can be unfolded as deep as the reader keeps clicking; each level is a fresh nested EditorInspector. The only depth-aware code is cosmetic: the sub-inspector background colour level saturates at 16 (editor_inspector.cpp:936). The lazy materialization is what makes this survivable — the recursion is driven by clicks, so it terminates when the reader stops.
Editing & mutation
Dispatch — plugins first (last registered wins,
can_handle(object), thenparse_propertymay claim the row), otherwise the built-inEditorInspectorDefaultPluginmaps(type, hint, hint_string)to a concreteEditorPropertysubclass.Mutation — a row calls
emit_changed(property, value, field, changing)(editor_inspector.cpp:305), which lands inEditorInspector::_edit_set(editor_inspector.cpp:5694). That method creates an undo/redo action rather than writing the object directly:cppundo_redo->create_action(vformat(TTR("Set %s"), p_name), UndoRedo::MERGE_ENDS, …); undo_redo->add_do_property(object, p_name, p_value); undo_redo->add_undo_property(object, p_name, value);MERGE_ENDSis the drag story: successive sets to the same property collapse into one undo entry. Objects that opt out (_dont_undo_redo), multi-node edits and remote debugger objects bypass the transaction and set directly.Commit semantics — the
changingflag is the transient/committed distinction, and its documentation states the rule:The "changing" variable must be true for properties that trigger events as typing occurs, like "text_changed" signal.
While
changing > 0,_edit_request_changedrops incoming refresh requests (editor_inspector.cpp:5683) — i.e. a live edit still writes through, but it suppresses the rebuild that would otherwise destroy the widget the reader is typing into. The distinction is not "don't commit yet"; it is "don't rebuild yet".Change notification — external changes mark
pendingproperty names orupdate_tree_pending, applied on the next process tick. There is no observer per row; the panel re-reads.Validation — none in the inspector.
Object::seteither takes the value or does not; the row shows what the object reports afterwards.
Type coverage
- Collections —
EditorPropertyArray/EditorPropertyDictionaryprovide add, remove, drag-reorder and a resize field, plus pagination:page_lengthcomes frominterface/inspector/max_array_dictionary_items_per_page(editor_properties_array_dict.cpp:1019) and rows are addressed asindex % page_lengthwithin the current page. Element identity is positional; reordering across a page boundary flips the page (editor_properties_array_dict.cpp:960). - Polymorphic values —
EditorResourcePickeris the type picker: it offers the concreteResourcesubclasses allowed by the property'shint_string, and choosing one replaces the value, after which the row's subtree is whatever the new object's property list says. There is no attempt to carry state across the change. - Optional / nullable — a null
Resourcerenders as an empty picker;checkable/checkedrows (mostly theme overrides) are the explicit "unset vs set" case, withautoclearmarking a property checked as soon as it is edited (editor_inspector.cpp:5695). - Opaque types — a property whose type has no editor simply gets no row;
PROPERTY_USAGE_EDITORgates visibility, and the walkcontinues on anything without it.
Presentation & control
- Grouping / ordering — declaration order of the property list, with category/group/subgroup pseudo-entries and
/-path nesting, as above. Favourites can hoist rows into a pinned box. - Conditional visibility — usage flags per rebuild (
_validate_propertyin script), plus_is_property_disabled_by_feature_profileand a read-only propagation from_is_read_only. - Multi-object editing —
MultiNodeEdit(multi_node_edit.cpp) is anObjectfaçade over N nodes. Its_get_property_listintersects the nodes' lists, keeping only entries whosename,type,class_name,hintandhint_stringall agree and that appear in every node (nc == E->uses,multi_node_edit.cpp:221). Its_getreturns the first node's value (multi_node_edit.cpp:144) — Godot shows no mixed-value indication at all, which is the sharpest divergence from WinForms and Unreal on this axis. - Search / filter — a filter box re-runs
update_tree, and while the filter is non-empty folding is disabled entirely (use_folding = false,editor_inspector.cpp:4464), so matches are always visible. Expansion state is untouched because it lives on the object. - Escape hatches —
EditorInspectorPluginat four granularities (whole object, category, group, single property) plusadd_custom_controlfor arbitrary widgets. - Virtualization — none. Every visible row is a real widget; large collections are handled by paging rather than recycling.
Strengths
- Plugin surface is genuinely open: any editor plugin can replace any row, or add rows the object never declared.
- Fold state on the object makes the full-rebuild model workable and gives "unfolded" free persistence.
- Transactional mutation with
MERGE_ENDSgives correct undo for drags without a separate transient-edit concept. - Lazy sub-inspectors keep deep resource graphs cheap until opened.
Weaknesses
- A refresh destroys and rebuilds the widget tree; selection and focus are restored by hand and caret position is knowingly lost (
editor_inspector.cpp:4396). - Hierarchy is recovered from a flat list by string-prefix matching, so grouping is a naming convention.
- Row addressing is by string path; nothing prevents two plugins from claiming the same path.
- No mixed-value representation in multi-object editing.
- No virtualization: a thousand-row object is a thousand
Controls.
Key design decisions and trade-offs
| Decision | Rationale | Trade-off |
|---|---|---|
| The widget tree is the tree | No model to keep in sync; plugins manipulate widgets directly | Any structural change is a full rebuild; in-progress state must be saved by hand |
Metadata as a flat List<PropertyInfo> stream | One reflection call serves every object kind, scripts included | Grouping is encoded as pseudo-rows and name prefixes rather than structure |
| Fold state stored on the edited object | Survives the rebuild for free, persists with the scene | View state is now object state; two panels over one object share folds |
Mutation via EditorUndoRedoManager | Uniform undo, merging for drags | Every set allocates an action; objects that must bypass it need special cases |
changing counter instead of transient edits | Live typing does not fight the rebuild | The value is written on every keystroke; there is no rollback point |
| Pagination instead of virtualization | Bounded widget count with no recycling machinery | Reordering and selection interact with pages; "the list" is never fully on screen |
Sources
All line numbers are at 944a3c6c.
editor/inspector/editor_inspector.h,editor/inspector/editor_inspector.cpp—EditorProperty,EditorInspectorSection,update_tree,_edit_set,_property_changed, plugin dispatcheditor/inspector/editor_properties.cpp—EditorPropertyResourceand the nested inspectoreditor/inspector/editor_properties_array_dict.cpp— array/dictionary editing and pagingeditor/inspector/multi_node_edit.cpp— the multi-selection façadecore/object/object.h—editor_set_section_unfold/editor_is_section_unfolded