Files
Oxide/docs/editor-extensions.md
T
Homer Simpson 9eead719b0 Import Oxide engine (Stages 0–10) under MIT license
Full project snapshot migrated to new Gitea remote without history:
engine, editor, physics, script, examples, tests, docs, and assets.
Relicensed from GPLv3 to MIT and updated repo URLs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 20:41:02 +02:00

5.9 KiB

Editor Extension API

oxide_editor::extension is the editor-side companion to the engine's Module trait. It is the mechanism through which a module contributes the UI it needs the editor to host on its behalf — menu items, dockable panels, viewport tools, component inspectors, and Preferences pages — without editing the editor's source.

This is the extension surface for both first-party modules (Stage-7 input binding pages, Stage-9 physics inspectors, Stage-17 ray-traced-audio panels…) and any third-party or AI-authored module.

Why a separate trait

The engine doesn't depend on egui — putting the editor hook on oxide_engine::app::Module would pull egui into the engine. Instead a module that wants to participate in the editor implements two traits on the same struct:

struct AudioPreviewModule;

impl oxide_engine::app::Module for AudioPreviewModule {
    fn name(&self) -> &'static str { "audio_preview" }
    fn build(&self, app: &mut oxide_engine::app::App) {
        // … register systems, types, asset loaders
    }
}

impl oxide_editor::extension::EditorModule for AudioPreviewModule {
    fn name(&self) -> &'static str { "audio_preview" }
    fn build_editor(&self, ext: &mut oxide_editor::extension::EditorExtensions) {
        // … register menu items, panels, inspectors, settings pages
    }
}

The shared name is how enable/disable in Preferences stays consistent across the two halves: toggling "audio_preview" hides the editor contributions and disables the engine systems together.

What a module can contribute

Kind Helper Stage-6 criterion
Menu items (File/New, Help/About, …) add_menu_item ✔ required
Dockable panels add_panel ✔ required
Viewport tools (gizmos, brushes) add_viewport_tool future stages
Component inspectors (by reflection name) add_inspector future stages
Settings pages (by section name) add_settings_page ✔ required
use oxide_editor::extension::{DockLocation, EditorExtensions, EditorModule};

struct DemoModule;
impl EditorModule for DemoModule {
    fn name(&self) -> &'static str { "demo" }
    fn build_editor(&self, ext: &mut EditorExtensions) {
        ext.add_menu_item("File/Demo…", || { /* open the demo dialog */ });
        ext.add_panel("Demo Panel", DockLocation::Right, |ui| {
            ui.label("hello from a module-owned panel");
        });
        ext.add_inspector("DemoComponent", |ui| {
            ui.label("custom editor for DemoComponent");
        });
        ext.add_settings_page("demo", "Demo", |ui| {
            ui.label("module preferences here");
        });
    }
}

The render closures take only &mut egui::Ui in Piece 5 (registration). Piece 6 — the docking shell — refines the signatures to pass through the editor's runtime context (scene, selection, asset server, settings). Modules that need shared state today can capture it through interior mutability (Rc<RefCell<...>>).

How the shell consumes the registry

# use oxide_editor::extension::{EditorExtensions, EditorModule, DockLocation};
# struct M; impl EditorModule for M {
#     fn name(&self) -> &'static str { "m" }
#     fn build_editor(&self, ext: &mut EditorExtensions) {
#         ext.add_menu_item("File/Open", || {});
#         ext.add_panel("Inspector", DockLocation::Right, |_| {});
#     }
# }
let mut ext = EditorExtensions::new();
ext.add_module(M);

// What the shell will do in piece 6:
for item in ext.iter_menu_items() {
    let _ = &item.path;        // build the menu tree
}
for panel in ext.iter_panels() {
    let _ = (&panel.name, panel.default_dock);  // place in dock layout
}

Lookups by name are also provided (has_inspector_for("Transform"), has_settings_page_for("audio")) so the Inspector and Preferences windows can ask "is there a custom editor for this thing?" before rendering a fallback.

Attribution and lifecycle

Every contribution remembers its source module. That gives three lifecycle operations the engine App already has and the editor needs to mirror:

Operation Effect
add_module Runs build_editor, attributes every contribution to the module, marks enabled. Re-adding replaces the old registration cleanly.
set_module_enabled(name, false) Contributions stay registered but vanish from every iter_* / has_* lookup — toggling Preferences is reversible without rebuilding state.
remove_module(name) Drops every contribution attributed to the module in one shot.

Adding contributions outside a module's build_editor panics: every entry must be attributable to some module, otherwise removal would leave orphans.

Inspector / settings-page resolution

The Inspector panel renders custom editors for components whose type has a registered inspector — keyed by the same name the component is registered under in the reflection registry. Settings pages plug into the Preferences framework by matching their section_name to the section the module registered. Both lookups respect the enabled flag, so a disabled module's inspector / page disappears even if the underlying section or type is still registered.

Testing strategy

The whole API is purely about registration, so it's directly unit-testable without bringing up egui — tests construct an EditorExtensions, add a demo module, and assert via the lookup helpers. The actual rendering of the contributed UI is exercised by the Piece-6 docking shell with a maintainer manual pass; the Stage-6 criterion ("a trivial test module adds a menu item, a panel, and a settings page through the API with no editor-core edits") is covered by the integration test in tests/src/lib.rs::stage6.