# Tela
A zero-dependency Elixir library for building interactive terminal UIs using the
[Elm Architecture](https://guide.elm-lang.org/architecture/).
```
args
│
▼
init/1
│
▼
┌─────model─────┐
│ │
▼ │
view/1 handle_event/2 ◄── key events from stdin
│ handle_info/2 ◄── cmd results, timer ticks, external messages
│ │
▼ ▼
Frame.t() {new_model, cmd}
│
▼
rendered to stdout (diff only)
```
Inspired by [Bubble Tea](https://github.com/charmbracelet/bubbletea). Designed to feel familiar
to Elixir developers through a callback interface modelled on `GenServer` and `Phoenix.LiveView`.
## Features
- **Zero runtime dependencies** — built entirely on OTP 28 stdlib
- **Plain library** — no `Application` callback, no hidden processes; you own supervision
- **Pure callbacks** — `init/1`, `handle_event/2`, `handle_info/2`, and `view/1` are pure
functions; test your UI logic without starting a terminal
- **Diff rendering** — only changed lines are written to stdout
- **Composable styles** — ANSI colours, bold, italic, borders via `Tela.Style`
- **Built-in components** — `Tela.Component.Spinner` and `Tela.Component.TextInput`
## Requirements
- Elixir `~> 1.19`
- OTP 28 (uses `shell.start_interactive/1` for raw terminal mode, introduced in OTP 28)
## Installation
```elixir
def deps do
[
{:tela, "~> 0.1"}
]
end
```
## Quick start
```elixir
defmodule Counter do
use Tela
@impl Tela
def init(_args), do: {0, nil}
@impl Tela
def handle_event(count, %Tela.Key{key: {:char, "k"}}), do: {count + 1, nil}
def handle_event(count, %Tela.Key{key: {:char, "j"}}), do: {count - 1, nil}
def handle_event(count, %Tela.Key{key: {:char, "q"}}), do: {count, :quit}
def handle_event(count, _key), do: {count, nil}
@impl Tela
def handle_info(count, _msg), do: {count, nil}
@impl Tela
def view(count) do
Tela.Frame.new("Count: #{count}\n\nk = increment j = decrement q = quit")
end
end
{:ok, final_count} = Tela.run(Counter, [])
IO.puts("Final count: #{final_count}")
```
Run it:
```sh
mix run -e "Tela.run(Counter, [])"
```
## Callbacks
### `init/1`
```elixir
@callback init(args :: term()) :: {model :: term(), Tela.cmd()}
```
Called once at startup. Returns `{initial_model, cmd}`. Use `{:task, fun}` to kick off background
work, or `nil` for no side effect.
### `handle_event/2`
```elixir
@callback handle_event(model :: term(), key :: Tela.Key.t()) :: {term(), Tela.cmd()}
```
Called for every keystroke from stdin. Must be pure — no side effects.
### `handle_info/2`
```elixir
@callback handle_info(model :: term(), msg :: term()) :: {term(), Tela.cmd()}
```
Called for cmd results, timer ticks, and any message sent to the runtime process via
`Process.send/2`. Must be pure.
### `view/1`
```elixir
@callback view(model :: term()) :: Tela.Frame.t()
```
Called after every update. Returns a `Tela.Frame.t()`. The runtime diffs the content against the
previous frame and writes only changed lines.
## Commands
```elixir
@type cmd :: nil | :quit | {:task, (() -> term())}
```
- `nil` — no side effect
- `:quit` — stop the runtime and restore the terminal
- `{:task, fun}` — run `fun` in a separate process; its return value is delivered to
`handle_info/2`
## Keys
Every keystroke arrives as a `%Tela.Key{key: key, raw: binary()}`. Pattern match on `key`:
```elixir
# Printable characters
%Tela.Key{key: {:char, "a"}}
# Control keys
%Tela.Key{key: {:ctrl, "c"}}
%Tela.Key{key: {:alt, "f"}}
# Named keys
%Tela.Key{key: :enter}
%Tela.Key{key: :backspace}
%Tela.Key{key: :up}
%Tela.Key{key: :down}
%Tela.Key{key: :left}
%Tela.Key{key: :right}
%Tela.Key{key: :escape}
%Tela.Key{key: :tab}
%Tela.Key{key: :shift_tab}
%Tela.Key{key: :home}
%Tela.Key{key: :end}
%Tela.Key{key: :page_up}
%Tela.Key{key: :page_down}
%Tela.Key{key: {:f, 1}} # F1–F12
# Unknown byte sequences
%Tela.Key{key: :unknown}
```
> **Note:** Always match `{:char, "q"}`, never a bare `"q"`. The latter will never match.
## Frames and layout
`Tela.Frame.new/1` wraps a string (lines separated by `\n`) into a frame. Frames compose
vertically with `Frame.join/2`:
```elixir
alias Tela.Frame
header = Frame.new("My App\n")
body = Frame.new("Content here")
footer = Frame.new("\n\nq to quit")
frame = Frame.join([header, body, footer], separator: "")
```
`Frame.join/2` adjusts cursor row offsets automatically, so components that expose a cursor
position remain correct when embedded in a larger layout.
### Real terminal cursor
Pass a `cursor:` option to `Frame.new/2` to position the real terminal cursor:
```elixir
Frame.new("Hello", cursor: {0, 3, :block})
# row col shape
```
Shapes: `:block`, `:bar`, `:underline`. Use `nil` (the default) to hide the cursor.
## Styles
`Tela.Style` produces composable ANSI style structs. All functions are pure.
```elixir
alias Tela.Style
style =
Style.new()
|> Style.bold()
|> Style.foreground(:cyan)
|> Style.background(:black)
|> Style.border(:rounded)
|> Style.padding(1, 2)
Style.render(style, "Hello, world!")
```
**Text attributes:** `bold/1`, `dim/1`, `italic/1`, `underline/1`, `strikethrough/1`, `reverse/1`
**Colours:** `:black`, `:red`, `:green`, `:yellow`, `:blue`, `:magenta`, `:cyan`, `:white`,
`bright_` variants (e.g. `:bright_cyan`), and `:default`
**Borders:** `:single`, `:double`, `:rounded`, `:thick`
**Padding:** `padding(style, all)`, `padding(style, vertical, horizontal)`,
`padding(style, top, right, bottom, left)`
Use `Style.width/1` to measure the visible width of a styled string (strips ANSI escapes).
## Components
### Spinner
```elixir
alias Tela.Component.Spinner
defmodule Loading do
use Tela
@impl Tela
def init(_) do
spinner = Spinner.init(spinner: :dot)
{%{spinner: spinner, done: false}, Spinner.tick_cmd(spinner)}
end
@impl Tela
def handle_event(model, %Tela.Key{key: {:char, "q"}}), do: {model, :quit}
def handle_event(model, _key), do: {model, nil}
@impl Tela
def handle_info(model, msg) do
{spinner, cmd} = Spinner.handle_tick(model.spinner, msg)
{%{model | spinner: spinner}, cmd}
end
@impl Tela
def view(model) do
Tela.Frame.new(Spinner.view(model.spinner).content <> " Loading... q to quit")
end
end
```
**Presets:** `:line`, `:dot`, `:mini_dot`, `:jump`, `:pulse`, `:points`, `:globe`, `:moon`,
`:monkey`, `:meter`, `:hamburger`, `:ellipsis`
Custom spinner: pass `spinner: {frames_list, interval_ms}`.
The parent owns the tick loop. Call `Spinner.tick_cmd/1` from `init/1` and re-arm from
`handle_info/2` by passing the result of `Spinner.handle_tick/2` as your cmd. Stale ticks
(arriving after a spinner is replaced) are silently dropped.
### TextInput
```elixir
alias Tela.Component.TextInput
alias Tela.Frame
defmodule Search do
use Tela
@impl Tela
def init(_) do
input = TextInput.init(placeholder: "Search...", char_limit: 100) |> TextInput.focus()
{%{input: input}, TextInput.blink_cmd(input)}
end
@impl Tela
def handle_event(model, %Tela.Key{key: :escape}), do: {model, :quit}
def handle_event(model, key) do
{input, cmd} = TextInput.handle_event(model.input, key)
{%{model | input: input}, cmd}
end
@impl Tela
def handle_info(model, msg) do
{input, cmd} = TextInput.handle_blink(model.input, msg)
{%{model | input: input}, cmd}
end
@impl Tela
def view(model) do
Frame.join(
[Frame.new("Query:\n\n"), TextInput.view(model.input), Frame.new("\n\nesc to quit")],
separator: ""
)
end
end
{:ok, model} = Tela.run(Search, [])
IO.puts("Searched for: #{TextInput.value(model.input)}")
```
**Key bindings:**
| Key | Action |
|---|---|
| `{:char, c}` | Insert character |
| `:backspace` / `{:ctrl, "h"}` | Delete before cursor |
| `:delete` / `{:ctrl, "d"}` | Delete at cursor |
| `{:ctrl, "k"}` | Delete to end of line |
| `{:ctrl, "u"}` | Delete to start of line |
| `{:ctrl, "w"}` | Delete word backward |
| `{:alt, "d"}` | Delete word forward |
| `:left` / `{:ctrl, "b"}` | Move left one character |
| `:right` / `{:ctrl, "f"}` | Move right one character |
| `{:alt, "b"}` | Move left one word |
| `{:alt, "f"}` | Move right one word |
| `:home` / `{:ctrl, "a"}` | Jump to start |
| `:end` / `{:ctrl, "e"}` | Jump to end |
**Options:** `placeholder`, `char_limit`, `echo_mode` (`:normal`, `:password`, `:none`),
`echo_char`, `focused_style`, `blurred_style`
**Cursor modes:** `:blink` (default), `:static`, `:hidden` — change with `set_cursor_mode/2`.
TextInput uses a virtual cursor (reverse-video character embedded in content); the real terminal
cursor stays hidden.
## Timers and background work
Any `{:task, fun}` cmd spawns `fun` in a separate process. The return value is sent back to
the runtime and delivered to `handle_info/2`. This is how timers work:
```elixir
# A tick that fires every 16ms
def tick_cmd, do: {:task, fn -> Process.sleep(16); :tick end}
def init(_), do: {initial_model(), tick_cmd()}
def handle_info(model, :tick) do
{update(model), tick_cmd()} # re-arm
end
def handle_info(model, _msg), do: {model, nil}
```
## External processes
Capture `self()` before calling `Tela.run/2`; that pid is the runtime process. External
processes can send messages to it directly:
```elixir
runtime_pid = self()
Task.start(fn ->
Stream.interval(1000)
|> Enum.each(fn i -> send(runtime_pid, {:tick, i}) end)
end)
Tela.run(MyApp, [])
```
Messages arrive in `handle_info/2`.
## Reading results
`Tela.run/2` blocks until the program quits and returns `{:ok, final_model}`:
```elixir
{:ok, model} = Tela.run(Picker, items: ["one", "two", "three"])
IO.puts("You chose: #{model.selected}")
```
## Testing
Because all callbacks are pure functions, test them directly — no terminal or runtime needed:
```elixir
defmodule CounterTest do
use ExUnit.Case
test "increment" do
{model, cmd} = Counter.init([])
{model, _cmd} = Counter.handle_event(model, %Tela.Key{key: {:char, "k"}, raw: "k"})
assert model == 1
assert cmd == nil
end
test "quit" do
{model, cmd} = Counter.handle_event(0, %Tela.Key{key: {:char, "q"}, raw: "q"})
assert cmd == :quit
end
end
```
## Examples
The `examples/` directory contains runnable scripts:
| File | Demonstrates |
|---|---|
| `result.ex` | Reading `{:ok, model}` after quit |
| `spinners.ex` | All 12 spinner presets, runtime swapping |
| `realtime.ex` | External process sending events to the runtime |
| `stopwatch.ex` | Start/stop tick loop, millisecond timer |
| `timer.ex` | Automatic `:quit` from `handle_info/2` |
| `debounce.ex` | Debounce pattern using stale-task guards |
| `text_input.ex` | Single `TextInput` field with placeholder and blink |
| `text_inputs.ex` | Multi-field form with tab navigation and password masking |
| `burrito/` | Self-contained binary via [Burrito](https://github.com/burrito-elixir/burrito) |
Run any example with:
```sh
mix run examples/stopwatch.ex
```
## License
MIT