Extending PluginUI
Version française : EXTENDING_FR.md
This guide shows the extension points of the library, from the simplest to the most complete.
| Need | Extension point |
|---|---|
| Change colors or fonts everywhere | Theme (Palette, Typography, Metrics) |
| Change the look of a control type | Theme.WithStyle<T>(VectorStyle) |
| Change the look of a single control | control.Style = … |
| Skin a control with images | control.Skin = new FilmStripSkin(…), SvgRotarySkin, ImageStateSkin, NineSliceSkin |
| Custom drawing for an existing control | DelegateSkin or a class implementing IControlSkin |
| New value control | derive from ValueControl |
| New control without a value | derive from Control |
| Decorative element or visualization | derive from UIElement |
| New container | derive from Panel (MeasureOverride / ArrangeOverride) |
| Floating window (menu, picker, dialog) | derive from Popup |
| Per-frame animation | ITickable + UIHost.AddTicker |
| Value source other than a plug-in parameter | implement IParameter |
1. Themes and styles
var theme = Theme.Dark
.WithAccent(Color.Parse("#FF7A1A"))
.WithStyle<Knob>(Theme.Dark.DefaultStyle with { Roundness = 1f, TrackThickness = 3f })
.WithStyle<Button>(Theme.Dark.DefaultStyle with { DrawShadow = false });
host.Theme = theme;
VectorStyle takes up the idea of iPlug2's IVStyle: colors (background, fill, accent, frame, shadow, hover), label and value fonts, whether the label and the value are shown, roundness, thicknesses, shadow, fraction of the area taken by the widget. Each control resolves its style in this order: control.Style, then the style registered in the theme for its type (or a parent type), then Theme.DefaultStyle.
2. Skins
A skin replaces the drawing of the widget; the label and the value are still drawn by the control (turn them off with Style = style.Bare() if the image already contains them).
// Vertical film strip of 128 frames (exported from KnobMan, for example).
var strip = new FilmStrip(ImageSource.FromFile("knob_128.png"), 128, Orientation.Vertical);
knob.Skin = new FilmStripSkin(strip);
// SVG knob: rotor turned from -135° to +135°, fixed background and highlight.
knob.Skin = new SvgRotarySkin(SvgImage.FromFile("rotor.svg"), SvgImage.FromFile("base.svg"), SvgImage.FromFile("glass.svg"));
// Bitmap button with states.
toggle.Skin = new ImageStateSkin(ImageSource.FromFile("off.png"), ImageSource.FromFile("on.png"))
{
Pressed = ImageSource.FromFile("pressed.png"),
};
// Free drawing for a single control.
meter.Skin = new DelegateSkin((DrawingContext dc, Control c, in SkinState s) =>
{
dc.FillRect(s.Bounds, Color.Black);
dc.FillRect(s.Bounds.TakeBottom(s.Bounds.Height * (float)s.Value), Color.Parse("#4CAF50"));
});
SkinState provides the widget area, the normalized value, the discrete state index, and the hover, pressed, enabled and focus states.
Images are decoded once by WIC (ImageSource.FromFile, FromStream, FromResource), then converted to Direct2D bitmaps on demand and cached per host. ImageSource.PixelsPerDip (2 for an @2x image) preserves the logical size.
SVG: native rendering goes through ID2D1DeviceContext5 (Windows 10 1703 and later). If the interface is missing, a placeholder frame is drawn. This is the case under Wine.
3. A new value control
ValueControl provides the binding to an IParameter, the gesture protocol (BeginGesture / UpdateGesture / EndGesture), quantization, the default value (double-click or Ctrl+click), the mouse wheel, the arrow keys, fine adjustment (Shift) and the value bubble.
/// <summary>Horizontal bar that fills with the value; drag horizontally to edit.</summary>
public sealed class BarControl : ValueControl
{
private float _startX;
private double _startValue;
protected override Size DefaultSize => new(120f, 20f);
protected override void RenderVector(DrawingContext dc, Rect widget, VectorStyle style)
{
float r = CornerRadiusFor(widget, style);
dc.FillRoundedRect(widget, r, style.Background);
dc.FillRoundedRect(widget.TakeLeft(widget.Width * (float)Value), r, style.Accent);
if (IsMouseOver) dc.FillRoundedRect(widget, r, style.Hover);
}
protected override void OnMouseDown(MouseEventArgs e)
{
base.OnMouseDown(e);
if (TryHandleReset(e) || e.Button != MouseButton.Left) return;
_startX = e.Position.X;
_startValue = Value;
IsPressed = true;
BeginGesture(); // beginEdit
e.Handled = true; // the host captures the mouse for this element
}
protected override void OnMouseMove(MouseEventArgs e)
{
base.OnMouseMove(e);
if (!IsEditing) return;
double scale = e.Shift ? FineFactor : 1.0;
UpdateGesture(_startValue + (e.Position.X - _startX) / DragSensitivity * scale); // performEdit
}
protected override void OnMouseUp(MouseEventArgs e)
{
base.OnMouseUp(e);
IsPressed = false;
EndGesture(); // endEdit
InvalidateVisual();
}
}
Rules to remember:
Boundsis in absolute host DIPs: no transform to apply for drawing or hit-testing a point.- Set
e.Handled = trueinOnMouseDown: the host then captures the mouse for this control until the button is released. - An open gesture must always be closed.
ValueControltakes care of it if the capture is lost (OnCaptureLost) or if the control leaves the tree (OnDetached). - Call
InvalidateVisual()after a change of visual state andInvalidateMeasure()after a change of natural size. - For a control bound to several parameters (XY pad, range), call
IParameter.BeginEdit / PerformEdit / EndEdityourself and close the gestures inOnCaptureLostandOnDetached. - Keep
ValueText(the value text) andGetSkinStateconsistent so that skins work.
4. A new element or container
public sealed class Ruler : UIElement
{
protected override Size MeasureOverride(Size available) => new(available.Width, 18f);
protected override void OnRender(DrawingContext dc)
{
var p = Theme.Palette;
for (float x = Bounds.X; x < Bounds.Right; x += 10f)
{
float h = (x - Bounds.X) % 50f == 0f ? 10f : 5f;
dc.DrawLine(new Point(x + 0.5f, Bounds.Bottom - h), new Point(x + 0.5f, Bounds.Bottom), p.TextDim, dc.Hairline);
}
}
}
/// <summary>Places children on a circle.</summary>
public sealed class RadialPanel : Panel
{
protected override Size MeasureOverride(Size available)
{
foreach (var c in Children) c.Measure(Size.Infinite);
return available;
}
protected override void ArrangeOverride(Rect bounds)
{
float radius = MathF.Min(bounds.Width, bounds.Height) * 0.4f;
for (int i = 0; i < Children.Count; i++)
{
var c = Children[i];
var center = PathGeometry.PointOnCircle(bounds.Center, radius, 360f * i / Children.Count);
c.Arrange(Rect.FromCenter(center, c.DesiredSize.Width, c.DesiredSize.Height));
}
}
}
5. Popups
Menus, drop-down lists, color pickers and dialogs are Popups drawn inside the editor window, above the main tree. This avoids any additional native window in the DAW. Useful properties: Anchor and Placement (Below, Above, Right, Left, AtPoint, Center; automatically flipped when there is not enough room), IsLightDismiss (closes on a click outside), IsModal (dimmed background, input blocked), CapturesKeyboard.
var popup = new Popup
{
Anchor = button.Bounds,
Placement = PopupPlacement.Below,
Background = Theme.Palette.SurfaceRaised,
CornerRadius = 6f,
Padding = 8f,
Content = new StackPanel(Orientation.Vertical, 6f).Add(new Knob(), new Knob()),
};
host.ShowPopup(popup);
UIHost.ShowMenu shows a list of MenuItems (submenus, check marks, shortcuts, separators), drawn by MenuPopup, or native with UIHost.UseNativeMenus = true. UIHost.BeginTextEdit opens an in-place text entry.
6. Animation and continuous refresh
host.Animator.Animate(0f, 1f, 0.25, v => panel.Opacity = v, Easing.CubicOut);
public sealed class Spinner : UIElement, ITickable
{
private float _angle;
protected override void OnAttached() => Host!.AddTicker(this);
protected override void OnDetached() => Host!.RemoveTicker(this);
public bool Tick(double dt) { _angle = (_angle + (float)dt * 360f) % 360f; InvalidateVisual(); return true; }
protected override void OnRender(DrawingContext dc)
=> dc.DrawArc(Bounds.Center, 8f, _angle, 270f, Theme.Palette.Accent, 2f, StrokeStyle.Round);
}
The frame timer (about 60 frames per second) runs only while an animation or a ticker is active.
7. Data coming from the audio thread
The audio thread must never touch the UI. It publishes values without locks, and the UI reads them every frame:
PeakValue: peak of a block (meters, LEDs, gain reduction);AudioRingBuffer: samples (oscilloscope, spectrum analyzer);NoteActivity: held notes (editor keyboard, viaPianoKeyboard.ActivitySource);UIHost.Post(Action): run code on the UI thread from any thread.
In an NPlug plug-in, these objects live in the processor's shared state:
public sealed class SynthShared
{
public NoteActivity Notes { get; } = new();
public PeakValue[] Output { get; } = [new(), new()];
}
public sealed class SynthProcessor : PluginUIProcessor<SynthModel>
{
private readonly SynthShared _shared = new();
protected override object? CreateSharedState() => _shared; // announced to the controller on connection
// ProcessMain: _shared.Output[0].PushBlockPeak(left); _shared.Notes.NoteOn(pitch); ...
}
// Editor: the state may arrive after Build (the components connect in any order).
controller.SharedStateChanged += () =>
{
if (controller.TryGetSharedState<SynthShared>(out var shared)) meter.Sources = shared.Output;
};
The opposite direction (editor → processor) goes through VST3 messages: PluginUIController.SendNote and PluginEditorView.ConnectKeyboard for notes, PluginUIMessages.TrySend for your own messages (identifiers outside the PluginUI. prefix). Messages arrive on the UI thread; never create them on the audio thread.
8. Value sources
IParameter decouples the controls from the plug-in format. PluginUI.NPlug provides NPlugParameter (NPlug VST3 parameters). LocalParameter is for UI-only state, standalone applications and tests. Any other source (CLAP parameters, MVVM model state, network…) only has to implement IParameter and raise Changed on the UI thread.
9. Live edit mode
Ctrl+Shift+E (or host.IsLiveEditEnabled = true) turns on the layout editor: a click selects an element, Alt+click selects its parent, drag to move and use the handles to resize. The arrow keys nudge the element (Shift: ×10, Ctrl: resize), Ctrl+Z undoes. Ctrl+C copies the C# code of the modified elements (SetBounds for the children of a Canvas, Margin/Width/Height otherwise), to paste back into the editor code. Name the elements (Name) to get code that can be used directly. Turn the shortcut off in production with host.LiveEditShortcutEnabled = false.
10. Off-screen rendering
host.RenderToPng("capture.png", scale: 2f) produces an image of the UI without a window: documentation, previews, visual regression tests.
11. Numbers: always a decimal point
Every number displayed or entered by PluginUI uses the point as the decimal separator, whatever the interface language and the regional settings of the machine. In your controls and formatters, use InvariantNumber.Format(…) / InvariantNumber.TryParse(…) (or CultureInfo.InvariantCulture), never double.Parse or ToString() without a culture.
InvariantNumber.TryParse rejects the comma ("1,5" could be read as 1.5 or 15) and thousands separators. In an in-place text entry, rejected text leaves the box open with a red frame (TextBox.IsInvalid). To get this behavior in your own text entries, use the UIHost.BeginTextEdit(bounds, text, Func<string, bool> tryCommit, …) overload: return false to reject the text.
The CA1304 and CA1305 analysis rules are errors in every project of the solution except the tests (CodeAnalysis.globalconfig). A culture-dependent call therefore does not compile.
12. UI texts and language
The texts PluginUI displays itself (dialog buttons, preset menu, placeholder texts, tooltips, live edit help) come from a UIStrings table:
UIHost.DefaultLanguage = UILanguages.Auto; // default: Windows display language
host.Language = UILanguages.French; // or for a single editor
UIStrings.Register("de", UIStrings.English with { Cancel = "Abbrechen", Save = "Speichern" });
English and French are built in. An unknown language falls back to English, and "fr-CA" falls back to "fr". In Auto mode, the language is read through Win32, which also works in a NativeAOT plug-in compiled in invariant globalization mode.
In a control, read the texts through the protected Strings property (the host's, or that of DefaultLanguage as long as the element is not attached). For a computed tooltip, override GetToolTipText(). The texts specific to your plug-in (labels, names of a parameter's values, preset bank names) do not go through this table: they are data of your plug-in.
13. Design conventions (PluginUIEditor)
The visual designer and the .pluginui format (see EDITOR.md) describe controls by their public properties. For a custom control to be fully usable there:
- Public parameterless constructor: this is what the type registry uses (
Register<T>()); otherwise, provide a factory (Register(() => new MyControl(…))). - A category for every public settable property:
[Category("…")], one ofDesign,Layout,Appearance,Text,Value,Data,Behavior. On the class,[Category("My controls")]chooses its group in the toolbox. [Browsable(false)]for what is not set at design time: delegates (Func,Action), run-time objects (audio sources, providers), internal state.- Property types: those the format can describe (numbers, strings, enums,
Color,Thickness, styles, images, skins, simple lists,IParameterfor bindings; full list in EDITOR.md). Other properties are set in code (OnBuilt). - Description: the XML summary (
/// <summary>) of the property, read from the generated documentation file (<GenerateDocumentationFile>true</GenerateDocumentationFile>,.xmlfile next to the DLL). Write it for a user, not for yourself. - Default value: that of a new instance. The editor writes into the design and the generated code only the properties that differ from it; no
[DefaultValue]is needed.
To see them in the toolbox, add the DLL in Tools ▸ Options ▸ Extension assemblies in PluginUIEditor. To load a design that uses them at run time, register them in the registry passed to DesignLoader.Build:
var registry = DesignTypeRegistry.CreateDefault();
registry.Register<MyMeter>("My controls");
var result = DesignLoader.Build(design, registry, options);
The DesignConventionTests test checks these rules for every element of the library. Copy it into your projects for your own controls.