ExRatatui brings the Rust
ratatui terminal UI library to Elixir: widgets, layout,
key and mouse events, and supervised TUI apps that run in a local terminal,
over SSH or across distributed Erlang. Ascii.ExRatatui makes frames into
ratatui widgets, so the components (a file tree, meters, gauges, a boot log)
sit in your terminal UI beside ratatui's own, and Ascii.ExRatatui.Viewer
is a ready-made app that plays pieces full screen. ExRatatui is an optional dependency of this
library: add it to your project to have both. Written against ExRatatui 0.17.
# needs: mix.exs
{:ascii, "~> 0.3.0"},
{:ex_ratatui, "~> 0.17"}The viewer
In a terminal:
mix run -e 'Ascii.ExRatatui.Viewer.run(piece: "donut")'
Space pauses, ← and → step through the pieces, q quits. It takes the pieces to step through, options for the first, and how to draw colour:
# needs: a real terminal
Ascii.ExRatatui.Viewer.run(pieces: ~w(donut elixir matrix-rain), mode: :ansi256)With interactive: true, keys go to a piece that can be used first (a file
tree's arrows, a form's typing), and those it has no use for to the viewer:
# needs: a real terminal
Ascii.ExRatatui.Viewer.run(piece: "file-tree", options: %{walk: false}, interactive: true)Coloured pieces want a terminal with 24-bit colour, which most modern ones
have; mode: :ansi256 takes the nearest of xterm's 256 colours instead.
Scenes are 200 columns wide: in a smaller window the viewer shows their
middle.
Frames as widgets
A frame is a widget, so [{frame, rect}] in render/2 draws it from the
top left of rect. Ascii.ExRatatui.place/3 gives it a rect of its own
size, centred in an area, in a titled box if you like:
alias ExRatatui.Layout.Rect
{:ok, frame} = Ascii.still("elixir")
area = %Rect{x: 0, y: 0, width: 80, height: 40}
{paragraph, rect} = Ascii.ExRatatui.place(frame, area, title: "elixir")
rect
#=> %ExRatatui.Layout.Rect{x: 20, y: 5, width: 40, height: 30}Too big for its area, a frame fills it and shows its middle. For text of
your own composing, Ascii.ExRatatui.lines/2 gives the frame as ratatui
lines, a span to each run of one colour. All three take mode: :ansi256.
An app of your own
ExRatatui.App gives a LiveView-like app: mount/1, render/2,
handle_event/2 and handle_info/2. An Ascii.Player keeps the time and
sends the app each frame, so render/2 only places the latest:
defmodule MyApp.ArtTUI do
use ExRatatui.App
alias ExRatatui.Event.Key
alias ExRatatui.Layout.Rect
@impl true
def mount(opts) do
{:ok, _player} = Ascii.Player.start_link(piece: Keyword.get(opts, :piece, "donut"))
{:ok, %{frame: nil}}
end
@impl true
def render(%{frame: nil}, _size), do: []
def render(%{frame: frame}, size) do
area = %Rect{x: 0, y: 0, width: size.width, height: size.height}
[Ascii.ExRatatui.place(frame, area, title: "art")]
end
@impl true
def handle_info({:ascii_frame, _ref, frame}, state), do: {:noreply, %{state | frame: frame}}
def handle_info(_message, state), do: {:noreply, state}
@impl true
def handle_event(%Key{code: "q", kind: "press"}, state), do: {:stop, state}
def handle_event(_event, state), do: {:noreply, state}
endStart it under a supervisor, {MyApp.ArtTUI, piece: "night-coast"}, or from a
script with MyApp.ArtTUI.start_link(piece: "donut"). In a standalone script,
add a terminate/2 that calls System.stop(0) so quitting ends the program.
Keys and clicks
A piece that can be used (see
Keys and clicks) takes ExRatatui's
events through Ascii.ExRatatui.handle_event/3: a key by its code, and
a left click as the frame's cell under it, wherever place/3 put the
picture (its box, centring and scroll allowed for). Keep the animation in
your state rather than a player, and what was chosen is in its options:
alias ExRatatui.Event.{Key, Mouse}
alias ExRatatui.Layout.Rect
tree = Ascii.new!("file-tree", options: %{walk: false, paths: ["lib/app.ex", "mix.exs"], root: "app"})
{picture, tree} = Ascii.frame(tree, 0.0)
placed = Ascii.ExRatatui.place(picture, %Rect{x: 0, y: 0, width: 80, height: 30}, title: "files")
{:ok, tree} = Ascii.ExRatatui.handle_event(tree, %Key{code: "down", kind: "press"}, placed)
tree.options.cursor
#=> "lib/"
# A click on the row of mix.exs, where it is on the screen.
{_paragraph, rect} = placed
{:ok, tree} = Ascii.ExRatatui.handle_event(tree, %Mouse{kind: "down", button: "left", x: rect.x + 15, y: rect.y + 3}, placed)
tree.options.cursor
#=> "mix.exs"In an ExRatatui.App, call it from handle_event/2 with the placed your
render/2 used (keep the screen's size in state from %ExRatatui.Event.Resize{}
events, or lay the screen out the same way in both), and start the app with
mouse_capture: true for clicks. Ascii.ExRatatui.event/2 gives the
piece's event alone, for a key you want to look at first.
A live dashboard
The UI and data pieces take your own data (see
Live data), so they make dashboard widgets:
a sparkline of samples, a gauge of a reading, a progress bar, an htop-style
cpu-meters. Keep each as an animation, take new data up with
Ascii.put_options!/2 as it comes, and draw on a timer. Here, the VM's
memory and process count, sampled every second, one under the other:
defmodule MyApp.Dashboard do
use ExRatatui.App
alias ExRatatui.Event.Key
alias ExRatatui.Layout
alias ExRatatui.Layout.Rect
@series %{label: "memory", unit: " mb", values: []}
@impl true
def mount(_opts) do
memory =
Ascii.new!("sparkline", options: %{series: [@series], range: false, cols: 60, rows: 3})
procs =
Ascii.new!("gauge",
options: %{label: "processes", unit: "", max: 1000, value: :erlang.system_info(:process_count)}
)
send(self(), :sample)
send(self(), :tick)
{:ok, %{memory: memory, procs: procs, samples: [], frames: [], t: 0.0}}
end
@impl true
def render(state, size) do
area = %Rect{x: 0, y: 0, width: size.width, height: size.height}
heights = for {_title, art} <- state.frames, do: {:length, art.rows + 2}
rects = Layout.split(area, :vertical, heights ++ [{:min, 0}])
for {{title, art}, rect} <- Enum.zip(state.frames, rects),
do: Ascii.ExRatatui.place(art, rect, title: title)
end
# New data: taken up by each piece, drawn on the next tick.
@impl true
def handle_info(:sample, state) do
mb = div(:erlang.memory(:total), 1_048_576)
samples = Enum.take(state.samples ++ [mb], -60)
memory = Ascii.put_options!(state.memory, series: [%{@series | values: samples}])
procs = Ascii.put_options!(state.procs, value: :erlang.system_info(:process_count))
Process.send_after(self(), :sample, 1000)
{:noreply, %{state | memory: memory, procs: procs, samples: samples}, render?: false}
end
def handle_info(:tick, state) do
t = state.t + 0.1
{memory_frame, memory} = Ascii.frame(state.memory, t)
{procs_frame, procs} = Ascii.frame(state.procs, t)
Process.send_after(self(), :tick, 100)
frames = [{"memory", memory_frame}, {"processes", procs_frame}]
{:noreply, %{state | memory: memory, procs: procs, frames: frames, t: t}}
end
@impl true
def handle_event(%Key{code: "q", kind: "press"}, state), do: {:stop, state}
def handle_event(_event, state), do: {:noreply, state}
endThe gauge's needle swings from its last reading to each new one; the
sparkline scrolls as samples come. Pieces with a size of their own (the
sparkline's cols and rows here) give frames of that size, so the layout
follows art.rows. ratatui already writes only the cells that change
between draws.
A piece is just a {widget, rect} in the list render/2 returns, so it can
sit beside lists, tables and ratatui's own gauges in any ExRatatui.Layout.
Colouring parts
Pieces that know their parts (a file tree's folders and files, a boot log's
statuses, a meter's bars; see Ascii.Parts) take a colour for each with
:parts, so a widget can match the rest of your UI: files by type, warnings
in your warning colour. Pass it to Ascii.new/2 or the player, or
parts: true for the colours each piece suggests. Parts given no colour are
drawn in the terminal's own.
parts = %{"folder" => "#58a6ff", "file.md" => "#d29922", "branch" => "#8b949e"}
{tree, _} = Ascii.new!("file-tree", parts: parts) |> Ascii.frame(3.0)
[_root, _docs, guide | _] = Ascii.ExRatatui.lines(tree)
Enum.map(guide.spans, &{String.trim(&1.content), &1.style.fg})
#=> [{"│ ├──", {:rgb, 139, 148, 158}}, {"guide.md", {:rgb, 210, 153, 34}}]Over SSH and across nodes
An app can be served over SSH, each client getting its own session (and so its own animation), or attached to from another node. The viewer serves as it is; give each transport its own child id:
# needs: an SSH host key in /data/ssh
children = [
Supervisor.child_spec(
{Ascii.ExRatatui.Viewer,
transport: :ssh, port: 2222, system_dir: "/data/ssh", app_opts: [piece: "matrix-rain"]},
id: :art_over_ssh
),
Supervisor.child_spec({Ascii.ExRatatui.Viewer, transport: :distributed}, id: :art_over_dist)
]Then ssh -p 2222 host. system_dir holds the daemon's host key (make one
with ssh-keygen -t ed25519 -N "" -f /data/ssh/ssh_host_ed25519_key), and
the other options of OTP's :ssh.daemon/2, such as :user_dir and
:pwdfun, say who may log in. This is a good fit for a Nerves
device.
Testing
ExRatatui's test_mode: {width, height} runs an app headlessly, and
ExRatatui.Runtime.inject_event/2 drives keys, so an art app is tested like
any other:
alias ExRatatui.Event.Key
alias ExRatatui.Runtime
{:ok, pid} = MyApp.ArtTUI.start_link(piece: "spinners", test_mode: {60, 30}, name: nil)
Process.sleep(300)
Runtime.snapshot(pid).render_count > 1
#=> true
Runtime.inject_event(pid, %Key{code: "q", kind: "press"})
#=> :okTo see what was drawn, draw it into an ExRatatui.CellSession and read the
cells back:
alias ExRatatui.CellSession
session = CellSession.new(38, 28)
:ok = CellSession.draw(session, [{frame, %Rect{x: 0, y: 0, width: 38, height: 28}}])
cells = CellSession.take_cells(session).cells
Enum.at(cells, 1 * 38 + 17).symbol
#=> "_"