Packages

Zero-dependency TUI for Elixir. Elm-style init/update/view, cell-grid diffs, and a restore contract that always gives the terminal back. OTP 28+.

Current section

Files

Jump to
terra README.md
Raw

README.md

# Terra

[![Hex.pm](https://img.shields.io/hexpm/v/terra.svg)](https://hex.pm/packages/terra)
[![Hexdocs](https://img.shields.io/badge/hex-docs-lightgreen.svg)](https://hexdocs.pm/terra)
[![License](https://img.shields.io/hexpm/l/terra.svg)](https://github.com/uminocelo/terra/blob/main/LICENSE)

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.1`. 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

- Elixir 1.18 or newer
- OTP 28 or newer

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

## Installation

```elixir
def deps do
  [
    {:terra, "~> 1.1"}
  ]
end
```

## The Counter app

```elixir
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](https://github.com/uminocelo/manto)
static build, driven from this repo. Clone Manto next to this one and run:

```bash
./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:

```elixir
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:

- `:quit` returned from `update/2`
- `Ctrl+C` / `:interrupt`
- a crash in `init/1`, `update/2`, or `view/1`
- the process that started `Terra.run/1` dying, or the runtime being killed

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.

[![Ctrl+C leaves a shell that can echo](docs/restore.gif)](docs/restore.gif)

## 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

- No NIFs and no runtime dependencies
- No widget kitchen sink
- No mouse, flexbox engine, SSH, or nested-component runtime

## License

MIT