All notable changes to Harlock will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
While on 0.x, the public API may break between minor versions. Breaking
changes are called out in the relevant release notes.
Unreleased
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,credodev deps;mix docsconfig;.credo.exstuned green; Dialyzer baseline clean; GitHub Actions CI running format check, warnings-as-errors compile, tests, Credo, and Dialyzer.Harlock.Cmdexecutor.Cmd.from/1runs a 0-arity function under a per-appTask.Supervisorand delivers its return value as a{:harlock_event, _}message;Cmd.batch/1dispatches a list concurrently;Cmd.map/2transforms 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 frominit/1are dispatched after the first render; cmds returned alongside:quitare dispatched before the runtime exits.SIGWINCH-driven terminal resize.
Keeperinstalls an:os.set_signal(:sigwinch, :handle)handler in init/1; on signal it queries the new size viaioctl(TIOCGWINSZ)through the termios NIF and forwards{:harlock_resize, rows, cols}to the runtime, which discardsprev_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 byRuntime.detect_sizefor 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 viaHarlock.run(MyApp, arg, theme: %Theme{...}); omitted =Theme.default/0which matches the pre-theming hard-coded values byte-for-byte.Theme.get/1is available insideview/1andupdate/2via the process dict, same pattern asHarlock.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:focusto 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, plusapply_key/3mapping a key event to{:edit, value, cursor} | :submit | :noop. Cursor is a grapheme index;cursor_column/2translates to display columns viaHarlock.Width(CJK and combining-mark aware).text_inputelement — 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 callsHarlock.TextBuffer.apply_key/3inupdate/2. When focused, the renderer positions the terminal cursor at the correct visual column (wide-grapheme aware).Frame.cursor({row, col} | nil) plusFrame.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 currentFrame.cursorfrom the runtime, useful for asserting text-input positioning in tests.Harlock.Terminal.Termios— NIF wrappingtcgetattr/tcsetattr/ioctl(TIOCGWINSZ)on/dev/tty. Replaces the previous:os.cmd-based termios calls, which never worked: ERTS spawns subprocesses viaerl_child_setupwithsetsid(), detaching them from the controlling terminal so/dev/ttyreturnsENXIOin the subshell. The NIF runs in-process and retains tty access. Dirty I/O scheduler; graceful fallback when/dev/ttyis unavailable (CI, piped stdin).C build via
elixir_makeand a smallMakefiledrivingc_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 setsfocus_trapon the over element directly.- Reader's spawn-based
:file.read("/dev/tty")never delivered bytes on macOS (verified empirically). Replaced withenif_select_read+ non-blockingread(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 beforesubscribe/2were dropped. - Demo
examples/contacts.exsTab 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.Supervisorchild positioned between IO and the runtime (rest_for_one,:temporary). It's available when the runtime'shandle_continuedispatches 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.charnow acceptsString.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/4walksString.graphemes/1instead of UTF-8 codepoints. Width-2 graphemes occupy two cells with:continuationin the second; the diff renderer skips continuations (no bytes emit).- Renderer's
clip/2,align_text/3, anddraw_title/6useHarlock.Widthfor column math — CJK and emoji content now lays out to the correct visual width instead of grapheme count. IO.Test.Writermirrors 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: truefor table headers,reverse: true/bold: truefor focused rows,bg: :cyanfor selection, orreverse: truefor the focus-overlay fallback. All of these now read fromHarlock.Theme. The default theme reproduces the prior visuals exactly. - The active-vs-inactive focus distinction in tables (was
reversewhen table focused,boldotherwise) collapses to a single:focustoken in v0.2. v0.4 may add a separate:focus_inactivetoken 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 → Runtimewithrest_for_oneandmax_restarts: 0. Terminal is restored on any crash viaKeeper.terminate/2. - TEA loop:
init/1,update/2,view/1, optionalsubs/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/:maxstubbed 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.Testbackend selectable viabackend: :testfor deterministic tests without a TTY. - Examples:
counter,sysmon. - Smoke tests driven by
script(1)(BSD vs util-linux flag handling).