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, 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.cpriv/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).