Custom Widgets

Build anything. Escape the widget library.

What Terminals Offer

Terminals do not have pixels. They have character cells arranged in a grid. Each cell holds one character with foreground color, background color, and text modifiers (bold, italic, underline).

This constraint shapes what you can draw:

The built-in Canvas widget uses Braille patterns for line graphs and shapes. Custom widgets give you direct control over every cell.

The Problem

Standard widgets handle common needs. Paragraphs display text. Lists show selections. Tables organize data.

But terminals can do more. You want a game board, a network graph, or a custom visualization. The built-in widgets cannot help you here.

The Solution

Any Ruby object that implements render(area) works as a widget. You are not limited to what the library ships. Define a class. Implement one method. Pass it to frame.render_widget.

The Engine calls your render method with the area where your widget should draw. You return an array of Draw commands. The Engine executes them.

The Contract

Your custom widget implements the _CustomWidget interface. The area parameter is a Rect with x, y, width, and height. It tells you where to draw and how much space you have.

Draw Commands

Two commands describe what to draw:

Command Purpose
Draw.string(x, y, text, style) Draw a styled string at absolute coordinates
Draw.cell(x, y, cell) Draw a single cell (character + style)
class HelloWidget
  def render(area)
    [
      RatatuiRuby::Draw.string(
        area.x,
        area.y,
        "Hello, World!",
        RatatuiRuby::Style::Style.new(fg: :green, modifiers: [:bold])
      )
    ]
  end
end

Coordinate Offsets

The area.x and area.y values are not always zero. When your widget renders inside a Block with borders, or within a nested layout, the area’s origin shifts.

Always add area.x and area.y to your drawing coordinates. This pattern ensures your widget works regardless of where it appears on screen.

class DiagonalWidget
  def render(area)
    (0...area.height).filter_map do |i|
      next if i >= area.width  # Stay within bounds

      RatatuiRuby::Draw.string(
        area.x + i,  # Offset from area origin
        area.y + i,
        "\\",
        RatatuiRuby::Style::Style.new(fg: :red)
      )
    end
  end
end

Composability

Custom widgets compose with standard widgets. Wrap them in Blocks. Place them in layouts. Mix them with Paragraphs and Lists.

RatatuiRuby.run do |tui|
  tui.draw do |frame|
    areas = tui.layout_split(
      frame.area,
      direction: :horizontal,
      constraints: [tui.constraint_percentage(50), tui.constraint_percentage(50)]
    )

    # Standard widget on the left
    frame.render_widget(tui.paragraph(text: "Standard"), areas[0])

    # Custom widget on the right
    frame.render_widget(DiagonalWidget.new, areas[1])
  end
end

To render inside a bordered Block, calculate the inner area first:

tui.draw do |frame|
  # Render the block frame
  block = tui.block(title: "Custom", borders: [:all])
  frame.render_widget(block, frame.area)

  # Calculate inner area (1-cell border on all sides)
  inner = tui.rect(
    x: frame.area.x + 1,
    y: frame.area.y + 1,
    width: [frame.area.width - 2, 0].max,
    height: [frame.area.height - 2, 0].max
  )

  # Render custom widget inside
  frame.render_widget(MyWidget.new, inner)
end

Using Custom Widgets in Layouts

Custom widgets work as children in Layout trees. The layout system passes the calculated area to your render method.

layout = RatatuiRuby::Layout::Layout.new(
  direction: :vertical,
  constraints: [
    RatatuiRuby::Layout::Constraint.length(1),
    RatatuiRuby::Layout::Constraint.fill(1),
  ],
  children: [
    RatatuiRuby::Widgets::Paragraph.new(text: "Header"),
    MyCustomWidget.new,  # Your widget here
  ]
)

RatatuiRuby.draw(layout)

Testing Custom Widgets

Custom widgets return arrays. Test them by calling render directly and asserting on the result.

def test_hello_widget_output
  area = RatatuiRuby::Rect.new(x: 0, y: 0, width: 20, height: 5)
  widget = HelloWidget.new
  commands = widget.render(area)

  assert_equal 1, commands.length
  assert_equal 0, commands[0].x
  assert_equal 0, commands[0].y
  assert_equal "Hello, World!", commands[0].string
end

For visual testing, use the test helper to render to a buffer and assert on content:

class TestMyWidget < Minitest::Test
  include RatatuiRuby::TestHelper

  def test_renders_in_terminal
    with_test_terminal(10, 5) do
      RatatuiRuby.draw(MyWidget.new)
      assert_equal "Expected  ", buffer_content[0]
    end
  end
end

Typing Your Widgets (RBS)

Type your custom widgets by implementing the _CustomWidget interface:

# my_widget.rbs
class MyWidget
  def render: (RatatuiRuby::Rect area) -> Array[RatatuiRuby::Draw::StringCmd | RatatuiRuby::Draw::CellCmd]
end

The interface uses structural typing. Any class with a matching render signature satisfies it.

Related Resources