# Harlock Roadmap
A pure-Elixir TUI framework for Unix terminals. TEA-style model/update/view
loop on top of OTP, no NIFs, no ports for the core rendering path.
This roadmap is the working plan from v0.1 (current) to v1.0 (Hex-publishable,
stable API). It's a living document — revise as we learn.
## Status snapshot (v0.1, current)
What works:
- OTP supervision tree (`Keeper → Writer → Reader → Runtime`, `rest_for_one`,
`max_restarts: 0`). Terminal is restored on any crash via
`Keeper.terminate/2`. This is correct and load-bearing — don't regress it.
- TEA loop with `init/1`, `update/2`, `view/1`, optional `subs/1`. Dirty-flag
rendering, no periodic polling.
- Focus traversal (`Tab` / `Shift-Tab`), focus traps for modals with
automatic stash/restore on open/close.
- Constraint layout solver: `:length`, `:percentage`, `:fill`. Deterministic
round-off absorption; graceful truncation on over-constraint (logs warning,
never crashes).
- Cell-grid renderer with frame diffing. ANSI output via `Writer`.
- Primitives: `text`, `vbox`, `hbox`, `spacer`, `box` (4 border styles +
title + padding), `overlay` (5 anchors + focus trap), `table` / `list`
(row-id identity, single/multi selection, header).
- Input parser handles CSI/SS3, bracketed paste, XTerm focus reporting.
- Headless `IO.Test` backend selectable via `backend: :test` for
deterministic tests without a TTY.
- Examples: `counter`, `sysmon`. Smoke tests driven by `script(1)` (handles
BSD vs util-linux flag differences).
What's stubbed / missing — the honest list:
- `Cmd` executor: runtime ignores the `{model, cmd}` return tuple entirely.
No async work, no IO, no HTTP. **The single biggest hole.**
- `Sub`: only `:interval` exists.
- Layout: `:min` and `:max` constraints behave as `:length` (documented).
- No SIGWINCH handling — terminal resize doesn't reflow.
- No `text_input`, `viewport`, `progress`, `spinner`, `tabs`, `tree`,
`menu`/`select`, `keybar`/`statusbar`.
- No mouse, no kitty keyboard protocol, no modified arrows (`CSI 1;5A`).
- No wide-grapheme width (CJK, emoji). `String.length` ≠ visual columns.
- `Style` not consistently cascaded (table headers hard-code `bold`,
focused box hard-codes `reverse`).
- No `row.ex` helper (only `column.ex`).
- `mix.exs` has no package metadata. README is the `mix new` default.
- No CI, no Dialyzer, no Credo wired in.
## Guiding principles
1. **OTP-first.** The supervision tree is the architecture. New features
that need processes get their own child, supervised correctly.
2. **No NIFs in the core.** ANSI in, ANSI out. NIFs only if a NIF-optional
path measurably ships (e.g. termios via port).
3. **Phoenix devs feel at home.** `update/view/subs` mirrors LiveView's
mental model on purpose. `Sub.pubsub` should be a first-class citizen.
4. **Headless-testable.** Every new widget gets `IO.Test`-driven coverage.
No "works on my terminal" features.
5. **Terminal restoration is sacred.** Any new IO path must survive crashes
without leaving the terminal in raw mode / alt-screen. Test it.
6. **Honest stubs over fake completeness.** Stub modules clearly say so in
their moduledoc. No silent half-features.
## Versioning
- **0.x** — API may break. We document breaking changes in CHANGELOG.
- **1.0** — locked public API for `Harlock`, `Harlock.App`, `Harlock.Elements`,
`Harlock.Cmd`, `Harlock.Sub`, `Harlock.Render.Style`, `Harlock.Layout`.
Internal modules — Harlock.App.Runtime, the rest of Harlock.Terminal.\*,
Harlock.Element.Renderer — stay `@moduledoc false` and remain free
to change without notice.
---
## v0.2-prep — tooling first (do before any v0.2 implementation)
CI gates the v0.2 implementation work; it does not land alongside it.
Otherwise the Cmd executor, SIGWINCH path, and text_input land without
type-checking or lint to catch regressions, and the first green build is
also the first build that has to satisfy every new check at once.
- Add dev deps: `:ex_doc`, `:dialyxir`, `:credo`. Wire `mix docs`.
- `.github/workflows/ci.yml`: `mix format --check-formatted`, `mix test`,
`mix dialyzer`, `mix credo --strict` on push + PR.
- `.credo.exs` tuned to a green baseline on v0.1 code.
- Dialyzer PLT cached in CI; baseline clean on v0.1 before opening v0.2 PRs.
- `CHANGELOG.md` seeded from v0.1.
---
## v0.2 — "actually usable" (~2 weeks)
The minimum set that lets someone build a real internal tool. Ship together.
### Cmd executor (the unblocker)
The runtime currently destructures `{model, _cmd}` and throws the cmd away.
Wire a real executor.
- Add a Task.Supervisor child to the app supervisor, positioned
**between IO and Runtime** so it's already up when Runtime's
`handle_continue` dispatches the init-time cmd. (Earlier plan said
"after Runtime"; that lost the init-cmd race against the supervisor's
child-start loop.) Strategy: `:one_for_one`, restart `:temporary` on
individual tasks.
- Implement `Cmd.from/1`: runs the 0-arity fn under `Task.Supervisor`,
sends result back as `{:harlock_event, result}`.
- Implement `Cmd.batch/1`: spawns each child cmd concurrently, no
ordering guarantees.
- Add `Cmd.map/2` for tagging results: `Cmd.map(cmd, fn r -> {:tag, r} end)`.
- Tasks linked to runtime; if runtime exits, tasks die. If a task crashes,
log + emit `{:harlock_event, {:cmd_error, reason}}` (don't kill runtime).
- Runtime: on `update/2` returning `{model, cmd}`, dispatch cmd, update
model, render. On `:quit` with cmd, dispatch then quit.
Tests: cmd that sleeps then returns; cmd that crashes; batch of three;
quit-with-cmd ordering.
### SIGWINCH + resize event
Terminal resize must reflow. Without this we can't ship.
- Use `:os.set_signal(:sigwinch, :handle)` (OTP 22+) in `Keeper` (the
TTY-owning process). Signal arrives as `{:signal, :sigwinch}` in
Keeper's mailbox.
- Keeper queries new size via `ioctl(TIOCGWINSZ)` through the termios
NIF (`Harlock.Terminal.Termios.winsize/1`). The original plan
considered `tput cols`/`tput lines` to avoid native code, but
`:os.cmd`-based shell-outs lose the controlling tty (every shell
spawned by ERTS is `setsid()`-detached), so a NIF was needed for
termios anyway — TIOCGWINSZ goes through the same one.
- Keeper sends `{:harlock_resize, rows, cols}` to Runtime.
- Runtime handles `{:harlock_resize, _, _}`: update `state.rows` /
`state.cols`, discard `prev_frame` (full redraw — diff against
differently-sized buffer is meaningless), mark dirty, render.
- Initial size at Runtime startup also comes from `Keeper.size/1`
(synchronous `GenServer.call`, no race because Keeper starts first
in the supervision tree). Test backend supplies explicit rows/cols
via opts.
Tests: simulate resize event into `IO.Test` runtime, assert reflow
(`test/harlock/resize_test.exs`).
### Wide-grapheme width (prerequisite for text_input)
`String.length` ≠ display columns. Pulled into v0.2 from v0.3 because
text_input cursor math depends on it — shipping text_input first would
either land with broken CJK/emoji handling or force a width retrofit later.
Verified usage at `renderer.ex:188, 194, 200, 308, 315–325` and called out
in code at `renderer.ex:323-324` and `cell.ex:7-8`.
- New module `Harlock.Width`: `width(grapheme)` returns 0/1/2 based on
Unicode East Asian Width + emoji presentation. Static table — pull from
Unicode 15.1 EastAsianWidth.txt at build time via `mix harlock.gen_width`.
- Replace `String.length` with `Harlock.Width.string_width/1` in:
`clip/2`, `align_text/3`, `draw_title/6`, table column rendering,
and the new `text_input` cursor math.
- Zero-width joiner / variation selectors: keep with the preceding
grapheme, don't advance the cursor.
- `Cell` stays one codepoint per cell; wide graphemes occupy `cell + cell'`
where `cell'` is a sentinel "continuation" cell. Frame diffing and clip
math must skip continuations.
Tests: "héllo" (combining), "東京" (wide), "🇮🇹" (regional indicator pair),
"👨👩👧" (ZWJ sequence).
### Minimal theme tokens (prerequisite for v0.3 widgets)
Replace the hard-coded styles in the renderer with theme lookups now, so
v0.3 widgets (`progress`, `tabs`, `statusbar`, `keybar`) aren't built
against hard-coded values that need a sweep in v0.4. Full theming
ergonomics still land in v0.4 — this is the minimum surface to avoid
rework.
- `Harlock.Theme` struct with the four tokens the current renderer needs:
`header`, `focus`, `selection`, `border`. Full token set (`primary`,
`accent`, `muted`, `error`) deferred to v0.4.
- `Harlock.Theme.get(token)` available in callbacks via process dict
(same pattern as `Focus`).
- App passes `theme: %Theme{...}` to `Harlock.run/3`; omitted = built-in
default that matches today's hard-coded values exactly (no v0.1 → v0.2
visual diff for existing apps).
- Replace hard-coded styles at `renderer.ex:165` (`%Style{bold: true}`
header), `:211-212` (`%Style{reverse: true}` / `bold: true` focused row),
`:254` (focus default), `:214, 217` (`bg: :cyan` selection) with theme
lookups.
Tests: app with custom theme overrides each token; app with no theme
renders byte-identical to v0.1 baseline (golden frame).
### text_input element
Single-line first. The cursor is a new concept — `Frame` needs to track it.
- Add `cursor: {row, col} | nil` to `Frame`. `Writer` emits cursor-show /
cursor-position after diff, or cursor-hide if nil.
- `text_input` element opts: `:value` (caller-owned, model holds state),
`:placeholder`, `:focusable` (required), `:on_change` (msg to send),
`:max_length`, `:style`, `:placeholder_style`, `:password` (mask with `•`).
- Runtime injects key events to the focused `text_input`'s `:on_change`
with shape `{msg, {:input_event, kind, payload}}` where kind is
`:insert | :delete | :move | :submit`. App owns the buffer.
- Helper module `Harlock.TextBuffer` (pure functions: `insert/3`,
`delete_backward/2`, `move_cursor/2`, etc.). App calls it from `update/2`.
Keeps the element dumb, model honest.
- Keys: printable chars insert, `Backspace` deletes-back, `Delete`
deletes-forward, `Left`/`Right` move, `Home`/`End` jump, `Enter` submits.
Defer to v0.3: multi-line, IME, word movement (`Ctrl-Left`/`Ctrl-Right`),
selection.
### viewport element
Generic scrollable container.
- `viewport(child: el, height: n, offset: model.offset, on_scroll: msg)`.
- Renders child into a virtual `Frame` of `{requested_w, large_h}`, then
blits the visible slice into the real region.
- Emits scroll messages on `PgUp`/`PgDn`/`Up`/`Down` when focused.
- App owns the offset (same TEA discipline as text_input).
### Presentation track (parallel with v0.2 implementation)
Tooling itself ships in v0.2-prep (above). What's left here is the
external-facing surface that gates adoption:
- Fill `mix.exs`: `description`, `package` (licenses, links, maintainers),
`source_url`, `homepage_url`, `docs` config (main: "readme", extras).
- Replace `README.md`: 30-second pitch, screenshot/GIF, install snippet,
minimum counter example, status table linking to ROADMAP, "why not X"
(vs Owl, Ratatouille, ratatui-via-port).
- Asciinema cast of `sysmon` embedded in README.
---
## v0.3 — "shows well in demos" (~3 weeks after v0.2)
### Layout: real :min and :max
Two-pass solver:
1. Sum all `:min` and `:length`. If > total, behave as today (truncate
from tail, warn).
2. Distribute remainder across `:fill` constraints proportional to weight.
3. Apply `:max` caps: any fill exceeding its `:max` gets clamped, the
excess redistributed to non-capped fills. Iterate to fixpoint
(max 3 passes, bail with warning otherwise).
Tests: percentage + min + fill + max combinations; over-constraint;
unsatisfiable max.
### Standard widgets
All composable from existing primitives; ship as named modules for
ergonomics and consistency.
- `progress(:value, :max, :width, :style, :fill_style)` — single-line bar.
- `spinner(:frames, :interval, :style)` — uses `Sub.interval` internally.
Caller passes a model field for the tick counter (TEA discipline).
- `tabs(:items, :active, :on_select, :focusable)` — horizontal bar +
region underneath, focus integrates with traversal.
- `statusbar(:left, :right, :style)` — pinned-bottom helper, often paired
with `vbox` and `{:length, 1}`.
- `keybar(:bindings, :style)` — `[{?q, "quit"}, {?n, "new"}]` → rendered
`[q] quit [n] new`.
### Mouse events
SGR mouse mode `\e[?1006h` on init, `\e[?1006l` on teardown (via Caps +
Writer).
- Parser emits `{:mouse, action, {row, col}, mods}` where action is
`:press | :release | :drag | :wheel_up | :wheel_down` and button is
`:left | :middle | :right` for press/release/drag.
- Runtime does a hit-test against the last `Frame` (frames carry
element-id metadata for hit-testable cells — new infra) and routes to
the right element.
- Defer global mouse-down dragging across regions to v0.4.
### Modified arrows + kitty keyboard protocol
- `CSI 1;<mod><letter>` for `Ctrl/Alt/Shift + arrows/home/end`.
- Detect kitty support via `\e[?u` query at startup, set protocol level if
supported. Falls back to legacy parsing otherwise.
- Emit unified `{:key, key, mods}` events regardless of source protocol.
---
## v0.4 — "polish & adoption" (~4 weeks)
### Theming (full token set)
The four-token minimum (`header`, `focus`, `selection`, `border`) landed
in v0.2. v0.4 extends to the full palette and ships the ergonomics around
it:
```elixir
%Theme{
primary: :cyan,
accent: :magenta,
muted: %Style{fg: {:rgb, 128, 128, 128}},
error: :red,
border: %Style{fg: :white},
focus: %Style{reverse: true},
header: %Style{bold: true},
selection: %Style{bg: :cyan}
}
```
- Add the remaining tokens (`primary`, `accent`, `muted`, `error`) and
any per-widget tokens surfaced during v0.3 widget work.
- Built-in themes: `:default`, `:dark`, `:high_contrast`.
- Auto-downgrade truecolor → 256 → 16 based on `Caps`.
- App still configures via `Harlock.run(MyApp, init_arg, theme: theme)`;
`Harlock.Theme.get/1` already exists from v0.2.
### Style cascade
- `box` propagates `:border_style` to title.
- `table` accepts `:header_style`, `:row_style`, `:alt_row_style`,
`:selected_style`, `:focus_style` (all default to theme).
- `Style.merge/2` with proper inheritance (child overrides parent;
unspecified attrs inherit).
### tree / menu / select widgets
- `tree(:nodes, :expanded, :focused, :on_toggle, :on_select)` — recursive
node display with expand/collapse on `Right`/`Left` or `Enter`.
- `menu(:items, :on_select)` — vertical list, arrow navigation, `Enter`
selects.
- `select(:items, :value, :on_change)` — dropdown (uses `overlay` for the
open state).
### Multi-line text_area
Builds on `text_input` + `viewport`. Word wrap, soft/hard line breaks,
proper cursor across wraps. Word movement (`Ctrl-Left`/`Ctrl-Right`),
line movement (`Up`/`Down` preserving visual column).
### Richer Sub kinds
- `Sub.pubsub(pubsub_mod, topic, transform_fn)` — subscribes via
`Phoenix.PubSub`. Killer integration for Phoenix-based ops dashboards.
- `Sub.file(path, opts)` — watch via `:fs` if available, polling fallback.
- `Sub.signal(:sigusr1, msg)` — wraps `:os.set_signal/2`.
- `Sub.port(cmd, args)` — long-running external process, stdout lines as
events.
---
## v0.5 — pre-1.0 hardening (~3 weeks)
- **Dialyzer clean** at `:underspecs` + `:overspecs`. Strict specs on all
public functions.
- **Property-based tests** for layout solver (StreamData): for any list of
constraints summing to ≥ 0, output sizes are non-negative and sum to
total. For any frame diff: replay produces equivalent frame.
- **Benchmarks**: `Harlock.Bench` — render a 200×80 frame with N elements,
measure µs per frame. Establish baseline, prevent regressions.
- **Documentation**: every public function has `@doc` + example. Module
guides (`guides/getting_started.md`, `guides/widgets.md`,
`guides/testing.md`, `guides/embedding.md`).
- **Examples expansion**: filemgr (two-pane), todo (text_input + list),
log_viewer (viewport + filter), git_branch_picker (tree).
- **Caps refinement**: detect terminfo entries properly; fallback table for
common terminals. Document the `Caps` API as public.
- **Public API freeze candidate**: walk every `@moduledoc false`, decide
stable-public vs internal-forever. Move stable parts to `@moduledoc`
proper.
## v1.0 — stable API, Hex release
- Public API frozen per the `@moduledoc` decisions above.
- All v0.5 hardening complete.
- Hex publish, hexdocs.pm live.
- Announcement post + Reddit/Elixir Forum thread.
- Minimum supported: Elixir 1.17+, OTP 26+ (decide closer to date).
---
## Out of scope (probably forever)
- Windows native console. The TTY layer is POSIX-only and that's fine.
WSL works.
- Rendering anything other than monospace cells. No Sixel, no Kitty
graphics protocol, no images. Different project.
- Web export. Different project.
## Stretch / "would be cool"
- **`Harlock.LiveView`** — a Phoenix LiveView hook that renders the same
element tree to HTML for remote viewing. Same model, two backends.
Probably v1.x.
- **Hot reload** of the app module during dev. Possible because TEA is
pure — re-invoke `view/1` after code change. Needs careful supervisor
handling.
- **Recording / replay** — log every event + final frame, replay
deterministically for bug reports. Useful for headless testing too.
## Notes for whoever picks this up
- The runtime is the spine. Don't add state to it casually — every field
is load-bearing for focus/render/sub lifecycle. New features that need
process state usually want their own GenServer, not a runtime field.
- The renderer is pure. Keep it that way. If you find yourself wanting
`IO.puts` in there, you're doing it wrong.
- Always test the crash path. `smoke_crash/0` is the template — kill a
linked process mid-render, assert the terminal is restored. New IO
paths get the same treatment.
- Read the `App.Supervisor` comment block before changing supervisor
config. The `rest_for_one` + `:temporary` runtime + Keeper-first
ordering is the entire correctness argument for clean teardown.