TermUI includes pre-built widgets for common UI patterns. This guide covers the available widgets and how to use them.

Widget Types

TermUI has two types of widgets:

  1. Simple Widgets - Stateless, render with keyword options (Gauge, Sparkline)
  2. Stateful Widgets - Use the StatefulComponent pattern with new/init/handle_event/render

Simple Widgets

Gauge

Example: See examples/gauge/ for a complete demonstration.

Displays a value as a progress bar with optional color zones.

alias TermUI.Widgets.Gauge
alias TermUI.Renderer.Style

# Basic gauge
Gauge.render(value: 75, width: 20)

# With color zones
Gauge.render(
  value: cpu_percent,
  width: 20,
  zones: [
    {0, Style.new(fg: :green)},     # 0-59: green
    {60, Style.new(fg: :yellow)},   # 60-79: yellow
    {80, Style.new(fg: :red)}       # 80-100: red
  ]
)

# With value display
Gauge.render(
  value: 42,
  width: 30,
  show_value: true,
  show_range: true
)

Options:

OptionTypeDefaultDescription
valuenumberrequiredCurrent value (0-100)
widthinteger20Width in characters
zoneslist[]Color zones [{threshold, style}]
show_valuebooleanfalseDisplay numeric value
show_rangebooleanfalseDisplay min/max
styleStyledefaultBase style

Example Output:

[] 60%

Sparkline

Example: See examples/sparkline/ for a complete demonstration.

Compact inline graph showing trends.

alias TermUI.Widgets.Sparkline

# Basic sparkline
Sparkline.render(values: [10, 25, 40, 30, 50, 45, 60])

# With range
Sparkline.render(
  values: history,
  min: 0,
  max: 100,
  style: Style.new(fg: :cyan)
)

Options:

OptionTypeDefaultDescription
valueslistrequiredList of numeric values
minnumberautoMinimum value for scaling
maxnumberautoMaximum value for scaling
styleStyledefaultColor style

Example Output:

Uses Unicode block characters (▁▂▃▄▅▆▇█) to show 8 levels of height.

Stateful Widgets

Stateful widgets follow the StatefulComponent pattern:

# 1. Create props with Widget.new(opts)
props = Widget.new(option: value)

# 2. Initialize state with Widget.init(props)
{:ok, widget_state} = Widget.init(props)

# 3. Handle events with Widget.handle_event(event, state)
{:ok, widget_state} = Widget.handle_event(event, widget_state)

# 4. Render with Widget.render(state, area)
node = Widget.render(widget_state, %{width: 80, height: 24})

Table

Example: See examples/table/ for a complete demonstration.

Scrollable data table with selection and sorting.

alias TermUI.Widgets.Table
alias TermUI.Widgets.Table.Column

# Create props
props = Table.new(
  columns: [
    Column.new(:name, "Name"),
    Column.new(:age, "Age", width: 10, align: :right),
    Column.new(:city, "City", width: 15)
  ],
  data: [
    %{name: "Alice", age: 30, city: "NYC"},
    %{name: "Bob", age: 25, city: "LA"},
    %{name: "Carol", age: 35, city: "Chicago"}
  ],
  selection_mode: :single,
  on_select: fn row -> IO.inspect(row) end
)

# Initialize
{:ok, table_state} = Table.init(props)

# In your component's event handler
def update({:table_event, event}, state) do
  {:ok, new_table} = Table.handle_event(event, state.table)
  {%{state | table: new_table}, []}
end

# In your view
def view(state) do
  Table.render(state.table, %{width: 60, height: 15})
end

Options:

OptionTypeDefaultDescription
columnslistrequiredColumn definitions
datalistrequiredList of row maps
selection_modeatom:single:none, :single, or :multi
sortablebooleantrueEnable column sorting
on_selectfunctionnilSelection callback
header_styleStyledefaultHeader row style
selected_styleStylereverseSelected row style

Keyboard Navigation:

  • Arrow keys: Move selection
  • Page Up/Down: Scroll by page
  • Home/End: Jump to first/last row
  • Enter: Confirm selection
  • Space: Toggle selection (multi mode)

Example: See examples/menu/ for a complete demonstration.

Hierarchical menu with submenus and keyboard navigation.

alias TermUI.Widgets.Menu

# Create props with item constructors
props = Menu.new(
  items: [
    Menu.action(:new, "New File", shortcut: "Ctrl+N"),
    Menu.action(:open, "Open...", shortcut: "Ctrl+O"),
    Menu.separator(),
    Menu.submenu(:recent, "Recent Files", [
      Menu.action(:file1, "document.txt"),
      Menu.action(:file2, "notes.md")
    ]),
    Menu.separator(),
    Menu.checkbox(:autosave, "Auto Save", checked: true),
    Menu.action(:exit, "Exit", shortcut: "Ctrl+Q")
  ],
  on_select: fn id -> handle_menu_action(id) end
)

