devuiGuideDemosAPIGitHub

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 saysExample
typesA kind of value: equality, cloning, saving, blendingvector, color
matchersWhich values become which controls: a type, a codec, a viewa THREE.Color is a colour
codecs (in a matcher)How to read a value off its object and write it backlinear RGB ⇄ the Color
viewsRow views the extension draws, by idthe spring curve below
inspectorsHow a whole object is laid outgui.add(material)
textureSourcesWhat the texture viewer can drawrender targets
lifetimesDispose a control with its objectthree's dispose event
commandsThings to run from the palette, menus and hotkeysLog renderer.info
menuItems in a row's right-click menuLearn MIDI
setupAnything else, with a cleanuplistening 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:

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