Harlock
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, mouse support, ANSI cell-diff rendering,
and a small termios NIF for direct /dev/tty control.
defmodule Counter do
use Harlock.App # imports the view DSL (box/1, text/2, vbox/1, …)
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)
Run it with mix run. Not from an IEx prompt: IEx's own terminal driver reads
the same tty, and the app would not receive keystrokes.
A more realistic app wires focus traversal, a selectable table, a
scrollable viewport, and a side-effect via Cmd — all together. Tab
moves focus between the two boxes; the focused widget owns its keys.
Full source: examples/overview.exs.
defmodule Overview do
use Harlock.App
alias Harlock.Cmd
def init(_) do
%{
tasks: [
%{id: 1, name: "compile", state: "done"},
%{id: 2, name: "test", state: "running"},
%{id: 3, name: "dialyzer", state: "queued"},
%{id: 4, name: "credo", state: "queued"},
%{id: 5, name: "publish", state: "blocked"}
],
selected: 1,
log: for(i <- 1..40, do: "[#{i}] event line #{i}"),
log_offset: 0
}
end
def update({:key, {:char, ?q}, []}, _), do: :quit
def update({:key, {:char, ?r}, []}, m) do
cmd =
Cmd.from(fn -> Enum.map(1..3, &"[refresh] new line #{&1}") end)
|> Cmd.map(fn lines -> {:refreshed, lines} end)
{m, cmd}
end
def update({:refreshed, lines}, m), do: %{m | log: lines ++ m.log}
# The runtime auto-routes scroll keys to the focused viewport and
# delivers this message; the app just writes where the offset lives.
def update({:harlock_scroll, :log, new_offset}, m), do: %{m | log_offset: new_offset}
# A focused table routes row movement too, so there is no key dispatch here.
def update({:harlock_select, :tasks, id}, m), do: %{m | selected: id}
def update(_, m), do: m
def view(m) do
vbox(
constraints: [fill: 1, length: 1],
children: [
hbox(
constraints: [percentage: 40, fill: 1],
children: [
box(
title: "Tasks",
border: :rounded,
border_style: [dim: true],
focus_style: [fg: :cyan, bold: true],
# :tasks lives on the table so row movement routes to it; the box
# mirrors its focus for the border.
focus_proxy: :tasks,
child:
table(
focusable: :tasks,
columns: [
column(title: "#", width: {:length, 3}, render: &Integer.to_string(&1.id)),
column(title: "name", width: {:fill, 1}, render: & &1.name),
column(title: "state", width: {:length, 8}, render: & &1.state)
],
rows: m.tasks,
row_id: & &1.id,
focused_row: m.selected,
selection: {:single, m.selected}
)
),
box(
title: "Log",
border: :rounded,
border_style: [dim: true],
focus_style: [fg: :cyan, bold: true],
# :log lives on the viewport, because that is what receives the
# scroll keys. focus_proxy: lights the box up with it.
focus_proxy: :log,
child:
viewport(
focusable: :log,
offset: m.log_offset,
content_height: length(m.log),
child:
vbox(
constraints: List.duplicate({:length, 1}, length(m.log)),
children: Enum.map(m.log, &text/1)
)
)
)
]
),
text("Tab focus arrows/PgUp/PgDn scroll r refresh q quit", style: [dim: true])
]
)
end
end
Harlock.run(Overview)
Installation
def deps do
[{:harlock, "~> 0.8"}]
end
Harlock builds a small termios NIF (c_src/termios.c) and an exec helper
(c_src/exec_helper.c, installed as priv/harlock_exec, which Cmd.exec
uses to run programs with the terminal) — elixir_make handles both
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. Any crash shuts the
tree down and the terminal owner restores the tty — checked in a real pty
for crashes, killed supervisors, and programs started with Cmd.exec.
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 Elixir for rendering, with a small in-process NIF only for terminal control — closer to "Elixir all the way down" if that matters to you.
- ratatui-via-port approaches (Rust binary speaking a wire
protocol to BEAM) ship as two artifacts: your Elixir release plus a
separately-compiled Rust binary that has to be on
PATHat runtime. Harlock ships as one mix dependency — its only native pieces build from source with it, and there is no version-skew between BEAM and renderer. The element tree is also ordinary Elixir data, which makes testing and composition easier than a wire-protocol boundary.
Status
Harlock is v0.8. 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.
| Area | Status |
|---|---|
TEA runtime (init / update / view / subs) |
✓ |
| OTP supervision + terminal restoration | ✓ |
Cmd executor (Cmd.from, Cmd.batch, Cmd.map) |
✓ |
Running another program with the terminal (Cmd.exec) |
✓ (v0.8) |
Job control: Ctrl-Z / fg (Cmd.suspend) |
✓ (v0.8) |
Layout constraints (:length, :percentage, :fill, :min, :max) |
✓ |
| Focus traversal + focus_trap overlays | ✓ |
Focus-aware key routing (viewport / tabs / text_input / textarea / menu / select / tree / table / button / checkbox) |
✓ |
| Wide-grapheme width (CJK, emoji, ZWJ, flags) | ✓ |
Theme tokens (:header, :focus, :selection, :border, :primary, :accent, :muted, :error) |
✓ (full set in v0.4) |
Built-in themes (:default / :dark / :high_contrast) |
✓ (v0.4) |
| Caps-aware color downgrade (truecolor → 256 → 16 → mono) | ✓ (v0.4) |
Table style cascade (:header_style / :row_style / :alt_row_style / :selected_style / :focus_style) |
✓ (v0.4) |
:default theme byte-identical to v0.3 (golden-frame pin) |
✓ (v0.4) |
Terminal resize reflows (SIGWINCH, ioctl(TIOCGWINSZ)) |
✓ (v0.8; broken before) |
text / vbox / hbox / box / spacer / overlay / table / list / text_input |
✓ |
Styled runs, newlines, wrap and align in text (Harlock.Text) |
✓ (v0.8) |
button / checkbox |
✓ (v0.8) |
Readline editing in text_input / textarea (word motions, kills) |
✓ (v0.4.2) |
Yank (Ctrl-Y) in routed inputs, one kill ring per app |
✓ (v0.8) |
progress / spinner / statusbar / keybar / tabs |
✓ |
viewport (render-then-clip + scroll-into-view + cursor remap) |
✓ |
:telemetry events (frame render, input dispatch, cmd, reader) |
✓ |
| Modified arrows / Home / End / F-keys (parser) | ✓ |
Mouse: clicks on elements and the items inside them, wheel scrolls (mouse: true) |
✓ (v0.8) |
| Kitty keyboard protocol (parser) | ✓ (parser only — runtime push deferred) |
tree / menu / select widgets |
✓ (v0.5) |
Multi-line textarea with opt-in word wrap |
✓ (v0.4.2) |
Goal-column memory for textarea vertical motion |
✓ (v0.4.3) |
Undo / redo (Harlock.UndoStack, app-held) |
✓ (v0.5) |
Push-shaped Sub kinds (telemetry / logger / source) |
✓ (v0.6; source v0.7) |
Sub kinds with real logic (file / port) |
1.1+ |
Windowed table rows (fn offset, limit -> rows) |
✓ (v0.7) |
sparkline widget |
✓ (v0.6) |
box(focus_proxy: id) (visual focus mirroring) |
✓ (v0.6) |
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
./scripts/run.sh showcase # tabs, viewport, widgets, modified keys
./scripts/run.sh notes # multi-line textarea: wrap toggle, readline editing
./scripts/run.sh explorer # tree + select + menu, with async-loaded nodes
./scripts/run.sh dashboard # telemetry + logger subscriptions into a sparkline
./scripts/run.sh nodes # BEAM node explorer: windowed table, lazy supervision tree
./scripts/run.sh overview # the README's second snippet
The scripts/run.sh wrapper is in the GitHub repo — clone the repo to
run the examples. The hex package itself is the library; apps depend
on :harlock and build their own runtime entry point (see the Counter
snippet above).
contacts exercises most of the core primitives: tab focus traversal,
text_input fields, an overlay with focus_trap, async save via
Cmd.from, custom theme, status bar with current-focus indicator.
showcase is a four-tab tour of the display widgets — a
200-row scrollable log viewer with viewport + scrollbar, a long form
that uses scroll-into-view to keep the focused field visible, a
widget gallery with animated progress/spinner/statusbar/keybar, and a
key-event inspector you can use to try out modified arrows
(Ctrl-Up, Alt-Left, etc.).
nodes is a BEAM node explorer — observer for people on SSH — and the
largest example: a process list, supervision trees, and memory over time. It
shows the two patterns that matter for real data. The process table uses a
window function, because enumerating pids is one cheap list while
Process.info/2 on all of them is not, so only the rows about to be drawn get
hydrated. The supervision tree loads children through a Cmd on expansion,
because which_children/1 is a call into another process and the window
function runs during rendering.
dashboard wires two push subscriptions into one screen: Sub.telemetry
feeds job durations to a sparkline, Sub.logger turns log calls into
update/2 messages, and Sub.interval drives the workload. It emits its own
telemetry because a standalone example has nothing else to listen to — but the
work runs inside a Cmd, so the handler fires in a different process from the
UI exactly as it would for a real query. Point the same subscription at
[:ecto, :repo, :query] and nothing else changes. Pausing removes only the
interval from subs/1, so you can watch the runtime stop one subscription and
leave the others running.
explorer puts tree, select and menu in one app. Its deps node
starts with nothing loaded: expanding it marks the node in flight, returns
a Cmd, and the fetched children arrive as an ordinary message — the
pattern any tree over a filesystem or a remote node needs. The filter
dropdown opens over the tree, and the filtered node list is rebuilt in
update/2 rather than handed to the widget, because the model owns what
is displayed.
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
The scripts in priv/*_smoke.exs exercise the real runtime and termios NIF
in a pty via script(1): resize, crash restoration, Cmd.exec and typed
input to the program it runs, suspend under a job-control shell, mouse
reporting, and two examples. They run in CI.
./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 about 10k lines of Elixir and under 1k lines of C. Start with lib/harlock/app/runtime.ex — everything
else is reachable from there.
License
MIT. See LICENSE.