Gum Tool Plugin System Reference
SkillDev toolsGum tool plugin system, including visualization plugins (EditorTabPlugin.Core/EditorTabPlugin_XNA, TextureCoordinatePlugin.Core). Triggers: plugin registration, PluginBase, PriorityPlugin, PluginManager, plugin events, finding which internal plugin owns a feature.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Gum Tool Plugin System Reference skill
What this skill tells your AI
The instructions your AI receives, as published by vchelaru/gum in .claude/skills/gum-tool-plugins/SKILL.md and read by ahel’s review.
Architecture
The plugin system uses MEF (Managed Extensibility Framework) for discovery. All plugins are marked with [Export(typeof(PluginBase))] and auto-discovered at startup.
Class Hierarchy
IPlugin— minimal interface:StartUp(),ShutDown(PluginShutDownReason),FriendlyName,UniqueId,VersionPluginBase(Gum.Presentation, framework-neutral) — concrete base with all event declarations and pre-injected helper services (_guiCommands,_fileCommands,_tabManager,_dialogService, plus theMenumodel). Menus:AddMenuEntry(Action click, params string[] path)returns aMenuItemModelwhoseHeader/IsEnabled/IsCheckeddrive the rendered item under both heads. Tabs:CreateTab(object content, …)takes a control the head can show or a ViewModel.IPriorityPlugin— marker interface for plugins that should receive events before others (checked byPluginManager, no framework type involved).WpfPluginBase(WPF tool only) — adds theDeleteOptionsWindowevent pair and an[Obsolete]AddMenuItemshim over the model for external WPF plugins (ADR-0018). Nothing in the repo should call the shim.PriorityPlugin(WPF tool) —WpfPluginBase+IPriorityPlugin; provides defaultShutDown()returningfalseand auto-generatesFriendlyName. Built-in plugins shared by both heads deriveCorePriorityPlugin(Gum.Presentation) instead, which has the same defaults without the WPF base; the Avalonia head's own plugins implementIPriorityPluginonPluginBasedirectly.
Origin vs. Priority
Origin (where the plugin's code lives) is independent of priority (whether it receives events early):
- First-party plugins live in
Tools/Gum.Presentation/Plugins/InternalPlugins/when both heads share them, inGum/Plugins/InternalPlugins/when only the WPF head has them (compiled into Gum.exe), or inTool/Gum.Avalonia/Plugins/when only the Avalonia head has them. Each head lists its own assembly and Gum.Presentation inIPluginHostConfiguration.InternalPluginAssemblies. - External plugins are separate .dlls loaded from
<app base directory>/Plugins/<PluginName>/at runtime (PluginManager.PluginFolder, OS-neutral). They usually inherit fromPluginBasedirectly, but may implementIPriorityPluginif they need early event dispatch (e.g.EditorTabPlugin_XNA, which ships as an external DLL but needs priority for wireframe events). The Avalonia head refuses an external assembly that references WPF/WinForms (AvaloniaPluginHostConfiguration.CanHostExternalAssembly) and reports it asPluginFileOutcome.NotHostable.
The type check is IPriorityPlugin is used at runtime — priority plugins receive events before non-priority ones, regardless of origin.
Key Files
| File | Purpose |
|---|---|
Tools/Gum.Presentation/Plugins/BaseClasses/PluginBase.cs | All event declarations + helper services + AddMenuEntry |
Tools/Gum.Presentation/Plugins/IPluginHostConfiguration.cs | What a head supplies to the host: built-in assemblies, AddHeadExports, CanHostExternalAssembly, cursor state; also IPriorityPlugin, IDeleteOptionsDialogPlugin |
Tools/Gum.Presentation/Plugins/PluginManager.cs | Loads plugins via MEF (AddCoreExports is the bridged core-service list), routes all events via Call* methods |
Tools/Gum.Presentation/Plugins/PluginContainer.cs | Wraps each plugin; tracks enabled state and failure info |
Gum/Plugins/WpfPluginHostConfiguration.cs, Tool/Gum.Avalonia/Services/AvaloniaPluginHostConfiguration.cs | The two heads' host configurations |
Gum/Plugins/BaseClasses/WpfPluginBase.cs, PriorityPlugin.cs | WPF-only bases (delete dialog events, obsolete menu shim) |
Gum/Plugins/InternalPlugins/ | WPF head built-in plugin subfolders |
Tools/Gum.Presentation/Plugins/InternalPlugins/ | Built-in plugins shared by both heads (CorePriorityPlugins) |
Tool/Gum.Avalonia/Plugins/ | Avalonia head plugins (ShellTitlePlugin, and the head subclasses of the editor tab, texture-coordinate and State Animation plugins) |
Tools/Gum.Presentation/Menus/ | MenuModel, MenuItemModel, StandardMenuModelBuilder; rendered by MenuStripManager (WPF) and AvaloniaMenuBuilder |
Plugin Lifecycle
StartUp() is called once on load — subscribe to events and add menu entries here (the menu model is populated before plugins load). ShutDown(PluginShutDownReason) is called on unload. Service dependencies arrive through [ImportingConstructor] parameters or the inherited [Import] properties; a few legacy plugins still call Locator.GetRequiredService<T>() in their constructor (drain on touch). If any plugin handler throws, PluginContainer disables that plugin for the rest of the session.
Internal Plugin Map
Each internal plugin has a Main[FeatureName]Plugin.cs entry point in [FeatureName]/ under Tools/Gum.Presentation/Plugins/InternalPlugins/ (shared) or Gum/Plugins/InternalPlugins/ (WPF-only).
| Feature | Plugin Folder | Where |
|---|---|---|
| Element tree view | TreeView/ | shared (Tool/TreeViewPlugin.Core, MainTreeViewPlugin); each head supplies an IElementTreeView |
| Variables/Properties tab | VariableGrid/ | shared (Gum.Presentation's VariableGridPluginBase); each head exports a thin MainVariableGridPlugin |
| State panel | StatePlugin/ | shared (StateTreePluginBase); each head builds its tree view |
| Behaviors panel | Behaviors/ | shared |
| Output panel | Output/ | shared |
| Alignment controls | AlignmentButtons/ | shared |
| Menu strip | MenuStripPlugin/ | shared model, WPF renderer |
| Undo/History | Undos/ | shared |
| Delete dialog | Delete/ | shared |
| Errors, Hotkeys, File Watch, Load Recent, Project Properties | per feature | shared |
Common Events
Most events are defined on PluginBase — subscribe in StartUp(). The full list is in PluginBase.cs; WPF-shell events such as the DeleteOptionsWindow pair live on WpfPluginBase instead. Most-used categories:
- Selection:
ElementSelected,InstanceSelected,ReactToStateSaveSelected,BehaviorSelected,TreeNodeSelected - Variable changes:
VariableSet,VariableSetLate - Element lifecycle:
ElementAdd,ElementDelete,ElementRename,ElementDuplicate,ElementReloaded - Instance lifecycle:
InstanceAdd,InstanceDelete,InstanceRename,InstanceReordered - Project:
ProjectLoad,BeforeProjectSave,AfterProjectSave - Wireframe:
WireframeRefreshed,BeforeRender,AfterRender,CameraChanged
Query events (plugins return values to intercept behavior): TryHandleDelete, GetSelectedIpsos, VariableExcluded, GetDeleteStateResponse, CreateGraphicalUiElement
Visualization Plugins
Visualization/rendering is handled by plugin projects, not by Gum.csproj itself. Each canvas is a framework-neutral core (net10.0) plus a thin plugin per head.
Editor tab. Tool/EditorTabPlugin.Core holds the wireframe canvas logic (WireframeCanvasCore),
editors, rulers, services, and the abstract EditorTabPluginBase, which owns all runtime/rendering
concerns: creating runtime instances for the wireframe preview, rendering, and wiring all
CustomSetPropertyOnRenderable statics in its StartUp() (SetPropertyOnRenderable,
UpdateFontFromProperties, ThrowExceptionsForMissingFiles, AddRenderableToManagers,
RemoveRenderableFromManagers, FontService, PropertyAssignmentError). The WPF head's plugin is
Tool/EditorTabPlugin_XNA (MainEditorTabPlugin, external DLL); the Avalonia head's is
Tool/Gum.Avalonia/Plugins/EditorTab/AvaloniaEditorTabPlugin. A head supplies only the canvas
control, tab layout, scroll bars, context menu and drop reader through the base class's hooks.
Texture coordinates. Tool/TextureCoordinatePlugin.Core (TextureCoordinatePluginBase,
TextureCoordinateDisplayController) over ImageRegionSelectionCore; heads in
Gum/TextureCoordinateSelectionPlugin (WPF) and Tool/Gum.Avalonia/Plugins/TextureCoordinates.
It piggybacks on the statics the editor tab sets up.
Both canvases draw through ICanvasHost (XnaAndWinforms, net10.0): WpfGraphicsDeviceControl
(XnaAndWinforms.Wpf) in the WPF head, AvaloniaGraphicsDeviceControl in the Avalonia head. Logic
added to a canvas goes in the core, never in one head's plugin.
Gum.csproj is save-class territory. It should operate purely on save classes (data model) without runtime/rendering dependencies. Runtime code that still exists in Gum.csproj (like WireframeObjectManager) is legacy being actively refactored out to plugins. Do not add new runtime/rendering code to Gum.csproj.
Non-Obvious Behaviors
Event ordering: PluginManager sorts with OrderBy(!(item is IPriorityPlugin)), so priority plugins always handle events before non-priority ones. Note: "priority" is about dispatch order, not where the plugin's code lives — an external DLL can still be a priority plugin.
Menu items are model entries, not controls: never hold a WPF MenuItem in a plugin. Keep the MenuItemModel from AddMenuEntry and set Header/IsEnabled on it; to remove and re-add an entry (Forms does this on project load) manipulate Menu.GetItem("Content").Items. The WPF renderer maps each model item to exactly one MenuItem for its lifetime and re-applies the Content layout on every change.
VariableSet vs. VariableSetLate: Two events for the same change. Use VariableSet to respond to a change; use VariableSetLate for cleanup/refresh that should run after all other plugins have responded.
Don't re-declare an injected helper: a plugin taking IDialogService in its [ImportingConstructor] must not store it in a field named _dialogService — PluginBase already declares that one and the shadow is a CS0108 build break. Same for the other pre-injected helpers listed under Class Hierarchy.
Finding which plugin owns a feature: Search StartUp() methods for the event subscription. E.g., to find what handles VariableSet, grep for VariableSet += in InternalPlugins/. The subscribing plugin is the owner.
A stale same-named DLL in Plugins/ surfaces as a TypeLoadException in an unrelated plugin (#4693): Assembly.LoadFrom returns the first copy of an assembly identity it loaded, so a leftover at the root of the folder wins over the fresh copy beside the plugin that needs it. PluginCatalogFactory.ReportMismatchedDuplicates hashes the scanned files before loading and writes an Output-tab error naming every path of a name whose copies differ (identical per-plugin copies of a shared dependency are normal and stay quiet). If a plugin fails with "does not have an implementation" for a method that exists in source, read the Output tab first, then delete the stale copy.
Composition is guarded by a headless test
AllPluginsCompositionTests (Tool/Tests/GumToolUnitTests/Plugins/) composes every WPF-head plugin through MEF exactly as PluginManager.LoadPlugins does — the automated replacement for manually launching Gum to confirm plugins load. A missing/typo'd bridge or a bad [ImportingConstructor] signature fails it as a red CompositionException. Tests/Gum.Avalonia.Tests/PluginHostTests does the same for the Avalonia head: its built-in plugins through the real PluginManager, and the neutral external plugins (ConvertToJsonPlugin, EventOutputPlugin, GumFormsPlugin, ImportFromGumxPlugin, SkiaPlugin) against AddCoreExports + the head's AddHeadExports.
When draining a plugin to [ImportingConstructor]: if the drain adds a new core service to PluginManager.AddCoreExports, mirror that type into PluginBridgedServiceTypes.All (same test folder) — it is a hand-maintained duplicate of that list and the test goes red otherwise. A service only one head has (MenuStripManager, MainPanelViewModel, ShellViewModel, …) is exported from that head's IPluginHostConfiguration.AddHeadExports, and a plugin that imports it is by definition head-specific. Reusing services already bridged needs no test change. (ServiceProviderCompositionSpikeTests resolves the same set from the real Builder.cs container, catching DI cycles / missing registrations.)
Adding a new external plugin under Gum/<PluginName>/
Three places need a matching entry per plugin:
Gum.csproj—<Compile Remove="<PluginName>\**" />plus matchingEmbeddedResource/None/Pageremoves. Without this, Gum.csproj's own default SDK glob also compiles the plugin's sources directly into Gum.exe. SinceGum.exe's executing assembly is itself inPluginManager's MEF catalog, the[Export(typeof(PluginBase))]class then composes twice as two distinctTypeobjects (one from Gum.exe, one from the plugin's own .dll) -StartUp()fires twice, and anything non-idempotent it does (e.g.AddMenuItemfor the same path) crashes.GumToolUnitTests.csproj— aProjectReferenceto the plugin's.csproj.AllPluginsCompositionTests.PluginAssemblies— an anchortypeof(...).Assemblyentry (anchor on the plugin's own entry type and make itpublic; anchoring on a type from another assembly, as the Forms plugin once did withFormsFileServiceafter it moved toGum.Presentation, silently leaves the plugin uncomposed).
Missing (2)/(3) doesn't fail the build or the test - it just means the plugin's real composition, including a case like (1), is never actually exercised by this test.
Built-in plugins shared by both heads (Gum.Presentation)
Most first-party internal plugins now live in Tools/Gum.Presentation/Plugins/InternalPlugins/<Feature>/
(and FileWatchPlugin/), derive from CorePriorityPlugin (neutral: PluginBase + IPriorityPlugin
with PriorityPlugin's defaults), and load in both heads because each head lists Gum.Presentation in
InternalPluginAssemblies. Keep their class names and namespaces when moving one: the Manage Plugins
dialog and the plugin-enablement store key on them.
A shared plugin's tab is a ViewModel. Pass the VM to AddControl/CreateTab; each head resolves it
through its own TabViewRegistry (Gum/Controls/TabViewRegistry.cs for WPF,
Tool/Gum.Avalonia/Shell/TabViewRegistry.cs for Avalonia), which also supplies an optional custom tab
header (the Errors count). The WPF head throws when a VM has no registered view;
PluginHostTests.EveryTabASharedPluginAdds_ResolvesToAnAvaloniaView fails for the Avalonia head. So a
new shared tab means one registration in each registry. View code-behind must not hold logic: move
it to the VM (with a test in Gum.Presentation.Tests) and bind.
Still WPF-only in Gum/Plugins/InternalPlugins/: the tree view and state tree (phase 60), the Variables
tab (phase 70), and the menu strip renderer. A WPF view that builds its rows from the view model's
members (Project Properties' DataUiGrid) must hide any view-only member you add to that view model.
Writing a plugin that runs under both heads
Target plain net10.0, reference Tools/Gum.Presentation/Gum.Presentation.csproj (not Gum.csproj), inherit PluginBase, use AddMenuEntry and IDialogService, and add the Microsoft.CodeAnalysis.BannedApiAnalyzers package with BannedSymbols.CrossPlatform.txt as an AdditionalFiles item so Windows-only calls fail the build. Gum/ConvertToJsonPlugin/ConvertToJsonPlugin.csproj is the template; its post-build copies the DLL into both Gum/bin/<Config>/Plugins/ and Tool/Gum.Avalonia/bin/<Config>/net10.0/Plugins/, with a $(SolutionDir) fallback so building the test project alone (CI on macOS/Linux) works. Reference the plugin from Tests/Gum.Avalonia.Tests and add its assembly to PluginHostTests.NeutralPluginAssemblies.
Dialogs in such a plugin. Put the DialogViewModel in Gum.Presentation and show it with IDialogService.Show; never build a window in the plugin. The WPF view goes in the WPF head under Gum/PluginViews/<Plugin>/ with [Dialog(typeof(TheViewModel))], and the Avalonia view is registered in Tool/Gum.Avalonia/Dialogs/DialogViewRegistry.cs (GumFormsPlugin and ImportFromGumxPlugin are the examples). Give the view model a Title: the Avalonia dialog window binds it, and the WPF view binds Dialog.DialogTitle to it. Content a plugin stages at build time (the Forms themes) must be copied into both heads' output folders.
NuGet dependencies. The plugin host only resolves other plugin assemblies; a plugin's package dependencies load from the application folder. A plugin that needs packages the heads don't already carry (SkiaPlugin's Svg.Skia, SkiaSharp.Skottie, SkiaSharp.Extended) needs them referenced by both Gum.csproj and Tool/Gum.Avalonia/Gum.Avalonia.csproj, or it composes in tests but fails when it first touches the type.
A plugin whose view differs per head. When a plugin owns a panel (a tab, not just dialogs), put its whole body in an abstract base in Gum.Presentation and let each head export a thin subclass that only builds the view: CodeOutputPluginBase (subclasses MainCodeOutputPlugin in the WPF plugin assembly and in Tool/Gum.Avalonia/Plugins/CodeOutput/) and VariableGridPluginBase are the examples. Rows a panel shows through a DataUi grid belong in Gum.Presentation as neutral members (CodeOutputSettingsMembers), not in a view's code-behind. A WPF-only hook such as the delete dialog's options goes on the WPF subclass, implementing IDeleteOptionsDialogPlugin directly rather than through WpfPluginBase.
Signals
- GitHub stars
- 620
- Forks
- 80
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
gum-tool-plugins- Source
- github.com/vchelaru/gum