Tela

A zero-dependency Elixir library for building interactive terminal UIs using the Elm Architecture.

         args
           │
           ▼
        init/1
           │
           ▼
    ┌─────model─────┐
    │               │
    ▼               │
 view/1          handle_event/2   ◄── key events from stdin
    │            handle_info/2    ◄── cmd results, timer ticks, external messages
    │               │
    ▼               ▼
 Frame.t()     {new_model, cmd}
    │
    ▼
 rendered to stdout (diff only)

Inspired by Bubble Tea. Designed to feel familiar to Elixir developers through a callback interface modelled on GenServer and Phoenix.LiveView.

Features

Requirements

Installation

def deps do
  [
    {:tela, "~> 0.1"}
  ]
end

Quick start

defmodule Counter do
  use Tela

  @impl Tela
  def init(_args), do: {0, nil}

  @impl Tela
  def handle_event(count, %Tela.Key{key: {:char, "k"}}), do: {count + 1, nil}
  def handle_event(count, %Tela.Key{key: {:char, "j"}}), do: {count - 1, nil}
  def handle_event(count, %Tela.Key{key: {:char, "q"}}), do: {count, :quit}
  def handle_event(count, _key), do: {count, nil}

  @impl Tela
  def handle_info(count, _msg), do: {count, nil}

  @impl Tela
  def view(count) do
    Tela.Frame.new("Count: #{count}\n\nk = increment  j = decrement  q = quit")
  end
end

{:ok, final_count} = Tela.run(Counter, [])
IO.puts("Final count: #{final_count}")

Run it:

mix run -e "Tela.run(Counter, [])"

Callbacks

init/1

@callback init(args :: term()) :: {model :: term(), Tela.cmd()}

Called once at startup. Returns {initial_model, cmd}. Use {:task, fun} to kick off background work, or nil for no side effect.

handle_event/2

@callback handle_event(model :: term(), key :: Tela.Key.t()) :: {term(), Tela.cmd()}

Called for every keystroke from stdin. Must be pure — no side effects.

handle_info/2

@callback handle_info(model :: term(), msg :: term()) :: {term(), Tela.cmd()}

Called for cmd results, timer ticks, and any message sent to the runtime process via Process.send/2. Must be pure.

view/1

@callback view(model :: term()) :: Tela.Frame.t()

Called after every update. Returns a Tela.Frame.t(). The runtime diffs the content against the previous frame and writes only changed lines.

Commands

@type cmd :: nil | :quit | {:task, (() -> term())}

Keys

Every keystroke arrives as a %Tela.Key{key: key, raw: binary()}. Pattern match on key:

# Printable characters
%Tela.Key{key: {:char, "a"}}

# Control keys
%Tela.Key{key: {:ctrl, "c"}}
%Tela.Key{key: {:alt, "f"}}

# Named keys
%Tela.Key{key: :enter}
%Tela.Key{key: :backspace}
%Tela.Key{key: :up}
%Tela.Key{key: :down}
%Tela.Key{key: :left}
%Tela.Key{key: :right}
%Tela.Key{key: :escape}
%Tela.Key{key: :tab}
%Tela.Key{key: :shift_tab}
%Tela.Key{key: :home}
%Tela.Key{key: :end}
%Tela.Key{key: :page_up}
%Tela.Key{key: :page_down}
%Tela.Key{key: {:f, 1}}      # F1–F12

# Unknown byte sequences
%Tela.Key{key: :unknown}

Note: Always match {:char, "q"}, never a bare "q". The latter will never match.

Frames and layout

Tela.Frame.new/1 wraps a string (lines separated by \n) into a frame. Frames compose vertically with Frame.join/2:

alias Tela.Frame

header = Frame.new("My App\n")
body   = Frame.new("Content here")
footer = Frame.new("\n\nq to quit")

frame = Frame.join([header, body, footer], separator: "")

Frame.join/2 adjusts cursor row offsets automatically, so components that expose a cursor position remain correct when embedded in a larger layout.

Real terminal cursor

Pass a cursor: option to Frame.new/2 to position the real terminal cursor:

Frame.new("Hello", cursor: {0, 3, :block})
#                            row col shape

Shapes: :block, :bar, :underline. Use nil (the default) to hide the cursor.

Styles

Tela.Style produces composable ANSI style structs. All functions are pure.

alias Tela.Style

style =
  Style.new()
  |> Style.bold()
  |> Style.foreground(:cyan)
  |> Style.background(:black)
  |> Style.border(:rounded)
  |> Style.padding(1, 2)

Style.render(style, "Hello, world!")

Text attributes:bold/1, dim/1, italic/1, underline/1, strikethrough/1, reverse/1

Colours::black, :red, :green, :yellow, :blue, :magenta, :cyan, :white, bright_ variants (e.g. :bright_cyan), and :default

Borders::single, :double, :rounded, :thick

Padding:padding(style, all), padding(style, vertical, horizontal), padding(style, top, right, bottom, left)

Use Style.width/1 to measure the visible width of a styled string (strips ANSI escapes).

Components

Spinner

alias Tela.Component.Spinner

