Quick lookup for every built-in widget and helper. One example per struct, key fields inline. For concepts see Building UIs; for full option shapes, the module docs.
Styles & rich text
Style
%Style{
fg: :green, # or {r,g,b}, or 0..255 index
bg: :reset,
underline_color: {:rgb, 255, 0, 0}, # colored underline (kitty/wezterm)
modifiers: [:bold, :italic, :underlined, :reversed, :dim]
}Colors accept :reset, named atoms, {r, g, b}, or 0–255. Modifiers are additive.
Rich text spans/lines
alias ExRatatui.Text.{Line, Span}
Line.new([
Span.new(" ok ", style: %Style{fg: :green, modifiers: [:bold]}),
Span.new(" build passed", style: %Style{fg: :gray})
])Accepted by Paragraph.text, List.items, Table.rows/header, Tabs.titles, Block.title.
Events
poll_event/1 returns
case ExRatatui.poll_event(timeout) do
%Event.Key{code: "q"} -> :quit
%Event.Mouse{kind: "down", button: "left", x: x, y: y} ->
{:click, x, y}
%Event.Resize{width: w, height: h} -> {:resize, w, h}
%Event.Paste{content: text} -> {:paste, text}
%Event.FocusGained{} -> :resume_animations
%Event.FocusLost{} -> :pause_animations
nil -> :no_event
endEvent.Paste arrives only on the local terminal today (byte-stream transports' VTE parser doesn't decode CSI 200~/201~ yet). Focus events are opt-in: ExRatatui.run(fun, focus_events: true).
Consuming Paste in widgets
case event do
%Event.Paste{content: text} ->
ExRatatui.text_input_insert_str(state.input, text)
# ...
endtext_input_insert_str/2 strips every control character (single-line). textarea_insert_str/2 preserves \n and \r\n as line breaks; lone \r is dropped. Both batch the insert as one NIF call.
Layout
Layout.split/4
alias ExRatatui.Layout
[left, right] =
Layout.split(frame_rect, :horizontal, [
{:percentage, 30},
{:percentage, 70}
])Direction: :horizontal or :vertical. Constraints: :length, :percentage, :min, :max, :ratio, {:fill, weight}.
Flex + spacing + margin
# Centered fixed-width popup
[popup] = Layout.split(area, :horizontal,
[{:length, 40}], flex: :center)
# Two growable panels with a 2-cell gutter
[a, b] = Layout.split(area, :horizontal,
[{:fill, 1}, {:fill, 1}], spacing: 2)
# Split but leave a 1-cell border around the edges
[body] = Layout.split(area, :vertical, [{:min, 0}], margin: 1):flex is one of :legacy, :start, :end, :center, :space_between, :space_around. :spacing is cells between segments; :margin (or :horizontal_margin / :vertical_margin) insets the whole area before splitting.
%Rect{}
%ExRatatui.Layout.Rect{x: 0, y: 0, width: 80, height: 24}All widgets render into a Rect. Returned from Layout.split/4, derived from %Frame{}, or built manually for absolute placement.
Padding helpers
alias ExRatatui.Layout.Padding
%Block{padding: Padding.uniform(1)} # {1, 1, 1, 1}
%Block{padding: Padding.symmetric(2, 1)} # {2, 2, 1, 1} (horiz, vert)
%Block{padding: Padding.horizontal(2)} # {2, 2, 0, 0}
%Block{padding: Padding.vertical(1)} # {0, 0, 1, 1}
%Block{padding: Padding.new(1, 2, 3, 4)} # {1, 2, 3, 4}Block.padding is a {left, right, top, bottom} tuple; these just build it.
Focus & theming
Focus ring
alias ExRatatui.{Focus, Event}
# Build the ring once.
focus = Focus.new([:search, :results, :details])
# Register hit-test regions after layout (typically on resize).
focus =
focus
|> Focus.set_region(:search, search_rect)
|> Focus.set_regions(%{results: results_rect, details: details_rect})
# Route keys: Tab / Shift+Tab move focus, everything else passes through.
{focus, key} = Focus.handle_key(focus, key_event)
# Route mouse: left-click in a region focuses that ID + passes event through.
{focus, mouse} = Focus.handle_mouse(focus, mouse_event)
# Style off the focused ID — Focus never touches widget structs.
border = if Focus.focused?(focus, :search),
do: %Style{fg: :yellow},
else: %Style{fg: :gray}Focus.at(focus, x, y) returns the ID under a point (smallest region wins on overlap). Scroll routing is intentionally not built in — pick Focus.current/1 for "scroll the focused widget" or Focus.at/3 for "scroll the widget under the cursor".
Theme palette
alias ExRatatui.Theme
theme = Theme.default() # dark-friendly; surface nil
# or Theme.light() # dark text on white surface
# or %Theme{primary: :magenta, accent: {:rgb, 245, 158, 11}, ...}
%Block{
borders: [:all],
border_style: Theme.border_style(theme, focused: focused?)
}
%List{
items: results,
highlight_style: Theme.selection_style(theme)
}
# Body text + dim hint
Theme.text_style(theme) # %Style{fg: theme.text, bg: theme.surface}
Theme.text_style(theme, dim: true) # %Style{fg: theme.text_dim, ...}Eleven semantic slots: :primary, :accent, :border, :border_focused, :surface, :surface_alt, :text, :text_dim, :success, :warning, :danger. Each accepts any ExRatatui.Style.color/0. Apps thread the theme through render code by hand; no global config, no automatic widget injection.
Text & content
Paragraph
%Paragraph{
text: "Hello!", # String | Span | Line | [Span]
style: %Style{fg: :green},
alignment: :center, # :left | :center | :right
wrap: true,
scroll: {0, 0}, # {vertical, horizontal}
block: %Block{title: " Info ", borders: [:all]}
}Markdown
%Markdown{
content: "# Title\n\n**bold** *italic* `code`\n\n- a\n- b",
wrap: true,
scroll: {0, 0},
block: %Block{borders: [:all]}
}Supports headings, bold/italic, inline code, lists.
BigText
ExRatatui.BigText.new("HELLO",
pixel_size: :quadrant, # :full | :half_height | :half_width
# :quadrant | :third_height | :sextant
# :quarter_height | :octant
alignment: :center, # :left | :center | :right
style: %Style{fg: :magenta},
block: %Block{borders: [:all]}
)Oversized 8×8-pixel text for slide titles / banners. Smaller pixel_size packs more characters into the same area; :octant is the densest. Backed by tui-big-text.
CodeBlock
%ExRatatui.Widgets.CodeBlock{
content: source_code,
language: "rust", # nil = plain text fallback
theme: :base16_ocean_dark, # 7 curated atoms or any raw string
line_numbers: true,
starting_line: 1,
highlight_lines: [3, 7..9] # ints + ranges, normalised
}Syntax-highlighted source code, display-only. Themes (curated atoms):
:base16_ocean_dark, :base16_ocean_light, :base16_eighties_dark,
:base16_mocha_dark, :inspired_github, :solarized_dark, :solarized_light.
Raw strings pass through for custom theme sets. Languages: syntect's bundled
SyntaxSet (Rust, Python, JS, Ruby, Go, Java, JSON, YAML, Erlang, etc.) plus
Elixir which we ship as an additional bundled syntax.
Raw highlighted lines for composite widgets:
ExRatatui.CodeBlock.highlight("fn main() {}", "rust", :solarized_dark)
# => [%ExRatatui.Text.Line{spans: [...]}, ...]Lists, tables & scrolling
List
%List{
items: ["Item 1", "Item 2", "Item 3"],
selected: 0,
highlight_style: %Style{fg: :yellow, modifiers: [:bold]},
highlight_symbol: ">> ",
direction: :bottom_to_top, # default :top_to_bottom (chat logs)
scroll_padding: 2, # default 0; vim's scrolloff
repeat_highlight_symbol: true, # default false; multi-row items
block: %Block{title: " Menu ", borders: [:all]}
}Table
%Table{
header: ["Name", "Score"],
footer: ["Total", "182"], # optional, renders at bottom
rows: [["Alice", "95"], ["Bob", "87"]],
widths: [{:percentage, 70}, {:percentage, 30}],
selected: 0, # row index
selected_column: 1, # required to fire column/cell styles below
highlight_style: %Style{fg: :yellow}, # row highlight
header_style: %Style{modifiers: [:bold]},
footer_style: %Style{fg: :gray},
column_highlight_style: %Style{bg: :dark_gray},
cell_highlight_style: %Style{fg: :black, bg: :yellow},
highlight_spacing: :always, # :always | :when_selected (default) | :never
column_spacing: 1,
block: %Block{borders: [:all]}
}Without :selected_column set, :column_highlight_style and :cell_highlight_style are no-ops (the activation gate is ratatui's TableState::select_column/1).
WidgetList
%WidgetList{
items: [
{%Paragraph{text: "Row 1"}, 3}, # {widget, height}
{%Paragraph{text: "Row 2"}, 5}
],
selected: 0,
scroll_offset: 0,
highlight_style: %Style{bg: {50, 50, 50}}
}Row-clipped scroll. Good for 1k+ items; only visible rows cross the NIF boundary.
Scrollbar
%Scrollbar{
orientation: :vertical_right, # or :vertical_left, :horizontal_bottom, :horizontal_top
content_length: 100,
position: 25,
viewport_content_length: 20,
thumb_style: %Style{fg: :yellow}
}Progress & activity
Gauge
%Gauge{
ratio: 0.65, # 0.0..1.0
label: "65%",
gauge_style: %Style{fg: :green},
block: %Block{borders: [:all]}
}LineGauge
%LineGauge{
ratio: 0.4,
label: "Loading...",
filled_style: %Style{fg: :blue},
unfilled_style: %Style{fg: :dark_gray}
}Single-cell-tall bar — great for compact status rows.
Sparkline
%Sparkline{
data: [0, 1, 3, 5, 8, nil, 2, 4], # nil = absent
max: 8, # nil auto-scales
direction: :left_to_right,
bar_set: :nine_levels,
style: %Style{fg: :cyan}
}Throbber
%Throbber{
label: "Loading",
throbber_set: :braille, # :braille | :dots | :line | :ascii
step: state.tick, # caller increments
throbber_style: %Style{fg: :cyan}
}Drive with Subscription.interval/3 (reducer) or Process.send_after/3 (callback).
BarChart
alias ExRatatui.Widgets.Bar
%BarChart{
data: [
%Bar{label: "Mon", value: 42},
%Bar{label: "Tue", value: 67, style: %Style{fg: :yellow}}
],
bar_width: 5,
bar_gap: 1,
direction: :vertical, # or :horizontal
max: 100 # nil auto-scales
}Use :groups with %BarGroup{} for side-by-side clusters (mutually exclusive with :data).
Input
TextInput
state = ExRatatui.text_input_new() # mount once, keep in app state
%TextInput{
state: state,
placeholder: "Type here...",
style: %Style{fg: :white},
cursor_style: %Style{bg: :white, fg: :black},
block: %Block{borders: [:all]}
}
ExRatatui.text_input_handle_key(state, key_event)
value = ExRatatui.text_input_get_value(state)Textarea
state = ExRatatui.textarea_new()
%Textarea{
state: state,
placeholder: "Enter message...",
cursor_line_style: %Style{bg: {40, 40, 40}},
line_number_style: %Style{fg: :dark_gray}, # nil hides line numbers
block: %Block{borders: [:all]}
}
ExRatatui.textarea_handle_key(state, key_event)
value = ExRatatui.textarea_get_value(state)Enter = newline, Ctrl+Enter = submit.
Checkbox
%Checkbox{
label: "Enable notifications",
checked: true,
checked_style: %Style{fg: :green},
checked_symbol: "[x]",
unchecked_symbol: "[ ]"
}SlashCommands
alias ExRatatui.Widgets.SlashCommands
alias ExRatatui.Widgets.SlashCommands.Command
commands = [
%Command{name: "help", description: "Show help", aliases: ["h", "?"]},
%Command{name: "quit", description: "Exit", aliases: ["q"]}
]
case SlashCommands.parse(input_text) do
{:slash, query} -> SlashCommands.match_commands(commands, query)
:no_slash -> []
endPair the matches with a %Popup{} in render/2 to show autocomplete.
Charts & canvas
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}],
marker: :braille,
graph_type: :line, # :line | :scatter | :bar
style: %Style{fg: :cyan}
}
],
x_axis: %Axis{title: "Time", bounds: {0.0, 3.0}, labels: ["0", "1", "2", "3"]},
y_axis: %Axis{title: "Usage %", bounds: {0.0, 100.0}, labels: ["0", "50", "100"]},
legend_position: :top_right # nil hides
}Canvas
alias ExRatatui.Widgets.Canvas.{Circle, Label, Line, Points, Rectangle}
%Canvas{
x_bounds: {0.0, 100.0},
y_bounds: {0.0, 50.0},
marker: :braille, # :braille | :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}], color: :green},
%Label{x: 70.0, y: 25.0, text: "★", color: :white}
]
}World map
alias ExRatatui.Widgets.Canvas.Map, as: CanvasMap
%Canvas{
x_bounds: {-180.0, 180.0},
y_bounds: {-90.0, 90.0},
marker: :dot,
shapes: [
%CanvasMap{resolution: :high, color: :green},
%Label{x: -74.0, y: 40.7, text: "NYC", color: :yellow}
]
}Containers & overlays
Block
%Block{
title: " Panel ", # String | Span | Line | [Span]
borders: [:all], # or [:top, :left, ...]
border_type: :rounded, # :plain :rounded :double :thick
# :light/heavy_{double,triple,quadruple}_dashed
# :quadrant_inside :quadrant_outside
border_style: %Style{fg: :cyan},
padding: {0, 0, 0, 0} # {left, right, top, bottom}
}Wraps another widget via that widget's :block field.
Block multi-title
alias ExRatatui.Widgets.Block.Title
%Block{
title: "src/lib.rs", # top-left, single-title shortcut
titles: [
%Title{content: "[3/12]", alignment: :right},
%Title{content: "modified", position: :bottom, alignment: :left},
%Title{content: "L42:7", position: :bottom, alignment: :right}
],
title_position: :top, # default for titles without one
title_alignment: :left, # default alignment
title_style: %Style{fg: :gray}, # default style
borders: [:all]
}Each %Block.Title{} carries optional :position, :alignment, :style overrides over the block defaults. Raw line-like entries in :titles (e.g. titles: ["status"]) inherit the defaults.
Popup
%Popup{
content: %Paragraph{text: "Are you sure?"},
percent_width: 50, # or fixed_width: 40
percent_height: 30, # or fixed_height: 10
block: %Block{title: " Confirm ", borders: [:all], border_type: :double}
}Renders centered on top of whatever's already drawn.
Tabs
%Tabs{
titles: ["Overview", "Details", "Settings"],
selected: 0,
highlight_style: %Style{fg: :yellow, modifiers: [:bold]},
divider: " | ",
padding: {1, 1},
block: %Block{borders: [:bottom]}
}Clear
%Clear{}Fills the rect with blank cells. Use before a popup to dim underlying content.
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}, # nil hides surrounding days
block: %Block{title: " March ", borders: [:all]}
}Needs ~22×8 cells (24×10 with a block). events accepts a keyword-like list of {%Date{}, %Style{}} tuples or a %{Date => Style} map.
Image
{:ok, picture} = ExRatatui.Image.new(File.read!("priv/cover.png"),
protocol: :auto, # or :halfblocks / :kitty / :sixel / :iterm2
resize: :fit, # or :crop / :scale
background: {0, 0, 0} # nil for transparent
)
# Drop into a render tree like any other widget:
[{picture, area}]Hold the handle in your model (decode is non-trivial — don't rebuild per frame). :auto adapts to the transport: Kitty/Ghostty get native graphics, Livebook gets halfblocks. Errors return {:error, {:decode_failed, msg}}.
Probe the local terminal for best protocol detection:
ExRatatui.Image.auto_local_protocol(terminal_ref)Over SSH or Distributed, declare both the protocol and the client's cell pixel size (needed for accurate Kitty/Sixel/iTerm2 scaling):
ExRatatui.SSH.Daemon.start_link(
mod: MyApp.TUI,
image_protocol: :kitty,
image_font_size: {10, 20}
)
ExRatatui.Distributed.attach(:"app@host", MyApp.TUI,
image_protocol: :kitty,
image_font_size: {10, 20}
)See Images for the full transport / protocol resolution table.