These docs describe the main branch, including unreleased changes. Read the 0.6.0 docs.

Custom renderables

This advanced guide is for authors who need a visual tree node that the built-in components do not supply.

Subclass Renderable for a visual node with Yoga layout and terminal-cell drawing. Subclass BaseRenderable only for a non-layout tree node that supplies the complete child, lookup, render-request, and destruction contract.

The custom-renderable API is supported. It exposes low-level layout, drawing, and ownership rules that application components usually hide.

Build a renderable#

This renderable measures an intrinsic width and draws one row of cells:

import { RGBA, Renderable, type OptimizedBuffer, type RenderableOptions, type RenderContext } from "@opentui/core"
import { MeasureMode } from "@opentui/core/yoga"

interface RuleOptions extends RenderableOptions<RuleRenderable> {
  columns: number
  color?: RGBA
}

export class RuleRenderable extends Renderable {
  static override readonly nativeIntegration = this.defineNativeIntegration({
    kind: "custom",
    body: "host",
    construction: "prototype",
  })

  private _columns: number
  private _color: RGBA

  constructor(ctx: RenderContext, options: RuleOptions) {
    super(ctx, options)
    this._columns = Math.max(1, Math.floor(options.columns))
    this._color = RGBA.clone(options.color ?? RGBA.fromInts(255, 255, 255))

    this.setMeasureProvider((width, widthMode) => ({
      width:
        widthMode === MeasureMode.Exactly
          ? width
          : widthMode === MeasureMode.AtMost
            ? Math.min(width, this._columns)
            : this._columns,
      height: 1,
    }))
  }

  set columns(value: number) {
    const columns = Math.max(1, Math.floor(value))
    if (columns === this._columns) return
    this._columns = columns
    this.invalidateIntrinsicSize()
    this.requestRender()
  }

  protected renderSelf(buffer: OptimizedBuffer): void {
    const x = this.buffered ? 0 : this.screenX
    const y = this.buffered ? 0 : this.screenY
    buffer.drawText("-".repeat(this.width), x, y, this._color)
  }
}

Use the test renderer to draw the rule:

import { createTestRenderer } from "@opentui/core/testing"

const { renderer, renderOnce, captureCharFrame } = await createTestRenderer({ width: 5, height: 1 })
try {
  renderer.root.add(new RuleRenderable(renderer, { columns: 5 }))
  await renderOnce()
  console.log(captureCharFrame().trimEnd())
} finally {
  renderer.destroy()
  await renderer.closed
}

Output:

-----

Add the instance to a parent as described in Renderables. The parent owns tree placement. Your code still owns resources that the instance allocates.

Choose the base class#

Base class Use it for Contract
Renderable Visual nodes, layout containers, controls, and drawing surfaces Creates a scene node for Yoga layout, paint order, and hit testing. Supplies buffering, events, and destruction
BaseRenderable Text-node-like or framework-only tree nodes without terminal layout Requires implementations of add, remove, insertBefore, child access, lookup, and requestRender

A scene node is the native node that represents a Renderable in the native scene. Native code walks the scene to lay out and paint the tree. While it paints, it fills the hit grid, the native grid used for mouse hit testing. BaseRenderable does not create a scene node, so it has no Yoga layout and does not draw. Most extensions must use Renderable.

Measure intrinsic size#

Call setMeasureProvider() when content determines an automatic width or height. The callback receives a width, a width mode, a height, and a height mode. Return the measured width and height in terminal cells.

The modes have these meanings:

Mode Meaning
MeasureMode.Undefined No finite constraint is supplied
MeasureMode.AtMost An available size is supplied
MeasureMode.Exactly Yoga fixes this layout dimension

Return intrinsic measurements. Yoga applies the layout constraints. A provider can return an intrinsic dimension larger than an AtMost constraint, or different from an Exactly constraint. Do not clamp results to the mode.

A renderable has one measure provider. A measured node cannot also act as a layout container with children. Setting a provider replaces the previous provider, including built-in text or editor measurement. Call setMeasureProvider(null) to clear it. Clearing does not restore a replaced built-in provider. Destruction releases the registered callback.

