Writing an extension
devui knows nothing about any value type by itself: numbers, colours, curves and three.js all come from extensions (std, three), and they use only the public extension API, the same one this guide uses. An extension tells devui:
| What it says | Example | |
|---|---|---|
types | A kind of value: equality, cloning, saving, blending | vector, color |
matchers | Which values become which controls: a type, a codec, a view | a THREE.Color is a colour |
codecs (in a matcher) | How to read a value off its object and write it back | linear RGB ⇄ the Color |
views | Row views the extension draws, by id | the spring curve below |
inspectors | How a whole object is laid out | gui.add(material) |
textureSources | What the texture viewer can draw | render targets |
lifetimes | Dispose a control with its object | three's dispose event |
commands | Things to run from the palette, menus and hotkeys | Log renderer.info |
menu | Items in a row's right-click menu | Learn MIDI |
setup | Anything else, with a cleanup | listening to the renderer |
It's all in one module, re-exported by the package:
import { defineExtension, type Codec, type Matcher, type RowView } from '@haxiomic/devui';
defineExtension checks the definition and stamps it with apiVersion, the extension API's own version (1). That changes only when an extension written for it would break, not with every release of the package; devui warns when an extension was written for a newer one.
A worked example: springs
demo/extension/ has the whole thing, and its tests run it (spring-extension.test.ts, tests/extension.spec.ts). The library is a tiny spring (spring.ts) of the kind animation libraries have. (devui has springs built in, every library's config among them: see the curves guide. This one is written as a library would write its own type's, and its matcher, at 200, comes before std's.)
class Spring {
constructor(public stiffness = 170, public damping = 26, public mass = 1) {}
step(target: number, dt: number): number;
response(times: number[]): number[];
get dampingRatio(): number;
}
We want a spring in your params to be one control that shows its response as a curve you drag, and gui.add(spring) to lay out its numbers.
1. A codec
A control's value is canonical: a plain value its type understands. A spring's is [stiffness, damping, mass], a vector. The codec reads it off the spring and writes it back into the same spring (return the object you were given), so your code keeps its reference:
export const springCodec: Codec<Spring, number[]> = {
id: 'spring',
read: (s) => [s.stiffness, s.damping, s.mass],
write: (s, [k, c, m]) => {
s.stiffness = k!;
s.damping = c!;
s.mass = m!;
return s;
},
// "Copy as code"
toCode: (s) => `new Spring(${s.stiffness}, ${s.damping}, ${s.mass})`,
// production builds (the noop) assign these fields back, without your codec
toLiteral: (s) => ({ stiffness: s.stiffness, damping: s.damping, mass: s.mass }),
};
2. A matcher
Matchers run highest priority first (your project 300, libraries 200, std 100), and the first to return a resolution wins:
const springMatcher: Matcher = {
id: 'spring:spring',
priority: 200,
match: ({ value }) =>
value instanceof Spring
? { type: 'vector', codec: springCodec, view: 'spring', explain: 'Spring: drag its curve' }
: null,
};
explain is what hovering the label says about how the control was inferred. A matcher also sees key, target and the hints, and can resolve() an inner value (that's how { value } uniforms are unwrapped).
3. A view
A row view draws into a plain element, the row's value cell, with any framework or none. It's in the panel's shadow root, so the panel's CSS variables are there: --accent, --fg, --muted, --row, --line, --radius, --row-h, --mono.
The panel's own CSS is adopted (constructed stylesheets), which the cascade puts after any <style> element: a rule in a <style> of your view's loses a tie with the panel's, and a page whose CSP keeps inline styles out refuses the element. Adopt your CSS too, after the panel's, and it wins ties as a later rule does, on any page:
const root = el.getRootNode() as ShadowRoot;
const sheet = new (el.ownerDocument.defaultView!.CSSStyleSheet)();
sheet.replaceSync(`.spring-curve { stroke: var(--accent); }`);
root.adoptedStyleSheets = [...root.adoptedStyleSheets, sheet];
(Made by the element's own window, as the panel may be drawn in a popped-out one.)
const view: RowView<number[]> = {
id: 'spring',
types: ['vector'],
mount(el, ctx) {
// draw ctx.value; a press drags it as the panel's values drag
el.addEventListener('pointerdown', (e) => {
const [k0, c0, m] = ctx.value;
ctx.drag(e, {
move: ({ dx, dy }) => ctx.set([k0 * Math.exp(dx / 120), c0 - dy / 4, m], { transient: true }),
readout: () => `k ${ctx.value[0].toFixed(0)}`,
});
});
return {
update(next) { ctx = next; draw(); },
destroy() { /* stop observers */ },
};
},
};
export default view;
The context has the control (its label, spec and path), the canonical value, readonly, and:
set(value, { transient }): a change you make. On its own it's one undo step.drag(event, { move, end?, readout? }): from a pointerdown, a drag as the panel's own values drag. It starts once the pointer moves (a press alone is still a click), the pointer locks at the window's edge (the cursor wraps round, so it never runs out of room), Shift is faster and Alt slower, Esc puts the value back, and the HUD by the pointer showsreadout().movegets{ dx, dy, snap }: the travel since the press in px, each step scaled by the gear. It's one undo step, and does nothing on a read-only control.begin()andend(cancel): a gesture of your own (a dragdragdoesn't fit). Every change in between is one undo step, andend(true)puts the value back.
update is called whenever the value changes (the user, your code, undo, a preset, a timeline). If mount or update throws, the row shows the error and the rest of the panel carries on.
A view that labels itself, like the texture view or the frame scope, can take the row's whole width with wide: true, which drops the label column.
Register it lazily, so its code loads the first time a row needs it:
views: [{ id: 'spring', types: ['vector'], load: () => import('./spring-view') }],
The spring view (spring-view.ts) draws the response on a canvas and maps a drag (ctx.drag) to stiffness (sideways) and damping (up and down).
4. An inspector
An inspector lays out a whole object when you gui.add(obj) it. It uses the ordinary API:
const springInspector: Inspector<Spring> = {
id: 'spring:inspector',
priority: 200,
test: (v): v is Spring => v instanceof Spring,
label: () => 'spring',
inspect(s, folder) {
folder.add({ response: s }, 'response'); // the matcher above: the curve
folder.add(s, 'stiffness', { min: 1, max: 600 });
folder.add(s, 'damping', { min: 0, max: 80 });
folder.add(s, 'mass', { min: 0.1, max: 10, scale: 'log' });
},
};
A collapsed inspector folder runs inspect when it's first opened (lazy: false opts out), so a scene of ten thousand objects costs a few rows until you look inside.
5. Commands, and putting it together
export const springExtension = defineExtension({
name: 'spring',
requires: ['std'], // it uses std's vector type
matchers: [springMatcher],
inspectors: [springInspector],
views: [{ id: 'spring', types: ['vector'], load: () => import('./spring-view') }],
commands: [{
id: 'spring.critical',
label: 'Make every spring critically damped',
group: 'spring',
run: (gui) => { /* for each control with view 'spring': set it */ },
}],
});
gui.use(springExtension);
// one spring as a row (its curve), another laid out whole by the inspector
const ball = gui.folder('Ball').params({ follow: new Spring(170, 12) });
gui.add(new Spring(90, 20), { label: 'Ring' });
The rest of the API
types:{ id, equals, clone, toJSON, fromJSON, lerp? }.lerplets presets blend and animate between values. Most extensions reuse std's (number,vector,color,boolean,string,ramp,curve,options), which also makes them keyable on a timeline.textureSources:{ id, test, resolve(value, { attachment }), describe?, depthRange? }.resolvereturns{ kind: 'webgl', texture: { gl, texture, width, height, … } },{ kind: 'webgpu', texture: { device, texture, … } },{ kind: 'image', source }or{ kind: 'cpu', data, width, height, channels }.lifetimes:{ test, onDispose(value, fn) }: a control bound to the value goes when it does.menu(node, gui): items for a row's (or folder's) right-click menu,{ id, label, run, disabled? }. MIDI learn and Drive with audio are made this way.control.setDriver({ label, title }): shows what drives a control from outside (a knob, an audio band) as a chip on its row;setDriver(null)clears it.- Driving a value:
control.set(v, 'driver', { transient: true })each step, then onecontrol.set(rest, 'driver')when you let go. A driver's changes runonChangebut aren't saved or undoable, andonFinishChangeruns once, at the end (as a drag's). Use'external'and handlers skip them; use'user'and they're saved and undoable, which is what MIDI does on purpose: a knob is your hand. setup(gui): runs when the extension is registered; return a cleanup.perf(perf): readouts for the Performance folder whengui.perf()is on.- Helpers:
words(key)splits camelCase and snake_case names for name-based matchers ('lightDir'→'light dir');isPlainObject,jsonEqual;linearToSrgb,srgbToLinear. - Re-registering an extension under the same name replaces it (its matchers, views and the rest go), which is what hot reload does.