Terra

A zero-dependency TUI library for Elixir built around an Elm-style init / update / view loop that always gives your terminal back.

Status: version 1.1.0. The terminal layer, input parser, renderer, run loop, headless test helpers, differential painting, widgets, focus and themes are all in place, and update/2 can return file and port commands so IO stays out of view/1.

Requirements

Terra ships no runtime dependencies: no NIFs, no port drivers, no widget framework.

Installation

def deps do
  [
    {:terra, "~> 1.0"}
  ]
end

The Counter app

defmodule Counter do
  use Terra

  def init(_opts), do: 0

  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

  def view(count) do
    box([text("Count: #{count}"), text("j/k to change, q to quit")])
  end
end

Terra.run(Counter)

Run it with mix run examples/counter.exs, or see examples/todo.exs and examples/pomodoro.exs for the widgets. examples/keys.exs debugs input by showing the last 20 parsed events, and examples/test_watcher.exs runs mix test as a port command and lists failures. guides/getting_started.md is the one-page walkthrough, guides/tutorial.md goes deeper, and guides/effects.md covers commands and the watcher.

The guides are published at https://uminocelo.github.io/terra and at https://hexdocs.pm/terra.

Building the guides site

The published guides are the output of a Manto static build, driven from this repo. Clone Manto next to this one and run:

./scripts/build_guides.sh            # writes dist/, reads manto.json
MANTO_DIR=~/code/manto ./scripts/build_guides.sh

.github/workflows/pages.yml runs the same script on every push to main and deploys dist/ to GitHub Pages.

How apps run

Each frame is rendered into a cell grid and diffed against the previous one, so only changed cells are written. update/2 can return commands as data: {:tick, ms, msg} schedules a message, {:read_file, path, msg} reads a file, and {:port, cmd, msg} runs a shell command. The runtime executes them off the view path and delivers the result to update/2 as {msg, {:ok, data} | {:error, reason}}, so view/1 stays pure. The runtime feeds resize events back as {:resize, w, h}.

Terra.Widget provides stateless list, progress, spinner and text input views whose state stays in your app, Terra.Focus handles Tab order, and Terra.Theme supplies fg / bg / accent / border colors that views read at render time.

Keys arrive as small runtime events such as {:char, "j"}, :up, or :interrupt, not as raw bytes. Map them to your own messages with event_to_msg/2, or let them pass through.

Running inside IEx

Not supported. Run apps as a script with mix run path/to/app.exs (or elixir path/to/app.exs).

Under iex -S mix, stdin belongs to the IEx shell process rather than the app. Terra's raw-mode and alternate-screen setup, and the restore contract, assume it owns the terminal, so rendering and restore are not guaranteed there. A TTY fallback for IEx may come later.

Headless testing needs no terminal at all:

machine = Terra.Test.start(Counter)
machine |> Terra.Test.send_keys("jj") |> Terra.Test.render()
#=> "┌────────────────────────\n│Count: 2                │\n│j/k to change, q to quit│\n└────────────────────────┘"

Restore contract

Terra owns the terminal only while your app runs, and it puts the terminal back on every exit path:

After each path the terminal is in cooked mode, the cursor is visible, and you are back on the main screen. If a callback raises, Terra restores first and then lets the error surface; it is never swallowed.

How it fits next to TermUI and Tuix

Terra is deliberately small. TermUI and Tuix are full widget frameworks with mature component sets; if you need tables, forms, charts and mouse support today, use them.

Terra is for the case where you want a handful of screens, an Elm-shaped loop, no runtime dependencies, and a restore contract you can rely on. Widgets are functions of your parent state rather than nested components, and the whole surface is init / update / view plus a few helpers.

Non-goals

License

MIT