Native code calls the provider synchronously while Yoga runs, so the provider must return synchronously. During measurement, you can read completed layout and content. You cannot mutate layout, replace a provider, invalidate measurement, change children, or destroy renderables.

When intrinsic content changes, call invalidateIntrinsicSize() to invalidate cached measurement. Then call requestRender() to schedule a renderer pass. Invalidation does nothing when no provider is registered. requestRender() alone does not invalidate measurement. Setting, replacing, or clearing a provider schedules a pass.

Use getLayout() for a read-only snapshot of raw local layout: left, top, right, bottom, width, and height. It preserves zero dimensions and excludes screen translations. It does not calculate layout. After a property change, it reports the previous completed layout until the next layout pass. Before the first layout pass, dimensions can be NaN. Use screenX, screenY, width, and height for drawing in terminal cells.

Renderable getLayoutNode() and protected yogaNode access are no longer supported. Use layout properties, measurement providers, and getLayout() instead. The standalone Yoga API remains available for separate layout trees. Read Layout for sizing, rounding, and parent constraints.

Use paint hooks#

The native scene handles layout, traversal, visibility, clipping, hit testing, and built-in drawing. Your measure providers, update hooks, lifecycle passes, and paint hooks run in JavaScript on the renderer’s thread.

The paint hooks are renderBefore, renderSelf(), and renderAfter. A paint hook records its drawing and does not draw immediately, unless its renderable is buffered, as described in Handle coordinates, clipping, and buffering. After layout, Core runs the paint hooks of every visible node in paint order. Native code then paints the frame in one pass. It plays each node’s recording at that node’s place in paint order. A measure provider works differently: native code calls it synchronously while Yoga runs. How Core uses native explains both paths.

For a custom renderable, the recordings play in this order: renderBefore, renderSelf(), and then renderAfter. renderAfter draws after the node’s own drawing, but before its descendants. Override renderSelf() on a custom Renderable for custom drawing. For built-in boxes, use renderBefore or renderAfter around native drawing.

Text and editor components skip the generic renderBefore and renderAfter hooks. These are subclasses of TextBufferRenderable and EditBufferRenderable, such as Text, Code, Textarea, and Input. To extend their drawing, override renderSelf(buffer) and call super.renderSelf(buffer) before you draw an overlay.

A paint hook of an unbuffered renderable receives a recording buffer. A recording buffer stores drawing calls and cannot read the frame. buffers, withBuffers(), getRealCharBytes(), and getSpanLines() throw. To apply an effect to existing cells, draw into a buffer that the renderable owns, for example with drawToBuffer(). Read or change that buffer, then compose it with drawFrameBuffer(). For whole-frame effects, use a post-process function.

Native code reads a drawn resource when it paints the frame, not when the hook draws it. A drawn resource is a composed buffer, encoded Unicode, a text buffer view, an editor view, or an image. If a later paint hook changes the resource before native code paints, the frame shows the changed resource. If a paint hook destroys the resource after it is drawn, the resource stays alive until native code paints the frame.

A drawing call in a hook checks ranges and types immediately. If a check fails, the call throws and records nothing. Native code checks the remaining input when it paints the frame. For example, it rejects attribute bits 8 to 31. Such an error does not throw inside the hook. It fails that frame, and render:error reports it with no renderable. Control characters in text take no cells. Tabs and over-long graphemes draw as spaces, as in direct drawing.

Renderable has no render() method, and Core does not call one. Native code handles traversal, hit testing, and frame order. Edit buffer renderables have no renderCursor() method. Use their cursor options.

Paint hooks run after layout and before painting. Prefer update hooks or lifecycle passes for state changes. A color or text change in a paint hook paints in the same frame, even for a node earlier in paint order. Layout changes, such as a new size, position, or child, wait for the next frame. The native scene skips nodes destroyed during hooks, including a node that destroys itself.

In demand mode, Core combines repeated render requests into one frame. But a request on every draw still creates a continuous rerender loop.

Update hooks#

Assign callbacks directly, such as box.renderAfter = callback. OpenTUI also supports subclass methods and class-field hooks.

