Packages

A terminal UI toolkit for Elixir, in the shape of Phoenix LiveView: stateful views, tiled windows, styled cells and an IO loop that only writes when the frame changes.

Current section

Files

Jump to
atui README.md
Raw

README.md

# Atui
A terminal UI toolkit for Elixir, in the shape of Phoenix LiveView.
You write **views** — modules with state, a `render/2` that returns cells, and
callbacks for keys, ticks and events. `Atui.Runtime` owns the terminal and runs
the IO loop that feeds them and paints the result. Views never touch the
terminal themselves, which is what makes a whole UI testable: `render/2` returns
a screen you can assert on without a tty in sight.
```elixir
def deps do
[{:atui, "~> 0.1.0"}]
end
```
## A first view
```elixir
defmodule Hello do
use Atui.View
def mount(_opts), do: {:ok, %{count: 0}}
def tick_interval(_state), do: 1_000
def handle_tick(tick, state), do: {:ok, %{state | count: tick}}
def handle_key({:char, "q"}, state), do: {:halt, state}
def handle_key(_key, state), do: {:pass, state}
def render(state, rect) do
Atui.Screen.new(rect.width, rect.height)
|> Atui.Screen.box(rect, title: " hello ")
|> Atui.Screen.put_lines_centered(rect, ["up #{state.count}s", "q to quit"])
end
end
```
The UI is a child spec, so it goes under your application's own supervisor:
```elixir
def start(_type, _args) do
Supervisor.start_link([{Atui, view: Hello}], strategy: :one_for_one)
end
```
Adding `:atui` to a project starts nothing by itself — there is no application
callback module in the library.
## Running it
A TUI needs the VM started so Ctrl-C reaches the application instead of opening
the emulator's BREAK menu. That is the `+Bc` flag:
```sh
elixir --erl "+Bc" -S mix run --no-halt
```
In a release, put `+Bc` in `rel/vm.args.eex`. `mix run --no-halt` works without
it, but the BEAM keeps Ctrl-C for itself. Under IEx the shell owns the terminal
and input stays line buffered — see `Atui.Terminal` for what raw mode requires
and how it is obtained.
## What the pieces do
| Module | |
| --- | --- |
| `Atui.Runtime` | the main loop: terminal, view stack, ticks, repaint |
| `Atui.View` | the behaviour a screen implements |
| `Atui.Panes` | a tiling window manager a view can hold in its state |
| `Atui.Screen` | a grid of styled cells, with clipped drawing helpers |
| `Atui.Rect` / `Atui.Layout` | geometry, and how to divide a region |
| `Atui.Style` | colour and attributes for a cell |
| `Atui.Key` / `Atui.Input` | raw bytes decoded into key events |
| `Atui.Terminal` | raw mode, alternate screen, size |
### Stacking and tiling
Views are **stacked** by the runtime: each `{:push, module, opts, state}` reply
draws over what is below it, which is what a popup wants. The root view sees
every key first — so global keys like quit live in one place — and passes on what
it does not claim to the focused view.
`Atui.Panes` is the other arrangement: several views visible at once, tiled in a
grid, each with its own ticker, focus moving between them and swappable
positions. A view holds one in its state and delegates a few callbacks to it:
```elixir
def mount(_opts), do: {:ok, %{panes: Panes.new(host: __MODULE__)}}
def handle_key(:tab, state), do: {:ok, %{state | panes: Panes.focus_next(state.panes)}}
def handle_key(key, state) do
case state.panes |> Panes.handle_key(key) |> Panes.into(state) do
{:pass, state} -> my_own_shortcuts(state, key)
reply -> reply
end
end
def handle_event(event, state) do
state.panes |> Panes.handle_event(event) |> Panes.into(state)
end
def render(state, rect), do: Panes.render(state.panes, rect, &banner(state, &1))
```
### Only writing what changed
Every event renders the stack into one frame and compares it with the last. If
nothing changed, nothing is written, so an idle UI is silent on the wire. Styles
stay data until that last moment: a run of equally-styled cells emits one escape
sequence, and two frames differ when their *styles* differ, not when someone
reordered the codes.
## Testing a UI
`:headless` renders into memory instead of a terminal and `:size` fixes the
viewport, so a test can press keys and read the frame back as text:
```elixir
pid =
start_supervised!(
{Atui.Runtime, view: Hello, headless: true, halt: :stop, size: {40, 10}}
)
Atui.Runtime.send_key(pid, {:char, "x"})
assert pid |> Atui.Runtime.screen() |> Atui.Screen.to_text() =~ "hello"
assert Atui.Runtime.view_state(pid, Hello).count == 0
```
## The demo
`demo/` is a small application built on Atui — a welcome screen that tiles an
information popup, a clock with an ASCII analog face, and a disk-usage table
beside it. It depends on the library by path, so it is also how the framework is
exercised end to end.
```sh
demo/bin/demo # run from source, with the right VM flags
cd demo && MIX_ENV=prod mix release
_build/prod/rel/atui_demo/bin/atui_demo start
```
`i`, `t` and `d` open and close windows, arrows move focus, Ctrl with an arrow
rearranges the grid, Tab cycles, ESC closes the focused window and `q` quits.
## Tests
```sh
mix test # the framework
cd demo && mix test # the demo, and the framework through it
```
## License
Apache-2.0 — see [LICENSE](LICENSE).