Editor · guide
Writing an editor plugin
What a plugin can contribute to the editor, how it registers, and how everything it added is taken back out when it unloads.
Edit this page on GitHubDocuments
- PluginHost
- DeclaredMenus
- EditorSubmenuAttribute
- BlockoutModule
- IBlockout
- BlockoutVerbs
- TerrainModule
- DiagnosticsModule
- AssetEditorsModule
- IActiveScene
- IPlayerDelivery
- ITerrainScene
- EditorRegistry
- IEditorRegistry
- NewAssetKind
- CustomInspector
- SceneTool
- TypeIcon
- AssetIcon
- EditorArt
- ScriptsStrings
- AssetEditorStrings
- AuthoringAssembly
- AuthoringKind
- IEditorPlugin
- PluginContext
- PluginServices
- IEditorGraphics
- IEditorImage
- ImporterContributions
- ImporterRegistry
- ImporterAttribute
- IContributionScanner
- CustomInspectorAttribute
- CustomDrawerAttribute
- EditorToolAttribute
- CreateAssetMenuAttribute
- OverlayAttribute
- DrawGizmoAttribute
- SceneOverlay
- ComponentGizmo
- GizmoDraw
- GizmoPlacement
- OverlayCorner
What it is
A plugin is a folder with a manifest and an assembly, dropped where the editor looks. One public type
implements IEditorPlugin; its Activate is handed a PluginContext and adds whatever the plugin
contributes.
There are two places a contribution can go, and which one depends on whether the thing already has a registry of its own.
IEditorRegistry holds the contributions that had no owner: an entry in Create ▸ (NewAssetKind),
a hand-written inspector for a type (CustomInspector), a tool in the scene pane (SceneTool).
Add hands back the removal, and PluginContext.Owns takes it.
The host's own registries hold everything that already had a home: drawers in DrawerRegistry,
commands, panels, layouts and modes in EditorShell. PluginContext.With registers with one and
records how to undo it.
What it is for
⚠ The audit that produced this found that every built-in feature was a project reference, so the
plugin API had never had to be sufficient and was not: a plugin could add a command, a panel, a mode
and a keybinding, and could not add an inspector, a drawer, a Create-menu entry, an importer, a
scene-view tool, a gizmo, a settings page or a preview. The registry is what closes that, and the
measure of it is a plugin built outside this repository doing four of those things at once — which is
what OutOfTreePluginTests is.
⚠ A contribution kind is a record in the assembly that owns it, and nothing in the plugin contract
changes when one is added. A method per kind on PluginContext would have put the whole kind list
in the contract assembly, which would mean it referencing every feature assembly that owns one — the
same shape of problem as an application that hard-references its own features, one layer down.
You do not want a plugin for something that is one project's own. A project's Editor/
scripts are a lighter path for that — no manifest, no build, no restart — and a
plugin is what you write when the thing is shared between projects or shipped to somebody else.
Using it
There is a template for the starting point, so the manifest, the project file and the one type below do not have to be typed from memory:
dotnet new vixen-plugin -n Kestrel # or: vixen new plugin -n KestrelWhat it writes is a plugin.yaml, a class library with EnableDynamicLoading set, and one
IEditorPlugin that adds a command, a menu entry and a panel — all three through PluginContext,
which is the habit the rest of this page is about.
Activate runs on the frame thread, after everything the plugin declared a dependency on. Throwing is
how a plugin refuses a host it cannot work with: what was registered before the throw is rolled back,
the assembly is unloaded, and the failure is reported against the plugin by name.
public sealed class SamplePlugin : IEditorPlugin { public void Activate(PluginContext context) { var registry = context.Services.Require<IEditorRegistry>(); context.Owns( registry.Add(new NewAssetKind("sample.create-widget", "Widget", ".widget", "New Widget")) ); context.Owns(registry.Add(new CustomInspector(typeof(Widget), DrawWidget))); context.Owns(registry.Add(new SceneTool("sample.paint", "Paint", new PaintTool()))); var drawer = new WidgetDrawer(); context.With<DrawerRegistry>( drawers => drawers.ForType<Widget>(drawer), drawers => drawers.Remove(drawer) ); }}⚠ Everything a plugin leaves behind keeps its assembly loaded, and the runtime's answer to "this
context cannot be collected" is silence. A registration with no matching removal is a leak with no
symptom, which is why every path above pairs the two in one statement rather than trusting
Deactivate to remember.
⚠ Services.Require rather than a static. A plugin reaching for DrawerRegistry.Default writes
to a process global whatever the host intended; asking for the published one means a host running two
editors, or a test running two plugins, gets two answers instead of one shared one. Require fails
with a sentence naming what was missing, caught by the loader and reported as a diagnostic — rather
than as a null reference from inside the plugin's own Activate.
Drawing, and claiming a file extension
Two extension points a plugin could not reach until doc 48's texture graph tried, both found by the first plugin that draws.
A device comes through IEditorGraphics, not as an IGraphicsDevice of its own. It is a live
view: Device is null in a headless host, before the window comes up, and after the device is
released, so a plugin asks each time rather than reading it once at activation.
public void Activate(PluginContext context) { if (context.Services.TryGet<IEditorGraphics>(out var graphics)) { // The evaluator holds the device: a pipeline cache is why the contract lends one rather // than lending a call. preview = new MyPreview(graphics); context.OnUnload(preview.Dispose); }}⚠ The device is the host's. Disposing it, or calling BeginFrame, EndFrame or
CreateSwapChain on it, takes the editor down; nothing in the contract can prevent that, which is
why the loan is what a plugin asks for by name. ⚠ And GPU work belongs outside the host's frame —
a command handler, a panel build or OnUpdate, all of which run from the application's update rather
than from inside the frame the editor is recording.
⚠ Upload takes pixels, not a texture view, and the narrowing is deliberate: a plugin's own
image is created for what it dispatches into, and a view registered straight from a storage image is
missing Sampled and is in the wrong layout — which MoltenVK forgives and a discrete card does not.
The host owns the staging buffer, the copy and the two barriers, and does them once.
using var bake = evaluator.Evaluate(plan);var picture = bake.Read(0);// The removal comes back with the number, so `Owns` is the whole of the bookkeeping — and an image// left behind is a texture and a descriptor set the editor holds for the rest of the session.var image = graphics.Upload(picture.Width, picture.Height, picture.Pixels);view.Image = image?.Image ?? 0;A file extension comes through IDocumentHost, and its view through IWorkspace — two
registrations since #1294, each of whose Add hands back the removal the way IEditorRegistry.Add
does. Register both or neither: a kind with no view opens a document nothing can show. See
documents and views.
if (context.Services.TryGet<IDocumentHost>(out var documents) && context.Services.TryGet<IWorkspace>(out var workspace)) { context.Owns(documents.Add(new MyDocumentKind())); context.Owns(workspace.Add(new MyDocumentView()));}⚠ TryGet, not Require, for both. A module that demanded either would refuse to start in every
host that is not the editor — which includes every test of everything else it does — and the honest
answer to "this host has none" is to carry on and say so on screen.
Declaring instead of registering
Seven contributions can be an attribute rather than a call, and they mean the same thing in a plugin
as in a project's Editor/ script:
[EditorMenu("Tools/Bake", Priority = 200)]public static void Bake() { … }[CustomInspector(typeof(Widget))]public static void DrawWidget(UiElement body, EditTarget target) { … }[CustomDrawer(typeof(Curve))]public sealed class CurveDrawer : IPropertyDrawer { … }[EditorTool("Sculpt", typeof(TerrainComponent))]public sealed class SculptTool : IViewportInput { … }[CreateAssetMenu("Terrain Layer", ".vxlayer")]public static string NewLayer() => "layers: []\n";[Overlay("Snapping", Corner = OverlayCorner.BottomLeft)]public static void BuildOverlay(UiElement host, SceneViewport pane) { … }[DrawGizmo(typeof(SpawnPoint), SelectedOnly = true)]public static void DrawSpawn(GizmoDraw draw, object component, GizmoPlacement placement, bool selected) { … }An eighth, [Importer(".ext")], is on the importer class itself and is what claims a file extension
— see the importer section, which is the same in
both tiers.
⚠ They are read by a scan of your assembly, which the loader already does to find your entry
point — so declaring costs nothing over registering, and everything a declaration adds is in the same
scope as everything Activate adds. An unload takes both out.
⚠ Declarations are read after Activate, so a hand-written registration for the same type wins
over an attribute. If you want the attribute to win, do not also register it.
[EditorMenu] has two more shapes than the verb above. On a public static string field — a const
command id — it is a line for a command something else registers, and every part of the path is a
menu: that is how a mode, whose commands are its own because their enablement and their keys are,
still declares where they appear. On a static IEnumerable<string> () it is a run of such lines, read
once. Separator = true puts a separator above a line, and Title = nameof(MyStrings.Thing) names the
line's title in a declaration class, so a localised verb can be declared at all — resolved in the class
Strings = typeof(…) names, or else in the one class of your assembly whose name ends in Strings
that declares it. A title that resolves to nothing refuses the plugin with a diagnostic rather than
showing the member's name. [CreateAssetMenu] and [Overlay] take the same Title and Strings,
by the same rule; their first argument is then only what a derived id is made from, and the line or
header follows a language switch while the editor runs.
[EditorSubmenu("Scene/Geometry", Title = …, After = "editor.menu.elements")], on any class, declares
a submenu: its translated title and where among its siblings it goes. Without one, a path makes the
submenu it names, titled with the path's own text and appended. Both readers hand what they find to
DeclaredMenus, which decides placement — lines in Priority order, a path that finds before it
creates, so two declarations under Tools/My Thing share one submenu — and takes all of it back out on
unload.
In the editor's own tree, a generator instead of the scan
An in-tree module is not scanned: it is compiled in and activated by name. It references
Vixen.Editor.Plugin.Generator as an analyzer, which writes a Declared.Register(PluginContext) for
its assembly at compile time — the scan's code with the reflection taken out, ADR-002 intact — and the
module's Activate calls it, last:
<ProjectReference Include="..\Vixen.Editor.Plugin.Generator\Vixen.Editor.Plugin.Generator.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />⚠ One reader per assembly, never both. A disk plugin is scanned after its Activate; one that also
called a generated Register would register everything twice, and the second command would throw. So
the generator does not ship in Vixen.Editor.Plugin's package, and a declaration the scan would pass
over silently — a private method, a member of a type the generated class cannot name — is a compile
error (VXP0001–VXP0005) rather than a line that is quietly not there. DeclaredParityTests
compiles one fixture through the generator, registers it both ways and compares everything the two
hosts end up holding.
Examples
A Create ▸ entry that writes a starter document. An empty file is right for a kind whose editor opens a zero-byte file as a new document; a kind read by an importer wants text, because an empty one deserialises to an asset that reports itself incomplete.
registry.Add( new NewAssetKind( "sample.create-widget", "Widget", ".widget", "New Widget", Contents: "size: 1", Opens: false ));A hand-written inspector, for a type the descriptor generator never saw. A plugin compiled outside
the solution has no analyzer, so its types have no generated rows — which is the case that most needs
this. It is handed an EditTarget, so it is as multi-object-aware and as undoable as a generated one
without writing any of that; see the editing pipeline.
static void DrawWidget(UiElement body, EditTarget target) { if (target.Find("Size") is { } size) { var slider = body.Add<Slider>(); using (size.Refreshing()) { slider.Value = size.Read().Or(0f); } slider.ValueChanged += (_, value) => size.Write(value); }}A scene-view tool. It gets the pane's input before the active mode does, because it is the more specific claim: a tool was chosen for this selection where a mode was chosen for the session.
public sealed class PaintTool : IViewportInput { public bool Pointer(IViewportPane pane, PointerEvent args) { if (args.Action != PointerAction.Pressed) { return false; } // …paint… return true; } public bool Key(IViewportPane pane, KeyEvent args) => false;}An icon, so the asset type you added is not visibly second-class. Two contributions, because
there are two things to name: a CLR type — a component, a behaviour, the class an asset deserialises
to — and a kind of file. An AssetIcon's key is an importer tag, or an extension with its dot; the
tag is tried first, because two plugins whose asset types share an extension can only be told apart
by which importer claimed the file.
registry.Add( new AssetIcon( ".widget", new IconArt( new IconPath(Body, IconPaint.Of(new Color4(0.44f, 0.72f, 0.94f, 1f))), new IconPath(Dot, IconPaint.Named("--accent")) ) ));registry.Add(new TypeIcon(typeof(WidgetComponent), IconArt.Of(EditorIcons.Cube)));⚠ Pick the paint by whether the colour should follow a retheme, not by how many colours you
want. IconPaint.Foreground is the inherited color and is what the editor's own chrome uses;
IconPaint.Named("--accent") is a custom property the cascade supplies, so a dark theme can override
it; IconPaint.Of(colour) is written into the icon and will not move. A set that offers only the
last looks correct in the theme it was drawn for and wrong in the other one.
⚠ There is no [EditorIcon("thing.svg")], and that is deliberate. Icon takes a PathBuilder
rather than parsing a path string, because an icon set is compiled content — turning "M12 2L2 22h20z"
into segments belongs to an asset pipeline rather than to every application at start-up. Declaring
the icon is a registration, which is a line in the same Activate everything else here is in.
An asset importer. Doc 36 § F8: the registry an import runs against is rebuilt per run, so what a plugin contributes to is a set that outlives one.
[Importer(".widget")]public sealed class WidgetImporter : AssetImporter<WidgetImportSettings> { public override int Version => 1; protected override ValueTask<ImportResult> ImportAsync( ImportContext context, WidgetImportSettings settings, CancellationToken cancellationToken ) => …;}var importers = context.Services.Require<ImporterContributions>();context.Owns(importers.Add(new WidgetImporter()));⚠ Two importers claiming one extension is an error naming both, raised when the set is assembled rather than when you contribute — so a plugin does not fail to load because of an importer that has already been withdrawn. Contributions are folded in after the built-ins and before the fallback.
⚠ A contributed importer reaches an out-of-process compiler worker too, which this page used to deny. The asset compiler starts workers for crash isolation and each builds its own registry; the pool names the files your importers came out of on each worker's command line, and the worker loads them. The one importer that cannot cross is one whose assembly has no file. See contributing an importer.
⚠ This is one thing a project script cannot do. An importer is named by its settings type's
[DataContract] alias, which a source generator writes — and editor scripts are
compiled without generators. Ship an importer as a plugin.
Components in an assembly of your own. If your plugin's components live in a runtime assembly the
editor never calls into, its [ModuleInitializer] may not have run by the time the Add ▸ menu asks
what exists — so say which assembly declares them:
registry.Add(new AuthoringAssembly(typeof(WidgetComponent)));A built-in feature is the same thing, activated by name
The editor's own features register the same way, through PluginHost.Activate(id, name, module) —
no assembly loading and no AssemblyLoadContext, because a compiled-in module is already in the
default one, but the same PluginContext, the same registration scope, the same rollback when
Activate throws and the same unload.
BlockoutModule is the worked example: it registers an editor mode and two panels, declares six
submenus of the Scene menu and every line in them with [EditorSubmenu] and [EditorMenu] — which
the in-tree generator turns into the Declared.Register its Activate calls last — and asks for the
four things it needs of the host — the shared mesh-editing state, the work plane, a mesh baker and a
mesh source — through Services.Require.
It also publishes something: its verbs. IBlockout is doc 24's tables as methods — Subtract(),
Extrude(), CubeGrid() — implemented by BlockoutVerbs over the mode's dials, and the mode's commands
are names for them. context.Publish(mode.Verbs) puts the same instance where another plugin's
Require<IBlockout>() finds it, and withdraws it on unload:
var blockout = context.Services.Require<IBlockout>();blockout.Subtract(); // undoably, exactly as Scene ▸ Boolean ▸ Subtract does⚠ Publish, not Services.Add. The bag has no removal, so a service added straight into it
outlives its module, and since a second publish of one type throws, the module's own reload then
fails.
⚠ It exists because an API whose own authors bypass it is a guess. A feature wired into the application through internals proves nothing about whether a third party could have written it; one that goes through this door proves it every build, because its assembly cannot see the application at all.
plugins.Activate(BlockoutModule.ModuleId, BlockoutModule.ModuleName, new BlockoutModule());TerrainModule is the larger one — two modes, five panels, and the session that binds them to the
scene in front of them. It needed two extension points that a contribution-shaped API does not have,
and every feature with a mode will want both:
* PluginContext.OnUpdate — a brush follows the entity selection, and nothing raises an event
about that. Once a frame, on the frame thread, and not the place for work.
* EditorDocument.Saved — a scene names a heightfield and a foliage file beside itself. Saving
one without the others leaves a project whose ground exists only in a process that has exited. It
throws through, so a sidecar that could not be written is a failed save rather than a silent half
of one.
⚠ A module can be a third assembly, and sometimes has to be. DiagnosticsModule joins the
profiler and the frame debugger to a project, a scene and a graphics device — and it is its own
assembly because neither of those two has ever heard of any of the three, which is what lets both be
tested against a bare UiDocument. Putting the joining code inside one would have bought the
registration and spent the testability.
It also shows the two shapes a host dependency takes. IActiveScene is required, because a panel
that counts entities has to count the scene being shown — an editor inspecting a prefab must count
the prefab. IPlayerDelivery is optional, fetched with TryGet: a host that cannot build a player
greys Deploy with a sentence rather than hiding the panel. ⚠ It is the build's own service and not a
deploy contract: what Deploy means per kind of device is the diagnostics module's vocabulary, so the
module maps its device kinds onto Refusal() and Build(launch: true) and the host names no device
type at all (#1295).
⚠ A module contributes what a panel needs of it rather than being fetched. The terrain module
puts an ITerrainScene — what ground to draw and where — into IEditorRegistry, and the viewport's
presenter reads it from there. Neither end names the other, and it goes away when the module does, so
a pane cannot be left drawing terrain out of an assembly that has unloaded.
⚠ Put verbs in the menu the thing they act on already has, with FindMenu and AddSubmenu, and
say where using MenuGroup.IndexOfSubmenu rather than a number. A module that could only append
would reorder somebody's menu the day it stopped being compiled in.
⚠ A module's words are its own, and it hands them over as it activates. A panel title, a mode's
name, a command category, a submenu — every StringId a feature shows is declared in that feature's
own declaration class (TerrainStrings, BlockoutStrings, WaterStrings, TexturingStrings,
DiagnosticsStrings, ScriptsStrings, AssetEditorStrings) and contributed with one line:
context.AddStrings(TerrainStrings.All);The shell cannot name those classes — EditorStrings would have to depend on every feature — so a
word the shell declared for a feature was a copy the feature could not own: it reached a
translator's template whether or not the feature was loaded, and a feature shipped out of tree would
have had no way to bring its own. AddStrings records the withdrawal too, so a module's words leave
the template when the module does, and its All array stops pinning its load context.
See also
* The editor shell — commands, panels, menus and the keymap a plugin also reaches
* The editing pipeline — what a contributed inspector or tool writes through
* Inspectors in markup — writing the inspector above as a .vxml instead
* Editor scripts — the same contributions from a loose .cs in a project
* Editor modes — the coarser thing a SceneTool sits inside