PackagesCoreDocs

Renderables vs Constructs

Lets look at two ways of composing Renderables, imperative and declarative. Assume we want to create a simple "login" form with a username and password input.

Imperative

Creates concrete Renderable instances with a RenderContext and composes via add(). State/behavior are mutated directly on instances (setters/methods), with mouse/key events bubbling upward through processMouseEvent for example.

import { BoxRenderable, TextRenderable, InputRenderable, createCliRenderer, type RenderContext } from "@opentui/core"

const renderer = await createCliRenderer()

const loginForm = new BoxRenderable(renderer, {
  id: "login-form",
  width: 20,
  height: 10,
  padding: 1,
})

// Compose renderables to a single renderable.
// Needs a RendererContext at creation time.
function createLabeledInput(renderer: RenderContext, props: { label: string; placeholder: string; id: string }) {
  const labeledInput = new BoxRenderable(renderer, {
    id: `${props.id}-labeled-input`,
    flexDirection: "row",
    backgroundColor: "gray",
  })

  labeledInput.add(
    new TextRenderable(renderer, {
      id: `${props.id}-label`,
      content: props.label + " ",
    }),
  )
  labeledInput.add(
    new InputRenderable(renderer, {
      id: `${props.id}-input`,
      placeholder: props.placeholder,
      backgroundColor: "white",
      textColor: "black",
      cursorColor: "blue",
      focusedBackgroundColor: "orange",
      width: 20,
    }),
  )

  return labeledInput
}

const labeledUsername = createLabeledInput(renderer, {
  id: "username",
  label: "Username:",
  placeholder: "Enter your username...",
})
loginForm.add(labeledUsername)

// Now it becomse difficult to focus. because it is in a container.
// This does not work:
labeledUsername.focus()

// Needs to be:
labeledUsername.getRenderable("username-input")?.focus()

const labeledPassword = createLabeledInput(renderer, {
  id: "password",
  label: "Password:",
  placeholder: "Enter your password...",
})
loginForm.add(labeledPassword)

// Compose a button component
function createButton(props: { content: string; onClick: () => void; id: string }) {
  const box = new BoxRenderable(renderer, {
    id: `${props.id}-button`,
    border: true,
    backgroundColor: "gray",
    onMouseDown: props.onClick,
  })
  const text = new TextRenderable(renderer, {
    id: `${props.id}-button-text`,
    content: props.content,
    selectable: false,
  })
  box.add(text)
  return box
}

const buttons = new BoxRenderable(renderer, {
  id: "buttons",
  flexDirection: "row",
  padding: 1,
  width: 20,
})
buttons.add(createButton({ id: "register", content: "Register", onClick: () => {} }))
buttons.add(createButton({ id: "login", content: "Login", onClick: () => {} }))
loginForm.add(buttons)

renderer.root.add(loginForm)

Declarative

Builds an allegedly lightweight VNode graph using functional constructs that return VNodes; no instances exist until instantiate(ctx, vnode) is called. During instantiation, children are flattened, renderables are created and added, and any chained method/property calls made on VNodes are replayed on the created instance. delegate(mapping, vnode) can annotate the VNode so selected APIs (e.g., focus, add) are later routed to a specific descendant when the instance is created.

import { Text, Input, Box, createCliRenderer, delegate, instantiate } from "@opentui/core"

const renderer = await createCliRenderer()

function LabeledInput(props: { id: string; label: string; placeholder: string }) {
  return delegate(
    {
      focus: `${props.id}-input`,
    },
    Box(
      { flexDirection: "row" },
      Text({ content: props.label + " " }),
      Input({
        id: `${props.id}-input`,
        placeholder: props.placeholder,
        width: 20,
        backgroundColor: "white",
        textColor: "black",
        cursorColor: "blue",
        focusedBackgroundColor: "orange",
      }),
    ),
  )
}

function Button(props: { id: string; content: string; onClick: () => void }) {
  return Box(
    {
      border: true,
      backgroundColor: "gray",
      onMouseDown: props.onClick,
    },
    Text({ content: props.content, selectable: false }),
  )
}

const usernameInput = LabeledInput({ id: "username", label: "Username:", placeholder: "Enter your username..." })
usernameInput.focus()

const loginForm = Box(
  { width: 20, height: 10, padding: 1 },
  usernameInput,
  LabeledInput({ id: "password", label: "Password:", placeholder: "Enter your password..." }),
  Box(
    { flexDirection: "row", padding: 1, width: 20 },
    Button({ id: "login", content: "Login", onClick: () => {} }),
    Button({ id: "register", content: "Register", onClick: () => {} }),
  ),
)

renderer.root.add(loginForm)

How is this guide?

Last updated on

Powered by Holocron

Documentation