If you replace or delete a hook descriptor through reflection, call box.refreshHooks() afterward. Paint and style changes do not rediscover hook descriptors automatically.

OpenTUI discovers class-field hooks when the node is attached and before frame or lifecycle work. An intermediate constructor can attach the node before derived fields initialize. If you need those hooks before the next discovery, call refreshHooks() after construction.

Declare native integration#

nativeIntegration describes how a class uses the native scene. Built-ins declare the node kind, a native or host body, lifecycle work, the generic paint hooks, and buffering. Core uses this description to register and dispatch hooks.

Declare the description with defineNativeIntegration() before you create instances. The RuleRenderable example declares kind "custom" and body "host" because its JavaScript method draws the cells.

Use construction: "prototype" only when the class adds no hook fields during construction. Use "fields" when it does. A subclass without its own description still gets hook-field discovery after construction, also when an intermediate constructor attaches the node early. An ordinary custom Renderable can use that default discovery without a declaration.

The body field has two forms:

Form Behavior
"host" Dispatch the body’s drawing to the host.
{ native: method } Identify the method whose work the native node already implements. An override uses host dispatch.

A native-body description does not compile a JavaScript method into native code. Use that form only when the native node already supplies the method’s drawing behavior. For a built-in subclass, inherit its description and change only the capabilities your subclass needs.

lifecycle describes resize and update work. beforeAfter: false disables the generic renderBefore and renderAfter hooks. paintBuffer: "destination" gives paint hooks the frame’s recording buffer, also when the node is buffered. bufferComposition: "native" declares that native code composes the node’s buffer, so Core does not. For intrinsic size, use a measure provider, as in Measure intrinsic size. The shared-text subclass example shows how to select a resource-backed text node.

Declared prototype methods keep normal super behavior. Direct hook assignments use the same registration path. Reflection still requires refreshHooks(), as described above.

Handle coordinates, clipping, and buffering#

An unbuffered renderable’s paint hooks draw in frame coordinates. Native code plays the recording into the renderer’s next buffer. Use screenX and screenY for absolute cell coordinates.

With buffered: true, OpenTUI creates a private OptimizedBuffer for the renderable’s own drawing. renderBefore, renderSelf(), and renderAfter draw into that buffer. Use coordinates relative to (0, 0) in that buffer. These draws happen at once, so the hooks can also read the buffer’s cells. OpenTUI resizes the buffer when the size changes. After renderAfter, Core records one draw that composes the buffer into the frame. Buffering does not turn the whole child subtree into one offscreen surface.

Text, editor, and image renderables are the exception. Their hooks receive a recording buffer even with buffered: true, because their classes declare paintBuffer: "destination" or bufferComposition: "native".

An ancestor with overflow: "hidden" or overflow: "scroll" clips descendant drawing and hit testing. Keep custom drawing inside the renderable’s computed rectangle. See Buffer API for cell operations.

Text renderables expose drawToBuffer(buffer, x, y) to paint their current text viewport into an owned buffer. The coordinates belong to the destination buffer, so (0, 0) is suitable for a local scratch surface. This drawing does not change the text’s layout, allocate a second text resource, or insert hit regions. Use withBuffers() on an owned buffer for scoped cell effects. Use the buffer’s checked clipping and opacity operations for local drawing limits.

Schedule updates#

Use requestRender() for a one-shot repaint. Property setters that affect drawing must call it.

Set live: true only when the node needs a continuous frame loop. A visible live descendant increments the root’s live count. Hiding, detaching, destroying, or setting live to false removes that request.

Core calls the update hook onUpdate(deltaTime) on each frame, after layout and before painting. deltaTime is the time since the previous frame, in milliseconds. Core does not call it for a hidden node or a node with a hidden ancestor. A ScrollBox that culls its children also skips onUpdate for children outside its viewport. See ScrollBox viewport culling.

Use Animation and Timeline when a timeline owns the changing values.

Handle resize#

OpenTUI calls the resize hook onResize(width, height) when computed dimensions change. It can run during a frame. Resize native or local buffers there. A render request from this hook schedules another frame. Do not depend on it for the current frame.

