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.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).