# Initialize
{:ok, menu_state} = Menu.init(props)

# Handle events and render
{:ok, menu_state} = Menu.handle_event(event, menu_state)
Menu.render(menu_state, %{width: 30, height: 20})

Item Types:

ConstructorDescription
Menu.action(id, label, opts)Selectable menu item
Menu.submenu(id, label, children)Item with nested menu
Menu.separator()Visual divider
Menu.checkbox(id, label, opts)Toggleable item

Keyboard Navigation:

  • Up/Down: Move between items
  • Enter/Space: Select or expand submenu
  • Left: Collapse submenu
  • Right: Expand submenu
  • Escape: Close menu

TextInput

Example: See examples/text_input/ for a complete demonstration.

Single-line and multi-line text input with cursor movement.

alias TermUI.Widgets.TextInput

# Create props
props = TextInput.new(
  placeholder: "Enter your name...",
  width: 40,
  multiline: false
)

# Initialize
{:ok, input_state} = TextInput.init(props)

# Handle events
{:ok, input_state} = TextInput.handle_event(event, input_state)

# Get current value
value = TextInput.get_value(input_state)

# Render
TextInput.render(input_state, %{width: 50, height: 1})

Options:

OptionTypeDefaultDescription
valuestring""Initial text value
placeholderstring""Placeholder text
widthinteger40Field width
multilinebooleanfalseEnable multi-line mode
max_visible_linesinteger5Lines before scrolling
enter_submitsbooleanfalseEnter submits vs newline
on_changefunctionnilValue change callback
on_submitfunctionnilSubmit callback

Keyboard Controls:

  • Left/Right: Move cursor
  • Up/Down: Move between lines (multiline)
  • Home/End: Start/end of line
  • Ctrl+Home/End: Start/end of text
  • Backspace/Delete: Delete characters
  • Ctrl+Enter: Insert newline (multiline)
  • Enter: Submit or newline

Helper Functions:

# Get current value
TextInput.get_value(state) # => "current text"

# Get cursor position
TextInput.get_cursor(state) # => {row, col}

# Get line count
TextInput.get_line_count(state) # => 3

# Set focus
state = TextInput.set_focused(state, true)

# Clear input
state = TextInput.clear(state)

Dialog

Example: See examples/dialog/ for a complete demonstration.

Modal dialog with buttons.

alias TermUI.Widgets.Dialog

# Create props
props = Dialog.new(
  title: "Confirm Delete",
  content: text("Are you sure you want to delete this file?"),
  buttons: [
    %{id: :cancel, label: "Cancel"},
    %{id: :confirm, label: "Delete", style: :danger}
  ],
  width: 50,
  on_confirm: fn button_id -> handle_action(button_id) end
)

# Initialize and use
{:ok, dialog_state} = Dialog.init(props)
{:ok, dialog_state} = Dialog.handle_event(event, dialog_state)
Dialog.render(dialog_state, %{width: 80, height: 24})

Options:

OptionTypeDefaultDescription
titlestringrequiredDialog title
contentnodenilDialog body content
buttonslist[{id: :ok, label: "OK"}]Button definitions
widthinteger40Dialog width
closeablebooleantrueEscape closes dialog
on_closefunctionnilClose callback
on_confirmfunctionnilButton activation callback

Keyboard Navigation:

  • Tab/Shift+Tab: Move between buttons
  • Enter/Space: Activate focused button
  • Escape: Close dialog

PickList

Example: See examples/pick_list/ for a complete demonstration.

Modal selection dialog with type-ahead filtering.

alias TermUI.Widget.PickList

# Create props
props = %{
  items: ["Apple", "Banana", "Cherry", "Date", "Elderberry"],
  title: "Select Fruit",
  width: 40,
  height: 12,
  on_select: fn item -> handle_selection(item) end,
  on_cancel: fn -> handle_cancel() end
}

# Initialize
{:ok, picklist_state} = PickList.init(props)

# Handle events
{:ok, picklist_state} = PickList.handle_event(event, picklist_state)

# Render
PickList.render(picklist_state, %{width: 80, height: 24})

Options:

OptionTypeDefaultDescription
itemslistrequiredList of items to display
titlestring"Select"Modal title
widthinteger40Modal width
heightinteger10Modal height
on_selectfunctionnilSelection callback fn item -> ... end
on_cancelfunctionnilCancel callback fn -> ... end
stylemap%{}Border/text style
highlight_stylemapinvertedSelected item style

Keyboard Controls:

  • Up/Down: Navigate items
  • Page Up/Down: Jump 10 items
  • Home/End: Jump to first/last
  • Enter: Confirm selection
  • Escape: Cancel
  • Typing: Filter items (type-ahead)
  • Backspace: Remove filter character

