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.4.1 — 2026-05-18
Patch release. One test-stability bug that affects downstream consumers running their suites against Harlock on CI, plus a small typespec + docs cleanup that gets the v0.4.x hexdocs build to zero warnings. No runtime behaviour change for end users; no API additions or removals.
Fixed
- The
:testbackend now uses deterministic truecolor caps instead of consulting host$TERM, so tests that render throughHarlock.Test.start_app/3produce identical bytes regardless of where they run. Previously:$TERMunset (GitHub Actions runners) ordumb→ caps classified as:mono→Style.to_sgr/1stripped every color SGR → test assertions on color output (golden frame, table style cascade) failed in CI while passing locally. The:terminalbackend still detects host caps and downgrades for genuinely color-poor terminals — that's the feature; only the test backend was wrong to inherit it. NewCaps.test_defaults/0exposes the synthetic struct for explicit use.
Changed (internal / docs only — no public API change)
Harlock.Render.Style.downgrade/2typespec uses a localStyle.depthtype (:mono | :ansi16 | :ansi256 | :truecolor) instead of the hiddenCaps.color_depth/0. Functionally identical; hexdocs.pm can now resolve the type reference.- CHANGELOG, ROADMAP, and
Stylemoduledoc dropped link-form references to@moduledoc falsemodules so the docs build is warning-free. The information stayed; the auto-link targets didn't resolve before and silently 404'd on hexdocs.pm. - ROADMAP
docs/feedback-v0.3.mdanddocs/v0.4-plan.mdreferences switched to full GitHub URLs so the links resolve from hexdocs.pm (those files are dev-facing and intentionally not in the hex tarball).
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 viaHarlock.Theme.builtin/1::default(byte-identical to v0.3),:dark, and:high_contrast.Harlock.Render.Style.to_sgr/1now 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 →:defaulton mono). When no caps are installed the path defaults to truecolor, preserving v0.3 emission exactly. The capabilities module gains__set__/__clear__/get/color_depthhelpers mirroring the Focus/Theme process-dict pattern; the runtime sets caps before each render and dispatch.Table style cascade. The
table/1element 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_styleis the only behavioural addition — when set, odd-indexed visible rows pick it up for zebra striping; the defaultnilkeeps 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 underTheme.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 tagv0.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
:focusableid and whose type is one of:viewport,:tabs, or:text_inputno longer needs the app'supdate/2to receive raw{:key, …}events and re-dispatch through the widget'sapply_keyhelper. 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}— focusedviewport{:harlock_select, focus_id, new_id}— focusedtabs{:harlock_edit, focus_id, {new_value, new_cursor}}— focusedtext_input{:harlock_submit, focus_id}— focusedtext_inputsaw Enter
The four tuples are documented as public-API contract in
Harlock.App's moduledoc. No-op operations (e.g.:upon a viewport already at offset 0,:lefton a text input at cursor 0) fall through toupdate/2as raw{:key, …}events so apps can still react. Opt out per-element withhandle_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→ reassemblemodel.form.{values,cursors}ladder; one{:harlock_edit, {:form_field, field}, {v, c}}clause replaces it.alias Harlock.TextBufferno 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. Thelog_visible_height/0helper andalias Harlock.Viewportdropped out. Tab now goes to runtime focus traversal, so the Logs tab's alert-row cycling moved fromTab/Shift-Tabto]/[; legend and keybar labels updated.
Across both clauses, the
apply_key-wiring share ofupdate/2fell from 29 lines (21 form + 7 logs + 1 helper) to 13 lines (7 + 6), -55% overall. Noapply_keyhelper is called from showcase'supdate/2anymore; the file is the worked example the four routed-message tuples inHarlock.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 aCmdround-trip. The same app body is embedded inREADME.md, andtest/examples/overview_test.exsCode.require_files 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 aviewport,tabs, ortext_inputwidget that carries a:focusableid and yourupdate/2binds the keys that widget handles (:up/:down/:page_up/:page_down/:home/:endfor viewport;:left/:right/:home/:endfor tabs; printables/arrows/backspace/delete/enter for textinput), those key clauses in yourupdate/2will 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 ofHarlock.App's moduledoc for the four shapes, or sethandle_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.
:upon a viewport already at offset 0,:lefton a text input at cursor 0) fall through toupdate/2as 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.exsrebound Logs-tab alert cycling from Tab to]/[in this release.) - The internal
Focusables.collect/1traversal 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 astext_input). Render pipeline:- Allocates a
width × content_heighttemporary 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_inputare 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
:scrollbaropt — single column on the right edge, thumb proportional tovisible_h / content_h.
- Allocates a
Harlock.Viewport.apply_key/4— pure helper translating:up | :down | :page_up | :page_down | :home | :endinto a new clamped offset.:page_up/:page_downmoveviewport_h - 1rows to preserve one row of context. Other keys return offset unchanged.Real
:minand:maxlayout constraints.{:min, n}reserves at leastncells and grows like a fill (weight 1) if there's room.{:max, n}behaves like a fill (weight 1) capped atn— excess from a hit cap redistributes to other flexible slots until the solver converges. If:maxcaps 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 bySub.interval).statusbar(left:, right:, style:)— pinned-row helper with left / right alignment and middle padding.keybar(bindings:, separator:, right:, style:)— formats[k] label [k] labelfrom a list of{key, label}tuples.tabs(items:, active:, focusable:, style:, active_style:, separator:)— single-line tab bar; active tab getsTheme.get(:focus)when the widget is focused,Theme.get(:header)when not.
Harlock.Tabs.apply_key/3— pure helper mapping:left | :right | :home | :endto{:select, id} | :noop, mirroring theTextBufferpattern.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)inupdate/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 | :metain 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> uemits{:capability, :kitty_keyboard, flags}. Key eventsCSI <code>[:<shifted>:<base>][;<mod>[:<type>]] uwith 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 (writingCSI > <flags> u) is deferred.:telemetryinstrumentation. Hard dep on:telemetry ~> 1.2(tiny, no transitive deps). Events:[:harlock, :frame, :render, :start | :stop | :exception]— span wrappingview/1+ tree traversal + diff emission. Metadata:app,dirty,rows,cols.[:harlock, :input, :dispatch, :start | :stop | :exception]— span wrapping keystroke →update/2return. 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.Telemetryfor 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/:maxconstraints 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:fillare 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,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).