This guide covers the building blocks for constructing screens in render/2: widgets, layout, styles, and events. Everything here works identically in both the Callback Runtime and Reducer Runtime.
Layout
render/2 returns a list of {widget, rect} tuples. Each %ExRatatui.Layout.Rect{} defines a rectangular area on the screen. Use ExRatatui.Layout.split/3 to divide areas into sub-regions using constraints:
alias ExRatatui.Layout
alias ExRatatui.Layout.Rect
area = %Rect{x: 0, y: 0, width: 80, height: 24}
# Three-row layout: header, body, footer
[header, body, footer] = Layout.split(area, :vertical, [
{:length, 3},
{:min, 0},
{:length, 1}
])
# Split body into sidebar + main
[sidebar, main] = Layout.split(body, :horizontal, [
{:percentage, 30},
{:percentage, 70}
])Constraint Types
| Constraint | Description |
|---|---|
{:percentage, n} | Percentage of the available space (0–100) |
{:length, n} | Exact number of rows or columns |
{:min, n} | At least n rows/columns, expands to fill remaining space |
{:max, n} | At most n rows/columns |
{:ratio, num, den} | Fraction of available space (e.g., {:ratio, 1, 3} for one-third) |
Styles
Styles control foreground color, background color, and text modifiers:
alias ExRatatui.Style
# Named colors
%Style{fg: :green, bg: :black}
# RGB
%Style{fg: {:rgb, 255, 100, 0}}
# 256-color indexed
%Style{fg: {:indexed, 42}}
# Modifiers
%Style{modifiers: [:bold, :dim, :italic, :underlined, :crossed_out, :reversed]}Named colors: :black, :red, :green, :yellow, :blue, :magenta, :cyan, :gray, :dark_gray, :light_red, :light_green, :light_yellow, :light_blue, :light_magenta, :light_cyan, :white, :reset.
Styles can be applied to most widgets via the :style field, and many widgets accept additional style fields for specific parts (e.g., highlight_style, border_style).
Rich Text
Text fields on many widgets accept more than a plain string: you can pass a %ExRatatui.Text.Span{}, a %ExRatatui.Text.Line{}, a list of spans, or any mix — letting a single string of output carry per-span colors and modifiers.
alias ExRatatui.Text.{Line, Span}
alias ExRatatui.Style
# A single styled run
Span.new("error", style: %Style{fg: :red, modifiers: [:bold]})
# Multiple styled runs on one line
Line.new([
Span.new(" ok ", style: %Style{fg: :green}),
Span.new(" Build ", style: %Style{fg: :yellow, modifiers: [:bold]})
])
# Line-level overrides: a style layered over spans + per-line alignment
Line.new([Span.new("centered")], style: %Style{modifiers: [:bold]}, alignment: :center)Widgets that accept rich text on their text-bearing fields:
| Widget | Field(s) |
|---|---|
Paragraph | :text |
List | each items entry |
Table | each cell in :rows, each :header cell |
Tabs | each :titles entry |
Block | :title (single-line only) |
Accepted shapes on these fields: String.t(), %Span{}, %Line{}, or [%Span{}]. Plain strings continue to work everywhere. Fields that are semantically single-line (table cells, tab titles, block titles) raise if you pass a string with embedded newlines.
Events
Terminal events are polled automatically by the runtime. In the Callback Runtime, they arrive in handle_event/2. In the Reducer Runtime, they arrive as {:event, event} in update/2.
Key Events
%ExRatatui.Event.Key{
code: "q", # key name: "a"-"z", "up", "down", "enter", "esc", "tab", etc.
kind: "press", # "press", "release", or "repeat"
modifiers: [] # list of "ctrl", "alt", "shift", "super", "hyper", "meta"
}Mouse Events
%ExRatatui.Event.Mouse{
kind: "down", # "down", "up", "drag", "moved", "scroll_down", "scroll_up"
column: 10,
row: 5,
modifiers: []
}Resize Events
%ExRatatui.Event.Resize{
width: 120,
height: 40
}The runtime automatically re-renders on resize — you don't need to handle resize events unless your app needs to react to size changes in its state.
Widgets
Paragraph
Displays text with support for alignment, wrapping, and scrolling.
%Paragraph{
text: "Hello, world!\nSecond line.",
style: %Style{fg: :cyan, modifiers: [:bold]},
alignment: :center,
wrap: true
}Block
A container with borders and a title. Any widget can be wrapped inside a Block via its :block field.
%Block{
title: "My Panel",
borders: [:all],
border_type: :rounded,
border_style: %Style{fg: :blue}
}
# Compose with other widgets:
%Paragraph{
text: "Inside a box",
block: %Block{title: "Title", borders: [:all]}
}Border types: :plain, :rounded, :double, :thick.
List
A selectable list with highlight support for the current item.
%List{
items: ["Elixir", "Rust", "Haskell"],
highlight_style: %Style{fg: :yellow, modifiers: [:bold]},
highlight_symbol: " > ",
selected: 0,
block: %Block{title: " Languages ", borders: [:all]}
}Table
A table with headers, rows, and column width constraints.
%Table{
rows: [["Alice", "30"], ["Bob", "25"]],
header: ["Name", "Age"],
widths: [{:length, 15}, {:length, 10}],
highlight_style: %Style{fg: :yellow},
selected: 0
}Gauge
A progress bar that fills proportionally to a given ratio.
%Gauge{
ratio: 0.75,
label: "75%",
gauge_style: %Style{fg: :green}
}LineGauge
A thin single-line progress bar using line-drawing characters, with separate styles for the filled and unfilled portions.
%LineGauge{
ratio: 0.6,
label: "60%",
filled_style: %Style{fg: :green},
unfilled_style: %Style{fg: :dark_gray}
}BarChart
Vertical or horizontal bar chart. :data is a list of %Bar{} structs, each with a plain-string :label and non-negative integer :value. Chart-level :bar_style, :value_style, and :label_style apply to every bar; individual bars override color via :style and replace the numeric display via :text_value. When :max is nil the chart auto-scales to the largest value.
alias ExRatatui.Widgets.{Bar, BarChart}
%BarChart{
data: [
%Bar{label: "Elixir", value: 80},
%Bar{label: "Rust", value: 95, style: %Style{fg: :red}, text_value: "95!"},
%Bar{label: "Go", value: 60}
],
bar_width: 6,
bar_gap: 2,
bar_style: %Style{fg: :cyan},
value_style: %Style{fg: :white, modifiers: [:bold]},
label_style: %Style{fg: :dark_gray},
direction: :vertical, # or :horizontal
block: %Block{title: " Traffic ", borders: [:all]}
}Values must be non-negative integers — floats or negatives raise ArgumentError at encode time.
Grouped bars
To render side-by-side clusters with shared captions — handy for comparing the same metric across categories (months, regions, products) — pass %BarGroup{} entries via :groups instead of :data. Each group carries its own optional :label and a list of %Bar{} structs, and :group_gap controls the spacing between clusters.
alias ExRatatui.Widgets.{Bar, BarChart, BarGroup}
%BarChart{
groups: [
%BarGroup{label: "Q1", bars: [%Bar{label: "A", value: 10}, %Bar{label: "B", value: 20}]},
%BarGroup{label: "Q2", bars: [%Bar{label: "A", value: 15}, %Bar{label: "B", value: 25}]}
],
bar_width: 3,
bar_gap: 1,
group_gap: 3,
max: 30
}Set either :data or :groups, not both. When :data is used, the chart renders as a single anonymous group; supplying :groups overrides it. :group_gap must be a non-negative integer, and each entry in :groups must be a %BarGroup{} whose :label is nil or a binary — anything else raises ArgumentError at encode time.
Sparkline
Compact, single-line bar chart for time-series or streaming data. :data is a list of non-negative integers with nil entries representing missing samples. Pick a preset via :bar_set (:nine_levels for smooth gradients, :three_levels for low-density glyphs) or pass a custom list of strings — the symbols are proportionally mapped across the nine internal density slots so any non-empty list works.
alias ExRatatui.Widgets.Sparkline
%Sparkline{
data: [0, 1, 3, 5, 8, 3, 1, nil, 2, 4],
max: 8, # auto-scales when nil
direction: :left_to_right, # or :right_to_left
bar_set: :nine_levels, # or :three_levels, or [" ", "▂", "▅", "█"]
style: %Style{fg: :cyan},
absent_value_symbol: "·",
absent_value_style: %Style{fg: :dark_gray},
block: %Block{title: " CPU ", borders: [:all]}
}Entries must be non-negative integers or nil — floats, negatives, and non-list :data raise ArgumentError at encode time. Unknown directions, unknown bar-set atoms, empty custom lists, and non-integer :max values raise similarly.
Calendar
A monthly calendar grid that highlights a target date and optional per-day events. :display_date drives which month is rendered; events can be passed as a list of {Date, Style} tuples or as a %{Date => Style} map (map entries with a nil value are skipped, making toggling easy).
alias ExRatatui.Widgets.Calendar
%Calendar{
display_date: ~D[2026-03-15],
events: [
{~D[2026-03-10], %Style{fg: :red, modifiers: [:bold]}},
{~D[2026-03-20], %Style{fg: :green}}
],
default_style: %Style{fg: :white},
show_month_header: true,
header_style: %Style{fg: :yellow, modifiers: [:bold]},
show_weekdays_header: true,
weekday_style: %Style{fg: :cyan},
show_surrounding: %Style{fg: :dark_gray},
block: %Block{title: " March ", borders: [:all]}
}:display_date must be a %Date{}; :show_month_header and :show_weekdays_header must be booleans; event entries must be {%Date{}, %Style{}} tuples. Anything else raises ArgumentError at encode time. Set :show_surrounding to a Style to bleed the previous/next month into empty grid cells (leave it nil to hide them). The widget needs roughly 22 columns × 8 rows without a block, or 24 × 10 with one.
Canvas
A 2D drawing surface for plotting shapes, charts, and custom visualizations. Shapes are drawn onto a virtual coordinate system defined by :x_bounds and :y_bounds (both {min, max} tuples), then sampled onto the terminal cells using the chosen :marker.
alias ExRatatui.Widgets.Canvas
alias ExRatatui.Widgets.Canvas.{Circle, Label, Line, Points, Rectangle}
alias ExRatatui.Widgets.Canvas.Map, as: CanvasMap
%Canvas{
x_bounds: {0.0, 100.0},
y_bounds: {0.0, 50.0},
marker: :braille, # or :dot, :block, :bar, :half_block
background_color: :black,
shapes: [
%Line{x1: 0.0, y1: 0.0, x2: 100.0, y2: 50.0, color: :cyan},
%Rectangle{x: 10.0, y: 10.0, width: 30.0, height: 20.0, color: :yellow},
%Circle{x: 70.0, y: 25.0, radius: 10.0, color: :magenta},
%Points{coords: [{20.0, 40.0}, {50.0, 30.0}, {80.0, 10.0}], color: :green},
%Label{x: 70.0, y: 25.0, text: "★", color: :white}
],
block: %Block{title: " Plot ", borders: [:all]}
}Every shape takes a plain Color.t() (not a Style) — canvases sample individual pixels so text modifiers do not apply. Rectangle is drawn as an outline anchored at its bottom-left corner; Circle is drawn as an outline centered on {x, y}; Points accepts a list of {x, y} tuples; Label writes a styled text annotation at the given canvas-space coordinate (handy for naming peaks, marking origins, or labeling map locations). Bounds must be {min, max} tuples with min <= max; width, height, and radius must be non-negative; any required field set to nil or a mistyped value raises ArgumentError at encode time. :marker defaults to :braille, which gives the finest sub-cell resolution — drop to :dot or :block for lower-density output or for terminals without Braille fonts.
Drawing a world map
%CanvasMap{} paints the world's coastlines into the canvas — pair it with the geographic bounds {-180, 180} × {-90, 90} and the :dot or :braille marker. Label shapes layered on top let you pin city names or other annotations directly in lat/lon space.
%Canvas{
x_bounds: {-180.0, 180.0},
y_bounds: {-90.0, 90.0},
marker: :dot,
shapes: [
%CanvasMap{resolution: :high, color: :green}, # :low | :high
%Label{x: -74.0, y: 40.7, text: "NYC", color: :yellow},
%Label{x: 139.7, y: 35.7, text: "Tokyo", color: :yellow}
],
block: %Block{title: " World ", borders: [:all]}
}Map.resolution accepts :low (cheap silhouette) or :high (richer coastline detail). Label.text must be a binary; the color applies as the text foreground. Both shapes raise ArgumentError if a required field is missing or mistyped.
Chart
An x/y line, scatter, or bar chart with axes, labels, legend, and multi-series support. Each %Dataset{} carries a list of {x, y} tuples (integers or floats) plus its own :marker, :graph_type, and :style. The required :x_axis and :y_axis configure the visible coordinate range via {min, max} :bounds and optional tick :labels. Pass nil as :legend_position to hide the legend entirely.
alias ExRatatui.Widgets.Chart
alias ExRatatui.Widgets.Chart.{Axis, Dataset}
%Chart{
datasets: [
%Dataset{
name: "CPU",
data: [{0.0, 12.0}, {1.0, 25.0}, {2.0, 48.0}, {3.0, 31.0}, {4.0, 19.0}],
marker: :braille, # or :dot, :block, :bar, :half_block
graph_type: :line, # or :scatter, :bar
style: %Style{fg: :cyan}
},
%Dataset{
name: "Memory",
data: [{0.0, 40.0}, {1.0, 42.0}, {2.0, 55.0}, {3.0, 60.0}, {4.0, 58.0}],
marker: :dot,
style: %Style{fg: :magenta}
}
],
x_axis: %Axis{
title: "Time (s)",
bounds: {0.0, 4.0},
labels: ["0", "2", "4"],
style: %Style{fg: :dark_gray}
},
y_axis: %Axis{
title: "Usage %",
bounds: {0.0, 100.0},
labels: ["0", "50", "100"],
style: %Style{fg: :dark_gray}
},
legend_position: :top_right, # or :top, :top_left, :bottom, :bottom_left,
# :bottom_right, :left, :right, or nil to hide
hidden_legend_constraints: {{:ratio, 1, 4}, {:ratio, 1, 4}},
block: %Block{title: " Metrics ", borders: [:all]}
}:hidden_legend_constraints takes a {width_constraint, height_constraint} tuple — the same shapes accepted by ExRatatui.Layout (:length, :percentage, :ratio, :min, :max, :fill). The legend is hidden whenever its rendered size would exceed those bounds against the chart area, which keeps things readable in cramped layouts. Each dataset's :graph_type is independent: combine a :line series with a :scatter overlay in the same chart for emphasis.
Datasets with non-tuple data points, non-numeric coordinates, unknown markers, unknown :graph_types, unknown :legend_positions, missing axes, malformed :bounds, malformed :hidden_legend_constraints, and unknown :labels_alignment values raise ArgumentError at the bridge boundary.
Tabs
A tab bar for switching between views.
%Tabs{
titles: ["Home", "Settings", "Help"],
selected: 0,
highlight_style: %Style{fg: :cyan, modifiers: [:bold]},
divider: " | ",
block: %Block{borders: [:all]}
}Scrollbar
A scroll position indicator for long content, supporting both vertical and horizontal orientations.
%Scrollbar{
content_length: 100,
position: 25,
viewport_content_length: 10,
orientation: :vertical_right,
thumb_style: %Style{fg: :cyan}
}Orientations: :vertical_right, :vertical_left, :horizontal_bottom, :horizontal_top.
Checkbox
A boolean toggle with customizable checked and unchecked symbols.
%Checkbox{
label: "Enable notifications",
checked: true,
checked_style: %Style{fg: :green},
checked_symbol: "✓",
unchecked_symbol: "✗"
}TextInput
A single-line text input with cursor navigation and viewport scrolling. This is a stateful widget — its state lives in Rust via ResourceArc.
# Create state (once, e.g. in mount/1 or init/1)
state = ExRatatui.text_input_new()
# Forward key events
ExRatatui.text_input_handle_key(state, "h")
ExRatatui.text_input_handle_key(state, "i")
# Read/set value
ExRatatui.text_input_get_value(state) #=> "hi"
ExRatatui.text_input_set_value(state, "hello")
# Render
%TextInput{
state: state,
style: %Style{fg: :white},
cursor_style: %Style{fg: :black, bg: :white},
placeholder: "Type here...",
placeholder_style: %Style{fg: :dark_gray},
block: %Block{title: "Search", borders: [:all], border_type: :rounded}
}Clear
Resets all cells in its area to empty space characters. This is useful for clearing a region before rendering an overlay on top of existing content.
%Clear{}Markdown
Renders markdown text with syntax-highlighted code blocks, powered by tui-markdown (pulldown-cmark + syntect). Supports headings, bold, italic, inline code, fenced code blocks, bullet lists, links, and horizontal rules.
%Markdown{
content: "# Hello\n\nSome **bold** text and `inline code`.\n\n```elixir\nIO.puts(\"hi\")\n```",
wrap: true,
block: %Block{title: "Response", borders: [:all]}
}Textarea
A multiline text editor with undo/redo, cursor movement, and Emacs-style shortcuts. This is a stateful widget — its state lives in Rust via ResourceArc.
# Create state (once, e.g. in mount/1 or init/1)
state = ExRatatui.textarea_new()
# Forward key events (with modifier support)
ExRatatui.textarea_handle_key(state, "h", [])
ExRatatui.textarea_handle_key(state, "enter", [])
ExRatatui.textarea_handle_key(state, "w", ["ctrl"]) # delete word backward
# Read value
ExRatatui.textarea_get_value(state) #=> "h\n"
# Render
%Textarea{
state: state,
placeholder: "Type your message...",
placeholder_style: %Style{fg: :dark_gray},
block: %Block{title: "Message", borders: [:all], border_type: :rounded}
}Throbber
A loading spinner that animates through symbol sets. The caller controls the animation by incrementing :step on each tick.
%Throbber{
label: "Loading...",
step: state.tick,
throbber_set: :braille,
throbber_style: %Style{fg: :cyan},
block: %Block{title: "Status", borders: [:all]}
}Available sets: :braille, :dots, :ascii, :vertical_block, :horizontal_block, :arrow, :clock, :box_drawing, :quadrant_block, :white_square, :white_circle, :black_circle.
Popup
A centered modal overlay that renders any widget over the parent area, clearing the background underneath. Useful for dialogs, confirmations, and command palettes.
%Popup{
content: %Paragraph{text: "Are you sure?"},
block: %Block{title: "Confirm", borders: [:all], border_type: :rounded},
percent_width: 50,
percent_height: 30
}WidgetList
A vertical list of heterogeneous widgets with optional selection and scrolling. Each item is a {widget, height} tuple, making it ideal for chat message histories and similar layouts where items have different heights.
scroll_offset is a row offset from the top of the content, not an item index. To scroll to a specific item, sum the heights of all preceding items. Items partially above the viewport are clipped row-by-row instead of being dropped entirely.
Migrating from v0.6.1 or earlier:
scroll_offsetused to be an item index. If you were passingscroll_offset: selected, convert by summing the heights of all preceding items, e.g.items |> Enum.take(selected) |> Enum.map(&elem(&1, 1)) |> Enum.sum(). See the v0.6.2 entry in the CHANGELOG for details.
%WidgetList{
items: [
{%Paragraph{text: "User: Hello!"}, 1},
{%Markdown{content: "**Bot:** Hi there!\n\nHow can I help?"}, 4},
{%Paragraph{text: "User: What is Elixir?"}, 1}
],
selected: 1,
highlight_style: %Style{fg: :yellow},
scroll_offset: 0,
block: %Block{title: "Chat", borders: [:all]}
}SlashCommands
SlashCommands is a utility module (not a widget struct) that helps you build a command palette on top of Popup + List. Use parse/1 to detect a /prefix, match_commands/2 to filter your commands, and render_autocomplete/2 to build the popup widgets you append to your render list.
alias ExRatatui.Widgets.SlashCommands
alias ExRatatui.Widgets.SlashCommands.Command
commands = [
%Command{name: "help", description: "Show help"},
%Command{name: "quit", description: "Exit the app"}
]
# In your render/2:
case SlashCommands.parse(input_text) do
{:command, prefix} ->
matched = SlashCommands.match_commands(commands, prefix)
popup_widgets = SlashCommands.render_autocomplete(matched, area: area, selected: 0)
base_widgets ++ popup_widgets
:no_command ->
base_widgets
endSee examples/chat_interface.exs for a full integration.
Focus management
Apps with multiple interactive widgets (e.g., a TextInput + List + details pane) need to track which widget "owns" the current keystroke. Rather than reinventing that bookkeeping every time, use ExRatatui.Focus:
alias ExRatatui.{Event, Focus}
# Declare the focus ring up front, e.g. in mount/2 or init/1.
state = %{
focus: Focus.new([:search, :results, :details]),
search: ExRatatui.text_input_new(),
results: [...],
selected: 0
}Route every key event through Focus.handle_key/2 before dispatching. Tab / Shift+Tab / back_tab are consumed (focus moves, you get nil back). Everything else passes through unchanged.
def handle_event(%Event.Key{} = key, state) do
{focus, key} = Focus.handle_key(state.focus, key)
state = %{state | focus: focus}
case key do
nil ->
state
key ->
case Focus.current(focus) do
:search -> update_search(state, key)
:results -> update_results(state, key)
:details -> update_details(state, key)
end
end
endStyle the focused widget with Focus.focused?/2:
border_style =
if Focus.focused?(focus, :search),
do: %Style{fg: :yellow},
else: %Style{fg: :gray}
%TextInput{
state: state.search,
block: %Block{borders: [:all], border_style: border_style}
}Override the default keys with %Event.Key{} entries — e.g., to add Ctrl+Tab / Ctrl+Shift+Tab or arrow-based cycling:
Focus.new([:search, :results, :details],
next_keys: [%Event.Key{code: "tab"}, %Event.Key{code: "right", modifiers: ["ctrl"]}],
prev_keys: [%Event.Key{code: "back_tab"}, %Event.Key{code: "left", modifiers: ["ctrl"]}]
)See examples/focus_multi_panel.exs for a full three-panel demo.
Examples
examples/widget_showcase.exs— interactive showcase of tabs, progress bars, checkboxes, text input, and scrollable logsexamples/chat_interface.exs— AI chat interface demonstrating markdown, textarea, throbber, popup, and slash commandsexamples/task_manager/— full CRUD app using table, tabs, scrollbar, line gauge, and block compositionsexamples/focus_multi_panel.exs— multi-panel layout with Tab-cycled focus
Related
- Callback Runtime — OTP-style callbacks
- Reducer Runtime — Elm-style commands and subscriptions
- Running TUIs over SSH — SSH transport
- Running TUIs over Erlang Distribution — distribution transport