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.
def deps do
[{:atui, "~> 0.1.0"}]
end
A first view
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:
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:
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:
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:
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.
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
mix test # the framework
cd demo && mix test # the demo, and the framework through it
License
Apache-2.0 — see LICENSE.