guides, terra

The Terra tutorial

A hands-on walkthrough of building terminal apps with Terra: a counter, a tick-driven spinner, a keyboard menu, layout, styling, and headless tests.

Everything here runs with Elixir 1.18+ on OTP 28+ and no runtime dependencies.

If you only want the short version, read guides/getting_started.md. This page goes deeper and every example is a file you can run from the repo.

1. Run the examples

From the repo root:

mix run examples/counter.exs # j/k to change, q to quit
mix run examples/tick.exs # a spinner that advances every 100 ms, q to quit
mix run examples/menu.exs # arrow keys via j/k or ↑/↓, Enter to select, q/Esc to quit

Each example takes over the terminal, draws a frame, reacts to keys, and puts your shell back when you quit. Run them in a real terminal (mix run), not inside IEx.

The app modules live in *_app.exs next to the runner so tests can load them without a terminal.

2. The loop in five minutes

A Terra app is a module implementing three callbacks. use Terra marks it as an app and imports the layout primitives.

defmodule Counter do
use Terra
# Called once. Return the initial state.
def init(_opts), do: 0
# Called for every key. Return the next state.
def update({:char, "j"}, count), do: count + 1
def update({:char, "k"}, count), do: count - 1
def update({:char, "q"}, count), do: {:quit, count}
def update(_event, count), do: count
# Called after every update. Return the screen as data.
def view(count) do
box([
text("Count: #{count}", fg: :green),
text("j/k to change, q to quit", dim: true)
])
end
end
Terra.run(Counter)

Run it:

mix run examples/counter.exs

What happens:

  1. Terra.run/1 takes the terminal (raw mode, alternate screen, hidden cursor).
  2. init/1 returns 0.
  3. view/1 is rendered to a cell grid and painted.
  4. Each keypress becomes an event, update/2 produces a new state, and the frame is redrawn.
  5. {:quit, state} stops the loop, restores the terminal, and run/1 returns {:ok, state}.

State can be anything: an integer, a map, a struct. update/2 must stay pure (no file or network IO); put effects in commands instead (section 5).

3. Events

Keys arrive as a small event union. You never match on raw escape bytes.

:up | :down | :left | :right arrow keys (CSI or SS3)
:enter | :tab | :backspace
:esc a lone Escape, after a short timeout
:interrupt Ctrl+C
{:char, "a"} one printable grapheme
{:ctrl, :a} a Ctrl+letter combination

Terra buffers partial input, so this is safe:

Bytes that never form a documented event (an unknown escape sequence, an Alt-modified key) are dropped.

4. Mapping events to messages

update/2 can match runtime events directly, but most apps want their own message names. Define event_to_msg/2 and return either a message or :ignore.

defmodule Menu do
use Terra
def event_to_msg({:char, "j"}, _state), do: :down
def event_to_msg({:char, "k"}, _state), do: :up
def event_to_msg(:enter, _state), do: :select
def event_to_msg({:char, "q"}, _state), do: :quit
def event_to_msg(:esc, _state), do: :quit
def event_to_msg(event, _state) when event in [:up, :down], do: event
def event_to_msg(_event, _state), do: :ignore
end

:ignore drops the event: update/2 never sees it. The default event_to_msg/2 (from use Terra) passes events through unchanged, so simple apps can skip this entirely.

5. Commands and ticks

update/2 may return {state, commands} to keep running and schedule work. Commands are data, not functions:

{:tick, ms, msg}

After ms milliseconds the runtime delivers msg to update/2.

defmodule Spinner do
use Terra
@frames ["|", "/", "-", "\\"]
@tick_ms 100
def init(_opts), do: {0, [{:tick, @tick_ms, :tick}]}
def update(:tick, ticks), do: {ticks + 1, [{:tick, @tick_ms, :tick}]}
def update({:char, "q"}, ticks), do: {:quit, ticks}
def update(_event, ticks), do: ticks
def view(ticks) do
frame = Enum.at(@frames, rem(ticks, length(@frames)))
box([text("#{frame} working", fg: :cyan), text("q to quit", dim: true)])
end
end

Returning a fresh {:tick, ...} on every tick is how you build a repeating timer. You can schedule several at once, and init/1 can return commands too (used above so the spinner starts immediately).

The three allowed update/2 return shapes are:

Return Meaning
state keep running, state unchanged or updated
{:quit, state} stop the loop and restore
{state, commands} keep running and schedule commands

There is no {:noreply, state}; just return the state.

6. Layout

view/1 returns data. Terra measures each primitive, places it into the available area, paints a cell grid, and clips anything that does not fit. There is no flexbox and no wrapping: overflow is dropped.

A root box grows to fill the area it is given, so an unsized box at the root occupies the whole screen. Give it :width/:height when you want it compact.

box(
vstack([
text("Title", bold: true),
hstack([text("left"), text(" right")]),
text("\nmulti\nline")
]),
padding: 1
)

Primitives:

Options:

Option Applies to Meaning
:width, :height all fixed size, overriding the natural size
:gap stacks blank cells between children (default 0)
:align vstack, box :left, :center, :right
:align hstack :top, :center, :bottom
:valign box :top, :center, :bottom
:padding box integer, or [top:, right:, bottom:, left:]
:border box false disables the single-line border

Sizes are in terminal cells, and wide graphemes (CJK, emoji) correctly take two columns.

Natural sizing example:

Terra.View.measure(box(text("hi"))) #=> {4, 3}
Terra.View.measure(box(text("hi"), padding: 1)) #=> {6, 5}
Terra.View.measure(vstack([text("a"), text("b")], gap: 1)) #=> {1, 3}

7. Styling

Pass a keyword list to text/2:

text("danger", fg: :red, bold: true)
text("muted", dim: true)
text("selected", reverse: true)
text("link", fg: :cyan, underline: true)
text("256-colour", fg: 208) # any 0..255 palette index

Supported keys: :fg, :bg (named colour or 0..255), :bold, :dim, :italic, :underline, :reverse. Unknown keys are ignored rather than crashing a frame.

Named colours: :black, :red, :green, :yellow, :blue, :magenta, :cyan, :white, and the same with a :bright_ prefix.

8. A menu with arrow keys

examples/menu_app.exs combines everything: a small state machine, event mapping, arrow keys, and selection highlighting.

defmodule Menu do
use Terra
@items [{"Start", :start}, {"Settings", :settings}, {"Quit", :quit}]
def init(_opts), do: %{index: 0, chosen: nil}
def event_to_msg({:char, "j"}, _state), do: :down
def event_to_msg({:char, "k"}, _state), do: :up
def event_to_msg(:enter, _state), do: :select
def event_to_msg({:char, "q"}, _state), do: :quit
def event_to_msg(:esc, _state), do: :quit
def event_to_msg(event, _state) when event in [:up, :down], do: event
def event_to_msg(_event, _state), do: :ignore
def update(:down, state), do: %{state | index: rem(state.index + 1, length(@items))}
def update(:up, state), do: %{state | index: rem(state.index - 1 + length(@items), length(@items))}
def update(:select, state) do
case Enum.at(@items, state.index) do
{"Quit", :quit} -> {:quit, state}
{label, action} -> %{state | chosen: {label, action}}
end
end
def update(:quit, state), do: {:quit, state}
def update(_msg, state), do: state
def view(state) do
rows =
@items
|> Enum.with_index()
|> Enum.map(fn {{label, _action}, index} ->
if index == state.index do
text("› " <> label, reverse: true)
else
text(" " <> label, dim: true)
end
end)
box(vstack(rows), padding: [top: 0, right: 2, bottom: 0, left: 1])
end
end

Run it with mix run examples/menu.exs. Use j/k, the actual arrow keys, or ↑/↓; press Enter to select; q or Esc to quit.

Note how event_to_msg/2 normalizes both "j" and :down into the same :down message, so update/2 only deals in your domain language.

9. Testing without a terminal

Terra.Test drives an app headlessly: snapshot rendering plus simulated keys. This is a first-class feature, not an afterthought, so no PTY or TTY is needed in CI.

Render a module or a view

Terra.Test.render(Counter, width: 26, height: 4)
#=> "┌────────────────────────┐\n│Count: 0 │\n│j/k to change, q to quit│\n└────────────────────────┘"
Terra.Test.render(Terra.View.text("hi"), width: 8, height: 2)
#=> "hi"

Drive a machine

machine = Terra.Test.start(Counter)
Terra.Test.send_keys(machine, "jj")
Terra.Test.state(machine) #=> 2
Terra.Test.render(machine) #=> "…Count: 2…"
Terra.Test.send_keys(machine, [:up]) # a single runtime event works too
Terra.Test.send_keys(machine, "k")
Terra.Test.state(machine) #=> 1

send_keys/2 accepts a string (each grapheme → {:char, g}), a single event like :up or {:ctrl, :a}, or a list of either. It returns the machine, so it pipes:

snapshot =
Counter
|> Terra.Test.start(width: 40, height: 5)
|> Terra.Test.send_keys("jj")
|> Terra.Test.render()

Assert on snapshots in ExUnit:

defmodule CounterTest do
use ExUnit.Case, async: true
test "j increments and q quits" do
machine = Terra.Test.start(Counter)
assert machine |> Terra.Test.send_keys("jj") |> Terra.Test.render() =~ "Count: 2"
ref = Process.monitor(machine)
Terra.Test.send_keys(machine, "q")
assert_receive {:DOWN, ^ref, :process, ^machine, :normal}
end
end

Ticks work headlessly too:

machine = Terra.Test.start(Spinner)
Process.sleep(250)
assert Terra.Test.state(machine) >= 2
Terra.Test.stop(machine)

Terra.Test.start/2 forwards options to Terra.Runtime, so :width, :height and :app (options for init/1) are available.

10. The restore contract

Terra owns the terminal only while the app runs, and it gives it back on every path it can observe:

Path Result
update/2 returns {:quit, state} restore, run/1 returns {:ok, state}
Ctrl+C / :interrupt restore, loop stops
init/1, update/2 or view/1 raises restore first, then the original error is re-raised
the process that called run/1 dies restore
the runtime is killed (:kill) restore via the monitored terminal owner

After each path: cooked input mode, visible cursor, main screen, and your shell is usable.

Crash handling means the error is never swallowed:

assert_raise RuntimeError, "boom from view/1", fn ->
Terra.run(BrokenApp)
end
# the terminal is already restored when the exception reaches you

Missing callbacks are a startup error, not a mystery:

Terra.run(NotAnApp)
# ** (ArgumentError) Terra app NotAnApp is missing required callbacks: init/1, update/2, view/1.

11. Gotchas and current limits

12. Widgets, focus, and themes

Terra.Widget renders the small pieces a form needs, and the state stays in your app. Each widget has an event helper that returns documented values:

def view(state) do
box(
vstack([
Widget.input(state.draft, cursor: state.cursor, focused: state.focus == :input),
Widget.list(state.todos, selected: state.selected, height: 5)
]),
title: "Todos",
border: :rounded
)
end
def event_to_msg(event, state) do
case Focus.event(event, Focus.ids(view(state)), state.focus) do
{:focus, id} -> {:focus, id}
:ignore -> event
end
end

Boxes also take a :title and a :border style (:single, :double, :rounded, :thick, :ascii, or false). examples/todo.exs uses input + list + focus, and examples/pomodoro.exs uses ticks + progress + spinner.

Where to go next