defmodule Loading do
  use Tela

  @impl Tela
  def init(_) do
    spinner = Spinner.init(spinner: :dot)
    {%{spinner: spinner, done: false}, Spinner.tick_cmd(spinner)}
  end

  @impl Tela
  def handle_event(model, %Tela.Key{key: {:char, "q"}}), do: {model, :quit}
  def handle_event(model, _key), do: {model, nil}

  @impl Tela
  def handle_info(model, msg) do
    {spinner, cmd} = Spinner.handle_tick(model.spinner, msg)
    {%{model | spinner: spinner}, cmd}
  end

  @impl Tela
  def view(model) do
    Tela.Frame.new(Spinner.view(model.spinner).content <> " Loading...  q to quit")
  end
end

Presets::line, :dot, :mini_dot, :jump, :pulse, :points, :globe, :moon, :monkey, :meter, :hamburger, :ellipsis

Custom spinner: pass spinner: {frames_list, interval_ms}.

The parent owns the tick loop. Call Spinner.tick_cmd/1 from init/1 and re-arm from handle_info/2 by passing the result of Spinner.handle_tick/2 as your cmd. Stale ticks (arriving after a spinner is replaced) are silently dropped.

TextInput

alias Tela.Component.TextInput
alias Tela.Frame

defmodule Search do
  use Tela

  @impl Tela
  def init(_) do
    input = TextInput.init(placeholder: "Search...", char_limit: 100) |> TextInput.focus()
    {%{input: input}, TextInput.blink_cmd(input)}
  end

  @impl Tela
  def handle_event(model, %Tela.Key{key: :escape}), do: {model, :quit}

  def handle_event(model, key) do
    {input, cmd} = TextInput.handle_event(model.input, key)
    {%{model | input: input}, cmd}
  end

  @impl Tela
  def handle_info(model, msg) do
    {input, cmd} = TextInput.handle_blink(model.input, msg)
    {%{model | input: input}, cmd}
  end

  @impl Tela
  def view(model) do
    Frame.join(
      [Frame.new("Query:\n\n"), TextInput.view(model.input), Frame.new("\n\nesc to quit")],
      separator: ""
    )
  end
end

{:ok, model} = Tela.run(Search, [])
IO.puts("Searched for: #{TextInput.value(model.input)}")

Key bindings:

Key Action
{:char, c} Insert character
:backspace / {:ctrl, "h"} Delete before cursor
:delete / {:ctrl, "d"} Delete at cursor
{:ctrl, "k"} Delete to end of line
{:ctrl, "u"} Delete to start of line
{:ctrl, "w"} Delete word backward
{:alt, "d"} Delete word forward
:left / {:ctrl, "b"} Move left one character
:right / {:ctrl, "f"} Move right one character
{:alt, "b"} Move left one word
{:alt, "f"} Move right one word
:home / {:ctrl, "a"} Jump to start
:end / {:ctrl, "e"} Jump to end

Options:placeholder, char_limit, echo_mode (:normal, :password, :none), echo_char, focused_style, blurred_style

Cursor modes::blink (default), :static, :hidden — change with set_cursor_mode/2. TextInput uses a virtual cursor (reverse-video character embedded in content); the real terminal cursor stays hidden.

Timers and background work

Any {:task, fun} cmd spawns fun in a separate process. The return value is sent back to the runtime and delivered to handle_info/2. This is how timers work:

# A tick that fires every 16ms
def tick_cmd, do: {:task, fn -> Process.sleep(16); :tick end}

def init(_), do: {initial_model(), tick_cmd()}

def handle_info(model, :tick) do
  {update(model), tick_cmd()}   # re-arm
end

def handle_info(model, _msg), do: {model, nil}

External processes

Capture self() before calling Tela.run/2; that pid is the runtime process. External processes can send messages to it directly:

runtime_pid = self()

Task.start(fn ->
  Stream.interval(1000)
  |> Enum.each(fn i -> send(runtime_pid, {:tick, i}) end)
end)

Tela.run(MyApp, [])

Messages arrive in handle_info/2.

Reading results

Tela.run/2 blocks until the program quits and returns {:ok, final_model}:

{:ok, model} = Tela.run(Picker, items: ["one", "two", "three"])
IO.puts("You chose: #{model.selected}")

Testing

Because all callbacks are pure functions, test them directly — no terminal or runtime needed:

defmodule CounterTest do
  use ExUnit.Case

  test "increment" do
    {model, cmd} = Counter.init([])
    {model, _cmd} = Counter.handle_event(model, %Tela.Key{key: {:char, "k"}, raw: "k"})
    assert model == 1
    assert cmd == nil
  end

  test "quit" do
    {model, cmd} = Counter.handle_event(0, %Tela.Key{key: {:char, "q"}, raw: "q"})
    assert cmd == :quit
  end
end

Examples

The examples/ directory contains runnable scripts:

File Demonstrates
result.ex Reading {:ok, model} after quit
spinners.ex All 12 spinner presets, runtime swapping
realtime.ex External process sending events to the runtime
stopwatch.ex Start/stop tick loop, millisecond timer
timer.ex Automatic :quit from handle_info/2
debounce.ex Debounce pattern using stale-task guards
text_input.ex Single TextInput field with placeholder and blink
text_inputs.ex Multi-field form with tab navigation and password masking
burrito/ Self-contained binary via Burrito

Run any example with:

mix run examples/stopwatch.ex

License

MIT