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 — and Atui.Runtime runs the IO loop
that feeds them and paints the result. Views never touch the terminal, so a
whole UI can be driven and asserted on without a tty in sight.
Installation
def deps do
[{:atui, "~> 0.1.0"}]
endA 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
endStart it from your application's supervision tree:
def start(_type, _args) do
Supervisor.start_link([{Atui, view: Hello}], strategy: :one_for_one)
endRunning it
A TUI needs the VM started so that Ctrl-C reaches the application rather than
opening the emulator BREAK menu. That is the +Bc flag:
elixir --erl "+Bc" -S mix run --no-halt # from source
# or, in a release's rel/vm.args.eex:
+Bcmix 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 actually requires.
The pieces
Atui.Runtime— the main loop: terminal, view stack, ticks, repaintAtui.View— the behaviour a screen implementsAtui.Panes— a tiling window manager a view can hold in its stateAtui.Screen/Atui.Rect/Atui.Layout— the drawing surface, its geometry, and how to divide itAtui.Style— colour and attributes for a cellAtui.Key/Atui.Input— raw bytes decoded into key eventsAtui.Terminal— raw mode, alternate screen, size
Views are stacked by the runtime: each push draws over what is below it,
which is what a popup wants. Atui.Panes is the other arrangement — several
views visible at once, tiled, each with its own ticker.
Driving a view from outside
Views react to keys and ticks on their own. Anything else — a job finishing, a file changing, a message from another process — arrives as an event:
Atui.send_event(:refresh) # to the view with focus
Atui.send_event_to(Atui.Runtime, MyView, {:rows, rows})Sending the runtime process a plain message does the same thing, so a Task
reply or a subscription needs no adapter.
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 Atui.Runtime.screen(pid) |> Atui.Screen.to_text() =~ "hello"
Summary
Functions
The child spec to put the UI under a supervisor.
Sends an event to the view with focus. See Atui.Runtime.send_event/2.
Sends an event to a named view. See Atui.Runtime.send_event_to/3.
Starts the UI loop with view as the root view.
The version of Atui in use — for an application to put in a title bar.
Functions
The child spec to put the UI under a supervisor.
Supervisor.start_link([{Atui, view: MyView}], strategy: :one_for_one):temporary — a UI that has quit has nothing to restart into, and restarting
it would take the terminal back from whatever the user returned to.
Sends an event to the view with focus. See Atui.Runtime.send_event/2.
Sends an event to a named view. See Atui.Runtime.send_event_to/3.
Starts the UI loop with view as the root view.
Accepts every option Atui.Runtime.start_link/1 takes; :view is required.
The version of Atui in use — for an application to put in a title bar.