A pure-Elixir TUI framework for Unix terminals. TEA-style model / update / view loop on top of OTP, with first-class focus traversal, layout constraints, ANSI cell-diff rendering, and a small termios NIF for direct /dev/tty control.

defmodule Counter do
  use Harlock.App

  def init(_), do: %{n: 0}

  def update({:key, {:char, ?+}, []}, m), do: %{m | n: m.n + 1}
  def update({:key, {:char, ?-}, []}, m), do: %{m | n: max(0, m.n - 1)}
  def update({:key, {:char, ?q}, []}, _), do: :quit
  def update(_, m), do: m

  def view(m) do
    box(
      title: "Counter",
      border: :rounded,
      child: text("count: #{m.n}")
    )
  end
end

Harlock.run(Counter)

Installation

def deps do
  [{:harlock, "~> 0.2"}]
end

Harlock builds a small C NIF (c_src/termios.c, ~250 LOC of POSIX) for termios access — elixir_make handles this automatically. Requires a C compiler and make available at install time. macOS, Linux, and *BSD are supported; Windows native is not (WSL works).

Why Harlock

If you've written a Phoenix LiveView app you already know how to use Harlock — init / update / view, message-passing for events, side-effects as Cmd values. The runtime is a single OTP supervision tree: terminal owner → IO → cmd executor → TEA loop, with terminal restoration guaranteed on any crash path via the supervisor's rest_for_one strategy.

Compared to alternatives:

  • Owl is a styled-output library ("println but pretty"). Harlock is a full interactive runtime — focus, layout, dirty-flag rendering, async cmds, resize handling.
  • Ratatouille wraps termbox via a C port. Solid, but the C dep is bigger and the runtime model is its own thing. Harlock is pure-Erlang for rendering with a small in-process NIF only for termios — closer to "Elixir all the way down" if that matters to you.
  • ratatui-via-port approaches (Rust binary speaking a wire protocol to BEAM) are reliable but you give up the testability and composability of pure-Elixir element trees. Harlock keeps the view tree as ordinary data structures.

Status

Harlock is v0.2. The API is intentionally narrow and stable for the primitives it ships; widgets and ergonomics are still landing. Anything @moduledoc false is internal and free to change.

Areav0.2
TEA runtime (init/update/view/subs)
OTP supervision + terminal restoration
Cmd executor (Cmd.from, Cmd.batch, Cmd.map)
Layout constraints (:length, :percentage, :fill)
Focus traversal + focus_trap overlays
Wide-grapheme width (CJK, emoji, ZWJ, flags)
Theme tokens (:header, :focus, :selection, :border)
SIGWINCH resize via ioctl(TIOCGWINSZ) NIF
text / vbox / hbox / box / spacer / overlay / table / list / text_input
viewport, progress, tabs, spinner, statusbar, keybarv0.3
:min / :max layout constraints (currently behave as :length)v0.3
Mouse, kitty keyboard protocol, modified arrowsv0.3
Full theme set + built-in themes + color downgradev0.4

See ROADMAP.md for the full plan through v1.0.

Examples

./scripts/run.sh counter    # simplest possible app — count up/down
./scripts/run.sh sysmon     # live BEAM process monitor
./scripts/run.sh contacts   # contact manager: search, list, modal forms, async save

contacts exercises most of v0.2: tab focus traversal, text_input fields, an overlay with focus_trap, async save via Cmd.from, custom theme, status bar with current-focus indicator.

Testing your app

Harlock.Test boots an app under a headless backend — no /dev/tty required — and exposes synchronous helpers:

test "Tab cycles focus through the form" do
  h = Harlock.Test.start_app(MyApp, init_arg)

  Harlock.Test.send_key(h, :tab)
  assert Harlock.Test.focused(h) == :email

  Harlock.Test.send_key(h, :tab)
  assert Harlock.Test.focused(h) == :submit

  Harlock.Test.stop(h)
end

Same code path as the real runtime — only the bytes-in / bytes-out boundary is mocked.

Smoke tests

A handful of scripts in priv/*_smoke.exs exercise the real runtime + termios NIF via script(1):

./scripts/smoke.sh

Picks the right flag syntax for BSD vs util-linux script automatically.

Contributing

Issues and PRs welcome at https://github.com/thatsme/harlock. The codebase is small enough (~3k LOC of Elixir + ~250 LOC of C) to read in an afternoon. Start with lib/harlock/app/runtime.ex — everything else is reachable from there.

License

MIT. See LICENSE.