Building Custom Widgets

Create reusable widgets as functions:

defmodule MyApp.Widgets do
  import TermUI.Component.Helpers
  alias TermUI.Renderer.Style

  @doc """
  Renders a labeled value pair.
  """
  def labeled_value(label, value, opts \\ []) do
    label_style = Keyword.get(opts, :label_style, Style.new(fg: :bright_black))
    value_style = Keyword.get(opts, :value_style, Style.new(fg: :white))

    stack(:horizontal, [
      text("#{label}: ", label_style),
      text(to_string(value), value_style)
    ])
  end

  @doc """
  Renders a bordered box with title.
  """
  def box(title, content, opts \\ []) do
    width = Keyword.get(opts, :width, 40)
    border_style = Keyword.get(opts, :border_style, Style.new(fg: :cyan))

    inner_width = width - 4
    top_border = "┌─ " <> title <> " " <> String.duplicate("─", inner_width - String.length(title) - 1) <> "┐"
    bottom_border = "└" <> String.duplicate("─", width - 2) <> "┘"

    stack(:vertical, [
      text(top_border, border_style),
      stack(:horizontal, [
        text("│ ", border_style),
        content,
        text(" │", border_style)
      ]),
      text(bottom_border, border_style)
    ])
  end

  @doc """
  Renders a status indicator.
  """
  def status_indicator(status) do
    {symbol, style} = case status do
      :ok -> {"●", Style.new(fg: :green)}
      :warning -> {"●", Style.new(fg: :yellow)}
      :error -> {"●", Style.new(fg: :red)}
      :unknown -> {"○", Style.new(fg: :bright_black)}
    end

    text(symbol, style)
  end
end

Usage:

import MyApp.Widgets

def view(state) do
  stack(:vertical, [
    box("System Status", stack(:vertical, [
      stack(:horizontal, [
        status_indicator(:ok),
        text(" "),
        labeled_value("CPU", "#{state.cpu}%")
      ]),
      stack(:horizontal, [
        status_indicator(:warning),
        text(" "),
        labeled_value("Memory", "#{state.memory}%")
      ])
    ]))
  ])
end

Widget Composition

Combine widgets for complex UIs:

alias TermUI.Widgets.{Gauge, Sparkline, Table}

def view(state) do
  stack(:vertical, [
    # Header with gauges
    stack(:horizontal, [
      box("CPU", Gauge.render(value: state.cpu, width: 15)),
      box("Memory", Gauge.render(value: state.mem, width: 15))
    ]),

    # Sparkline history
    box("Network", stack(:vertical, [
      stack(:horizontal, [
        text("RX: "),
        Sparkline.render(values: state.rx_history)
      ]),
      stack(:horizontal, [
        text("TX: "),
        Sparkline.render(values: state.tx_history)
      ])
    ])),

    # Process table (stateful widget)
    Table.render(state.table, %{width: 60, height: 10})
  ])
end

Full Example: Component with TextInput

defmodule MyApp.SearchForm do
  use TermUI.Elm

  alias TermUI.Event
  alias TermUI.Widgets.TextInput

  def init(_opts) do
    props = TextInput.new(
      placeholder: "Search...",
      width: 40
    )
    {:ok, input_state} = TextInput.init(props)

    %{
      input: TextInput.set_focused(input_state, true),
      results: []
    }
  end

  def event_to_msg(%Event.Key{key: :enter}, state) do
    query = TextInput.get_value(state.input)
    {:msg, {:search, query}}
  end

  def event_to_msg(%Event.Key{key: "q"}, %{input: input}) do
    # Only quit if input is empty
    if TextInput.get_value(input) == "" do
      {:msg, :quit}
    else
      {:msg, {:input_event, %Event.Key{key: "q", char: "q"}}}
    end
  end

  def event_to_msg(event, _state) do
    {:msg, {:input_event, event}}
  end

  def update(:quit, state), do: {state, [:quit]}

  def update({:input_event, event}, state) do
    {:ok, new_input} = TextInput.handle_event(event, state.input)
    {%{state | input: new_input}, []}
  end

  def update({:search, query}, state) do
    results = perform_search(query)
    {%{state | results: results}, []}
  end

  def view(state) do
    stack(:vertical, [
      text("Search:", Style.new(fg: :cyan)),
      TextInput.render(state.input, %{width: 50, height: 1}),
      text(""),
      render_results(state.results)
    ])
  end

  defp perform_search(query), do: []
  defp render_results([]), do: text("No results")
  defp render_results(results) do
    stack(:vertical, Enum.map(results, &text(&1)))
  end
end

Next Steps

  • Advanced Widgets - Navigation, visualization, streaming, and BEAM introspection widgets
  • Styling - Customize widget appearance
  • Layout - Position widgets
  • Events - Handle widget interactions