Every renderer in this library takes an Ascii.Frame, and so can yours.
A frame is plain data with no processes or resources behind it, so it can be
stored, sent between nodes, or turned into anything that can show a grid of
characters.
Cells
lines holds rows lines of exactly cols characters (code points: count
and split them with String.codepoints/1, not by bytes). A coloured piece
drawn in colour also has colors, one byte a cell, row by row, indexing
palette. Walk them together:
{frame, _} = Ascii.new!("ubuntu") |> Ascii.frame(0.0)
cells =
for {line, y} <- Enum.with_index(frame.lines),
{char, x} <- Enum.with_index(String.codepoints(line)) do
{x, y, char, Enum.at(frame.palette, :binary.at(frame.colors, y * frame.cols + x))}
end
length(cells) == frame.cols * frame.rows
#=> trueAscii.Frame.color_at/3 does the lookup for one cell. For speed, turn the
palette into a tuple once a frame and read colours a row at a time with
:binary.bin_to_list(colors, y * cols, cols).
A few rules every renderer here follows:
colorsisnilfor text pieces, and for coloured ones made withcolor: false: drawtextin a single ink of your choice. Given an:inkor:parts(seeAscii.InkandAscii.Parts), they have colours too.- A palette entry can be
nil: those cells are in your own ink, as a piece's parts are when only some of them have colours. groundis the colour behind a scene. Fill it behind a coloured frame; blank cells show it.- A space is blank, whatever its colour index. Merging spaces into the run around them makes far fewer runs of colour.
cellis a cell's height in cell widths: 2 for most pieces, the shape of a character, and 1 for scenes, whose cells are square.
Runs of colour
Most outputs want runs of one colour rather than single cells. This groups each row, letting spaces join the run they are in:
defmodule MyApp.Runs do
@doc "Each row as `[{hex_colour, text}]`."
def rows(%Ascii.Frame{colors: colors, palette: palette} = frame) when is_binary(colors) do
palette = List.to_tuple(palette)
for {line, y} <- Enum.with_index(frame.lines) do
line
|> String.codepoints()
|> Enum.zip(:binary.bin_to_list(colors, y * frame.cols, frame.cols))
|> Enum.reduce([], fn
{" ", _}, [{color, text} | runs] -> [{color, text <> " "} | runs]
{char, i}, [{color, text} | runs] when color == elem(palette, i) -> [{color, text <> char} | runs]
{char, i}, runs -> [{elem(palette, i), char} | runs]
end)
|> Enum.reverse()
end
end
end
{frame, _} = Ascii.new!("elixir") |> Ascii.frame(0.0)
row = MyApp.Runs.rows(frame) |> Enum.at(14)
String.length(Enum.map_join(row, &elem(&1, 1))) == frame.cols
#=> trueWhat changed
Between one frame and the next most pieces change a few cells in a hundred.
Ascii.Frame.changes/3 gives just those, as runs along a row,
{y, x, text, colors}, the colours being the run's palette indices; a
display that already shows the frame before needs nothing else. With nil
before, it gives every row whole.
anim = Ascii.new!("night-coast")
{a, anim} = Ascii.frame(anim, 1.0)
{b, _anim} = Ascii.frame(anim, 1.1)
runs = Ascii.Frame.changes(a, b)
cells = runs |> Enum.map(fn {_y, _x, text, _colors} -> String.length(text) end) |> Enum.sum()
cells < b.cols * b.rows / 5
#=> trueAscii.Render.ANSI.changes/3 writes them to a terminal, and the LiveView
player sends them to the browser.
SVG
An SVG keeps the characters as text, so it scales and can be copied: for a
README, an Open Graph image, or an email that allows SVG.
Ascii.Render.SVG writes one: each row a <text>, each run of one
colour a <tspan> placed at its column and held to its width, so the
columns line up in whatever monospace font the reader has.
{frame, _} = Ascii.new!("elixir") |> Ascii.frame(0.0)
svg = frame |> Ascii.Render.SVG.render(title: "the Elixir logo") |> IO.iodata_to_binary()
String.starts_with?(svg, "<svg") and svg =~ "<title>the Elixir logo</title>"
#=> trueFile.write!("elixir.svg", svg) and open it in a browser, or from the
command line, mix ascii.svg elixir. Text pieces take :foreground
(the colour of the text around an inline SVG by default) and anything
:background; a CSS variable in a palette stays one, so an SVG inlined in a
page takes the page's colours. Frames put together with Ascii.Compose
are frames like any other: a dashboard as one picture.
A terminal recording
asciinema's .cast format is a JSON header and a
JSON line per burst of output, so Ascii.Render.ANSI makes a recording
anyone can replay in a terminal or embed with asciinema's web player:
defmodule MyApp.Cast do
alias Ascii.Render.ANSI
def record(piece, seconds) do
anim = Ascii.new!(piece)
step = 1 / max(anim.fps, 1)
header = JSON.encode!(%{version: 2, width: anim.meta.cols, height: anim.meta.rows})
{events, _anim} =
Enum.map_reduce(0..round(seconds / step), anim, fn i, anim ->
t = i * step
{frame, anim} = Ascii.frame(anim, t)
# A terminal's newline returns the cursor too; a recording must say so.
out = [ANSI.home(), ANSI.render(frame)] |> IO.iodata_to_binary() |> String.replace("\n", "\r\n")
{JSON.encode!([Float.round(t, 3), "o", out]), anim}
end)
Enum.join([header | events], "\n") <> "\n"
end
end
cast = MyApp.Cast.record("spinners", 2)
cast |> String.split("\n", trim: true) |> length()
#=> 26File.write!("spinners.cast", cast), then asciinema play spinners.cast.
Over the wire
To draw frames somewhere else, a browser, another service, a microcontroller, send the data and let the other side draw. The palette is fixed for an animation, so send it once; each frame is then its text and its colours:
{frame, _} = Ascii.new!("elixir") |> Ascii.frame(0.0)
once = JSON.encode!(%{cols: frame.cols, rows: frame.rows, cell: frame.cell, palette: frame.palette, ground: frame.ground})
each = JSON.encode!(%{text: frame.text, colors: Base.encode64(frame.colors)})
byte_size(each) < 5_000
#=> trueA logo's frame is a few kilobytes. A scene's is 20,000 cells, about 70 kB as
JSON: at 15 frames a second, a megabyte a second to each viewer. Two things
cut that down, and Ascii.Phoenix.PlayerLive does both: a scene changes
a few cells in a hundred a frame, so send only the runs of cells that
changed; and a piece that closes a loop (Ascii.loop/1) can be sent one
cycle, once, for the other side to repeat.
Between BEAM nodes, send the %Ascii.Frame{} itself. To share one
animation between many viewers, play it once and let them subscribe (see
the Phoenix guide).
Other displays
The Nerves guide turns frames into pixels for a framebuffer or an SPI panel, and the ExRatatui guide into ratatui spans; both are the walk above with a different output.