Call super.onResize(width, height) from an override. The base method calls onSizeChange and emits "resize".

Reuse interaction behavior#

Renderable supplies focus, keyboard subscription, mouse bubbling, and selection hooks. Native code adds each visible scene node to the hit grid. Do not create a second interaction system in a subclass.

When every instance can own focus, override protected static readonly defaultFocusable = true. Assigning null or undefined to focusable restores that default. Override handleKeyPress, handlePaste, onMouseEvent, or the selection hooks only for local behavior. Read Interaction, focus, and selection for event order, focus ownership, hit testing, and selection coordinates.

Clean up ownership#

Put final resource cleanup in destroySelf(). Release timers, subscriptions, native handles, and owned buffers there. The base destroy() method is idempotent and performs these actions before destroySelf():

  1. Marks the node destroyed and emits RenderableEvents.DESTROYED.
  2. Detaches it from its parent.
  3. Destroys its private buffer.
  4. Detaches, but does not destroy, its children.
  5. Removes the node from the global renderable map.
  6. Removes focus and listeners.

It then calls destroySelf(). After destroySelf() returns, it destroys the scene node, which owns the Yoga node and the measure provider. Call super.destroySelf() from an override.

onRemove() is not final cleanup. It runs whenever a parent detaches the node. Reparenting calls remove() on the old parent, so onRemove() also runs during reparenting. Use it only for reversible detach behavior.

destroy() detaches children. destroyRecursively() destroys descendants first. Read Lifecycle and cleanup before a custom class owns native resources.

Handle failures#

Validate custom options before allocating dependent resources where possible. If setup fails after an allocation, release every completed allocation before you rethrow.

An exception from onUpdate(), onResize(), or a paint hook aborts that frame. CliRenderer emits render:error with the error. When a paint hook throws, event.renderable is that renderable. For other frame errors, such as an onUpdate() exception or a native paint error, event.renderable is undefined. A listener can correct state and request another frame. Without a listener, the renderer reports the error through its normal error path.

Register framework elements#

React and Solid expose an extend() catalogue. Both catalogues accept a constructor with this shape:

new (ctx: RenderContext, options: unknown) => BaseRenderable

Their construction behavior is different:

Binding Constructor options Later assignment
React Calls the constructor with { id, ...initialProps } Applies initial properties again, then applies updates
Solid Calls the constructor with { id } only Assigns JSX properties after construction

React can directly register a class whose required constructor options come from initial props. Solid cannot do that for required or readonly constructor state.

Do not blindly register FrameBufferRenderable, SliderRenderable, or ScrollBarRenderable in Solid. A frame buffer needs dimensions during construction. A slider and scroll bar need a readonly orientation during construction. Use an adapter that supplies valid constructor defaults or register separate fixed-orientation classes.

Do not register EmbeddedTerminalRenderable in Solid without an adapter. It reads cols, rows, and maxScrollback only in the constructor.

Expose setters only for state that the class can safely change after construction.

After you define an adapter, register it and augment the binding’s OpenTUIComponents interface. The binding guides show the exact TypeScript declarations:

Next#

Changes0.6.0
0.6.0
core: Renderable.render(), updateLayout(), updateFromLayout(), canReuseRenderCommandList(), getLayoutNode(), the protected yogaNode, the RenderCommand type, and RootRenderable.calculateLayout() are removed. Draw in renderSelf(), renderBefore, or renderAfter, and use setMeasureProvider(), invalidateIntrinsicSize(), and getLayout() for size and layout. Protected members such as _x, _y, getScissorRect(), and _getVisibleChildren() are also removed. Use x, y, screenX, and screenY, and the viewportCulling option of ScrollBox. See Custom renderables. (#1479)
core: Custom renderables get setMeasureProvider(), invalidateIntrinsicSize(), getLayout(), refreshHooks(), and defineNativeIntegration(). TextRenderable and CodeRenderable get drawToBuffer(), and OptimizedBuffer gets withBuffers() for scoped cell access. (#1479)
Added NativeRenderableIntegration.