# Changelog
All notable changes to Harlock will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
While on `0.x`, the public API may break between minor versions. Breaking
changes are called out in the relevant release notes.
## [Unreleased]
## [0.4.0] — 2026-05-18
The "absorb the boilerplate" release. v0.3 shipped a complete widget
set; v0.4's job was to stop making app authors hand-wire every key
press through `apply_key` helpers. The runtime now routes navigation
keys directly to focused widgets, the theme grows beyond the four
renderer-only tokens it had, and `:default`-theme rendered output is
verified byte-for-byte against v0.3.0 by a pinned golden-frame test.
There is one breaking change to how key events reach `update/2` — see
Changed.
### Added
- **Theme: full token set, built-in themes, caps-aware color
downgrade.** `%Harlock.Theme{}` picks up four general-purpose
tokens — `:primary`, `:accent`, `:muted`, `:error` — alongside the
four v0.2 renderer tokens. Three built-in themes ship via
`Harlock.Theme.builtin/1`: `:default` (byte-identical to v0.3),
`:dark`, and `:high_contrast`. `Harlock.Render.Style.to_sgr/1`
now consults the internal terminal-capabilities layer and downgrades
RGB and 256-color values to whatever the terminal can display
(truecolor → 6×6×6 cube → nearest of the 16 standard ANSI colors →
`:default` on mono). When no caps are installed the path defaults to
truecolor, preserving v0.3 emission exactly. The capabilities module
gains `__set__`/`__clear__`/`get`/`color_depth` helpers mirroring the
Focus/Theme process-dict pattern; the runtime sets caps before each
render and dispatch.
- **Table style cascade.** The `table/1` element accepts five new
style opts: `:header_style`, `:row_style`, `:alt_row_style`,
`:selected_style`, `:focus_style`. Each defaults to its current
resolution (`header_style` → `Theme.get(:header)`, selection / focus
via the existing tokens, plain rows to `%Style{}`), so existing
apps render unchanged. `:alt_row_style` is the only behavioural
addition — when set, odd-indexed visible rows pick it up for zebra
striping; the default `nil` keeps v0.3 single-style row output.
- **Golden-frame test** (`test/harlock/golden_frame_test.exs`). A
small canonical app exercising every theme-driven render path is
rendered under `Theme.default()`; the raw byte stream is hashed and
pinned in-tree. The pinned hash was independently captured by
running the same app under a git worktree at tag `v0.3.0`, so the
test proves byte-for-byte parity with v0.3 rather than just locking
Phase 3's output to itself. Future changes that silently alter
default output fail CI with a message instructing how to
intentionally re-pin if the drift is wanted.
- **Focus-aware widget key routing (R2).** A focused widget that
carries a `:focusable` id and whose type is one of `:viewport`,
`:tabs`, or `:text_input` no longer needs the app's `update/2` to
receive raw `{:key, …}` events and re-dispatch through the
widget's `apply_key` helper. The runtime calls the helper itself
(`Harlock.Viewport.apply_key/4`, `Harlock.Tabs.apply_key/3`,
`Harlock.TextBuffer.apply_key/3`) and delivers the result as one
of four well-known routed-message tuples:
- `{:harlock_scroll, focus_id, new_offset}` — focused `viewport`
- `{:harlock_select, focus_id, new_id}` — focused `tabs`
- `{:harlock_edit, focus_id, {new_value, new_cursor}}` — focused `text_input`
- `{:harlock_submit, focus_id}` — focused `text_input` saw Enter
The four tuples are documented as public-API contract in
`Harlock.App`'s moduledoc. No-op operations (e.g. `:up` on a viewport
already at offset 0, `:left` on a text input at cursor 0) fall
through to `update/2` as raw `{:key, …}` events so apps can still
react. Opt out per-element with `handle_keys: false`.
Applied on `examples/showcase.exs` (the most key-handling-heavy
example in the repo), measured against the v0.3 manual-dispatch
baseline:
- **Form text-input clause** (Phase 4a, no user-visible change):
21 → 7 lines, **-67%** on that clause. Removed the
`Focus.current()` → `TextBuffer.apply_key/3` → reassemble
`model.form.{values,cursors}` ladder; one
`{:harlock_edit, {:form_field, field}, {v, c}}` clause replaces
it. `alias Harlock.TextBuffer` no longer needed.
- **Logs viewport scroll clause** (Phase 4b, with a keybind
change): 7-line `when key in [...]` guard + helper call → 3-line
`{:harlock_scroll, :logs_viewport, n}` clause. The
`log_visible_height/0` helper and `alias Harlock.Viewport`
dropped out. Tab now goes to runtime focus traversal, so the
Logs tab's alert-row cycling moved from `Tab`/`Shift-Tab` to
`]`/`[`; legend and keybar labels updated.
Across both clauses, the `apply_key`-wiring share of `update/2`
fell from 29 lines (21 form + 7 logs + 1 helper) to 13 lines
(7 + 6), **-55% overall**. No `apply_key` helper is called from
showcase's `update/2` anymore; the file is the worked example
the four routed-message tuples in `Harlock.App`'s moduledoc
point at.
- `examples/overview.exs` — runnable end-to-end example covering focus
traversal, a focusable table with row selection, a focusable viewport
with R2 auto-routing, and a `Cmd` round-trip. The same app body is
embedded in `README.md`, and `test/examples/overview_test.exs`
`Code.require_file`s the example so the README snippet can't rot
silently in CI.
### Changed
- **BREAKING: R2 default-on auto-routing changes how navigation keys
reach `update/2`.** If you have a `viewport`, `tabs`, or `text_input`
widget that carries a `:focusable` id and your `update/2` binds the
keys that widget handles (`:up`/`:down`/`:page_up`/`:page_down`/
`:home`/`:end` for viewport; `:left`/`:right`/`:home`/`:end` for
tabs; printables/arrows/backspace/delete/enter for text_input),
**those key clauses in your `update/2` will silently stop firing
the moment that widget is focused** — the routed
`{:harlock_scroll | _select | _edit | _submit, focus_id, _}`
message arrives instead. See the Event vocabulary section of
`Harlock.App`'s moduledoc for the four shapes, or set
`handle_keys: false` on the element to keep the v0.3 manual-handling
path. This is the single migration step every existing app must
consider; everything else is additive.
- Boundary cases (e.g. `:up` on a viewport already at offset 0,
`:left` on a text input at cursor 0) fall through to `update/2` as
raw `{:key, …}` events so apps that bind those gestures for
out-of-widget actions (focus-out, menu open) still work.
- Tab / Shift-Tab were always consumed by the runtime when any
focusable existed in the tree; v0.4 documents this explicitly in
`Harlock.App`'s moduledoc. Apps that bound Tab for sub-navigation
should rebind to another key when adding focusable widgets — the
binding will be silently shadowed otherwise. (Worked example:
`examples/showcase.exs` rebound Logs-tab alert cycling from Tab to
`]`/`[` in this release.)
- The internal `Focusables.collect/1` traversal now returns
`{ids, traps, routed_widgets}` instead of `{ids, traps}`. The third
element is the focus-id-to-element map the runtime uses for R2
dispatch. Internal API (`@moduledoc false`); affects only callers
that pattern-matched on the previous 2-tuple shape.
## [0.3.0] — 2026-05-13
Demo-quality release. Adds the viewport, the standard widget set
(progress / spinner / statusbar / keybar / tabs), real `:min` / `:max`
layout constraints, telemetry instrumentation, and parser support for
SGR mouse events, modified arrows, and the kitty keyboard protocol.
### Added
- **Viewport element.** `viewport(child:, offset:, content_height:,
scrollbar:)` renders a child taller than its allocated region and
blits the visible slice. App owns the offset (same TEA discipline as
`text_input`). Render pipeline:
- Allocates a `width × content_height` temporary frame, renders the
child into it, blits the window. Cost is O(content × width) per
frame — fine for hundreds of rows. A pull-based windowed source
for 10k-row content is a v0.5 candidate.
- Scroll-into-view is a render-pipeline phase: focusable elements
record their bounds via `Frame.set_focus_rect/2`; the viewport
snaps the effective offset so the focused element stays visible.
Model offset is untouched (render-time adjustment only).
- Cursor positions set by `text_input` are remapped from tall-frame
coords to dst coords when the cursor falls in the visible window;
hidden otherwise. Focused inputs inside a scrolled viewport
position the terminal cursor correctly.
- Optional cosmetic scrollbar via `:scrollbar` opt — single column
on the right edge, thumb proportional to `visible_h / content_h`.
- `Harlock.Viewport.apply_key/4` — pure helper translating
`:up | :down | :page_up | :page_down | :home | :end` into a new
clamped offset. `:page_up` / `:page_down` move `viewport_h - 1`
rows to preserve one row of context. Other keys return offset
unchanged.
- Real `:min` and `:max` layout constraints. `{:min, n}` reserves at
least `n` cells and grows like a fill (weight 1) if there's room.
`{:max, n}` behaves like a fill (weight 1) capped at `n` — excess
from a hit cap redistributes to other flexible slots until the
solver converges. If `:max` caps leave space unallocated
(e.g. `[max: 10, max: 10]` in a 30-cell region), the trailing
region is unused rather than overflowing.
- Standard widgets — composable from existing primitives, dumb
renderers with app-owned state:
- `progress(value:, max:, width:, style:, fill_style:)` — single-
line bar, integer cell fill.
- `spinner(tick:, frames:, style:)` — single cell, frame cycled by
the caller's tick counter (typically driven by `Sub.interval`).
- `statusbar(left:, right:, style:)` — pinned-row helper with left
/ right alignment and middle padding.
- `keybar(bindings:, separator:, right:, style:)` — formats
`[k] label [k] label` from a list of `{key, label}` tuples.
- `tabs(items:, active:, focusable:, style:, active_style:,
separator:)` — single-line tab bar; active tab gets
`Theme.get(:focus)` when the widget is focused,
`Theme.get(:header)` when not.
- `Harlock.Tabs.apply_key/3` — pure helper mapping
`:left | :right | :home | :end` to `{:select, id} | :noop`,
mirroring the `TextBuffer` pattern.
- **Mouse event parser.** SGR encoding only
(`CSI < button;col;row M|m`). Emits
`{:mouse, action, button | nil, col, row, mods}`. Actions:
`:press | :release | :drag | :move | :wheel_up | :wheel_down`.
Buttons: `:left | :middle | :right | :extra4 | :extra5`. Runtime
enabling (writing `\e[?1006h`) and hit-test routing are deferred
— apps that need mouse input can write the enable sequence
themselves and match on raw `(col, row)` in `update/2`.
- **Modified arrow / navigation keys.** `CSI 1;<mod><letter>` for
arrows + Home/End, `CSI <n>;<mod>~` for PageUp/PageDown/Insert/
Delete and F1-F12. Modifier set is `:shift | :alt | :ctrl | :meta`
in any combination — encoded as the XTerm modifier byte minus 1,
with each bit mapped to one modifier.
- **Kitty keyboard protocol parser.** Capability detection response
`CSI ? <flags> u` emits `{:capability, :kitty_keyboard, flags}`.
Key events `CSI <code>[:<shifted>:<base>][;<mod>[:<type>]] u`
with event-type 1 (press) → `{:key, ...}`, 2 (repeat) →
`{:key_repeat, ...}`, 3 (release) → `{:key_release, ...}`. Kitty
private-range codepoints (57344-57375) map to functional-key
atoms. Enabling the protocol (writing `CSI > <flags> u`) is
deferred.
- `:telemetry` instrumentation. Hard dep on `:telemetry ~> 1.2`
(tiny, no transitive deps). Events:
- `[:harlock, :frame, :render, :start | :stop | :exception]` —
span wrapping `view/1` + tree traversal + diff emission.
Metadata: `app`, `dirty`, `rows`, `cols`.
- `[:harlock, :input, :dispatch, :start | :stop | :exception]` —
span wrapping keystroke → `update/2` return. Metadata: `app`,
`event`, `focused`.
- `[:harlock, :cmd, :dispatch]` — one-shot when a cmd is handed
to the task supervisor. Metadata:
`%{kind: :fun | :batch | :map | :none}`.
- `[:harlock, :cmd, :complete]` — when a cmd task returns.
Measurements: `%{duration: native}`. Metadata:
`%{status: :ok | :error}`.
- `[:harlock, :reader, :tty_lost]` — one-shot on EOF (ssh
disconnect, terminal close).
See `Harlock.Telemetry` for the full catalog.
- `examples/showcase.exs` — four-tab tour of v0.3: 200-row
scrollable log viewer with viewport + scrollbar, a long form using
scroll-into-view, a widget gallery with animated
progress/spinner/statusbar/keybar, and a key-event inspector for
modified arrows.
### Changed
- `:min` / `:max` constraints now solve distinctly from `:length`.
In v0.2 these atoms were documented to "behave as `:length`"; code
written against that stub behavior will produce different layouts
in v0.3. Replace `{:min, n}` with `{:length, n}` if you wanted the
v0.2 behavior. `:length`, `:percentage`, and `:fill` are unchanged.
### Removed
- The v0.2 `validate_constraints!` guard that raised on `:min`/`:max`
— those constraints now work.
## [0.2.0] — 2026-05-13
First Hex release. Adds the Cmd executor, SIGWINCH-driven resize, wide-
grapheme width, minimal theme tokens, `text_input`, and a termios NIF
for direct `/dev/tty` control. The library is ready to depend on as
`{:harlock, "~> 0.2"}`.
### Added
- v0.2-prep tooling baseline: `ex_doc`, `dialyxir`, `credo` dev deps;
`mix docs` config; `.credo.exs` tuned green; Dialyzer baseline clean;
GitHub Actions CI running format check, warnings-as-errors compile,
tests, Credo, and Dialyzer.
- `Harlock.Cmd` executor. `Cmd.from/1` runs a 0-arity function under a
per-app `Task.Supervisor` and delivers its return value as a
`{:harlock_event, _}` message; `Cmd.batch/1` dispatches a list
concurrently; `Cmd.map/2` transforms results before delivery, with
nested maps applying inner-first. Task crashes are caught and surfaced
as `{:cmd_error, reason}` events without taking down the runtime. Cmds
returned from `init/1` are dispatched after the first render; cmds
returned alongside `:quit` are dispatched before the runtime exits.
- SIGWINCH-driven terminal resize. `Keeper` installs an
`:os.set_signal(:sigwinch, :handle)` handler in init/1; on signal it
queries the new size via `ioctl(TIOCGWINSZ)` through the termios NIF
and forwards `{:harlock_resize, rows, cols}` to the runtime, which
discards `prev_frame` (full redraw at the new size) and re-renders.
The handler is removed on Keeper terminate so it doesn't leak past
process death.
- `Termios.winsize/1` — TIOCGWINSZ via the NIF, also used by
`Runtime.detect_size` for the initial frame dimensions.
- `Harlock.Test.resize/3` — synthetic resize event for headless tests.
Resizes the test writer's cell buffer in lockstep so the next frame
has somewhere to land.
- `Harlock.Width` — display-column width for terminal rendering. Handles
East Asian Wide / Fullwidth, emoji, regional-indicator flag pairs,
combining marks, ZWJ, and variation selectors. Public surface:
`width/1`, `string_width/1`, `slice/2`, `pad_trailing/3`,
`pad_leading/3`. Ranges sourced from Unicode 15.1 EastAsianWidth.txt.
- `Harlock.Theme` — minimal theme tokens (`:header`, `:focus`,
`:selection`, `:border`). Apps configure via `Harlock.run(MyApp,
arg, theme: %Theme{...})`; omitted = `Theme.default/0` which matches
the pre-theming hard-coded values byte-for-byte. `Theme.get/1` is
available inside `view/1` and `update/2` via the process dict, same
pattern as `Harlock.Focus`. Full token set (`:primary`, `:accent`,
`:muted`, `:error`) plus built-in themes and color downgrade still
land in v0.4.
- `Style.merge/2` — layer one style on top of another (non-default
colors win, booleans OR). Used to apply theme `:focus` to user-set
element styles without losing fg/bg.
- `Harlock.TextBuffer` — pure helpers for editing a `(value, cursor)`
pair: `insert/3`, `delete_backward/2`, `delete_forward/2`, cursor
movement, plus `apply_key/3` mapping a key event to
`{:edit, value, cursor} | :submit | :noop`. Cursor is a grapheme
index; `cursor_column/2` translates to display columns via
`Harlock.Width` (CJK and combining-mark aware).
- `text_input` element — single-line input with `:value`, `:cursor`
(grapheme index), `:focusable`, `:placeholder`, `:placeholder_style`,
`:style`, `:password`. Dumb renderer: the app owns the buffer in its
model and calls `Harlock.TextBuffer.apply_key/3` in `update/2`. When
focused, the renderer positions the terminal cursor at the correct
visual column (wide-grapheme aware).
- `Frame.cursor` (`{row, col} | nil`) plus `Frame.set_cursor/2`. The
diff renderer wraps each frame with cursor-hide before the body and
cursor-position + show after, so the terminal cursor only appears
where a focused widget asks for it.
- `Harlock.Test.cursor/1` — read the current `Frame.cursor` from the
runtime, useful for asserting text-input positioning in tests.
- `Harlock.Terminal.Termios` — NIF wrapping `tcgetattr` / `tcsetattr` /
`ioctl(TIOCGWINSZ)` on `/dev/tty`. Replaces the previous `:os.cmd`-based
termios calls, which never worked: ERTS spawns subprocesses via
`erl_child_setup` with `setsid()`, detaching them from the controlling
terminal so `/dev/tty` returns `ENXIO` in the subshell. The NIF runs
in-process and retains tty access. Dirty I/O scheduler; graceful
fallback when `/dev/tty` is unavailable (CI, piped stdin).
- C build via `elixir_make` and a small `Makefile` driving
`c_src/termios.c` → `priv/termios_nif.so`. Cross-compiles on
macOS (with `-undefined dynamic_lookup`) and Linux.
- End-to-end runtime focus-traversal tests
(`test/harlock/app/runtime_focus_test.exs`): Tab cycles, Shift-Tab
reverses, focus_trap inside an overlay confines cycling to the trap,
trap entry/exit stashes and restores prior focus. The gap of missing
these tests let the focus_trap bug below ship in earlier v0.2 work.
### Fixed
- `overlay(focus_trap: true)` previously included the *background* child
in the trap (the entire overlay subtree), so Tab inside a modal could
leak focus into the underlying widgets and opening a modal would
sometimes move focus to a background id instead of the foreground.
The constructor now sets `focus_trap` on the over element directly.
- Reader's spawn-based `:file.read("/dev/tty")` never delivered bytes
on macOS (verified empirically). Replaced with `enif_select_read` +
non-blocking `read(2)` through the termios NIF, with the Reader as
a single GenServer (no spawn child). EOF on the tty (ssh disconnect,
terminal close) is surfaced as `{:harlock_tty_lost, :eof}` to the
subscriber and the Reader terminates so the supervisor can tear
down cleanly. The subscribe-then-arm sequence also kills the prior
race where bytes arriving before `subscribe/2` were dropped.
- Demo `examples/contacts.exs` Tab focus traversal now actually works.
(Tab failure was a downstream symptom of the broken spawn-read path,
not a demo bug.)
### Changed
- The app supervisor gained a `Task.Supervisor` child positioned between
IO and the runtime (rest_for_one, `:temporary`). It's available when
the runtime's `handle_continue` dispatches the init-time cmd; a
runtime exit terminates all in-flight cmd tasks for free; a
TaskSupervisor crash takes down the runtime cleanly while leaving
IO alive long enough for the terminal restore on shutdown.
- `Render.Cell.char` now accepts `String.t()` in addition to a codepoint
integer, so multi-codepoint graphemes (NFD diacritics, ZWJ sequences,
flag emoji) are stored verbatim rather than NFC-normalized lossily.
- `Render.Frame.write/4` walks `String.graphemes/1` instead of UTF-8
codepoints. Width-2 graphemes occupy two cells with `:continuation`
in the second; the diff renderer skips continuations (no bytes emit).
- Renderer's `clip/2`, `align_text/3`, and `draw_title/6` use
`Harlock.Width` for column math — CJK and emoji content now lays out
to the correct visual width instead of grapheme count.
- `IO.Test.Writer` mirrors real terminal behavior for wide chars: cursor
advances by 2, the trailing cell is marked `:continuation`, and the
reconstructed buffer-to-string output skips continuations.
- Renderer no longer hard-codes `bold: true` for table headers,
`reverse: true` / `bold: true` for focused rows, `bg: :cyan` for
selection, or `reverse: true` for the focus-overlay fallback. All of
these now read from `Harlock.Theme`. The default theme reproduces the
prior visuals exactly.
- The active-vs-inactive focus distinction in tables (was `reverse` when
table focused, `bold` otherwise) collapses to a single `:focus` token
in v0.2. v0.4 may add a separate `:focus_inactive` token if the visual
loss matters in practice.
## [0.1.0] — 2026-05-12
Initial release. Pure-Elixir TUI framework — TEA-style model/update/view
loop on top of OTP, no NIFs, no ports for the core rendering path.
### Added
- OTP supervision tree: `Keeper → Writer → Reader → Runtime` with
`rest_for_one` and `max_restarts: 0`. Terminal is restored on any crash
via `Keeper.terminate/2`.
- TEA loop: `init/1`, `update/2`, `view/1`, optional `subs/1`.
Dirty-flag rendering, no periodic polling.
- Focus traversal (`Tab` / `Shift-Tab`) with focus traps for modals;
automatic stash/restore on open/close.
- Constraint layout solver: `:length`, `:percentage`, `:fill` (with
`:min` / `:max` stubbed as `:length`). Deterministic round-off
absorption; graceful truncation on over-constraint.
- Cell-grid renderer with frame diffing; ANSI output via `Writer`.
- Elements: `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 handling CSI/SS3, bracketed paste, and 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)` (BSD vs util-linux flag handling).
[Unreleased]: https://github.com/thatsme/harlock/compare/v0.4.0...HEAD
[0.4.0]: https://github.com/thatsme/harlock/releases/tag/v0.4.0
[0.3.0]: https://github.com/thatsme/harlock/releases/tag/v0.3.0
[0.2.0]: https://github.com/thatsme/harlock/releases/tag/v0.2.0
[0.1.0]: https://github.com/thatsme/harlock/releases/tag/v0.1.0