View-tree constructors. Auto-imported into apps via use Harlock.App,
so most apps use text(...), vbox(...), box(...) etc. directly
without qualification.
An element is a plain struct (Harlock.Element); building a view is
just calling these functions to assemble a tree. The renderer walks
the tree once per dirty frame and produces a Frame ready for the
diff renderer.
Primitives
text/2— single-line text contenttext_input/1— single-line editable input (paired withHarlock.TextBuffer)vbox/1/hbox/1— vertical / horizontal stacks with layout constraints (:length,:percentage,:fill)box/1— single-child container with border + title + paddingspacer/0— empty element that occupies a layout slotoverlay/1— render a foreground element on top of a background with optionalfocus_traptable/1/list/2— row-based primitives with selection and focus highlightingcolumn/1— column spec fortable/1
All elements that accept focus take a :focusable opt — the runtime
walks the tree to collect focusable ids for Tab traversal.
Summary
Functions
A single-child container with a border and optional inner padding.
Build a column spec for use inside table/1.
Horizontal stack. Children share the box's height; width is split.
Single-line bar showing key bindings as [k] label [k] label.
Single-column table with chrome hidden. :row_id defaults to & &1
because lists are usually homogeneous; pass an explicit :row_id if
yours aren't.
Vertical menu: a list of labels with one highlighted.
Render over on top of child in a sub-rectangle anchored within the
parent region.
Single-line horizontal progress bar.
Dropdown: a one-line control that opens a list of choices.
Empty cell that occupies a layout slot. Useful with constraints.
Single-line trend line drawn with block glyphs.
Single-cell animated spinner.
Single-line bar with left- and right-aligned text. Useful as the pinned-bottom row of a screen.
Table primitive.
Single-line horizontal tab bar.
A text element. content is rendered as a single line; callers split
multi-line content themselves.
Single-line text input.
Multi-line text area.
Collapsible tree.
Vertical stack. Children share the box's width; height is split according
to :constraints.
Scrollable container.
Functions
@spec box(keyword()) :: Harlock.Element.t()
A single-child container with a border and optional inner padding.
Required options:
:child— the element drawn inside the box
Optional:
:title— string overlaid on the top border (truncated to fit):title_align—:left(default) |:center|:right:border—:single(default) |:double|:rounded|:thick|:none:border_style—%Style{}or keyword applied to the border + title:padding— non-negative integer (uniform),{v, h}, or{top, right, bottom, left}:focusable,:focus_style— when focused, the focus style replaces the border style (the child is left alone):focus_proxy— mirror another element's focus for styling only
For multiple children, wrap them in vbox/1 or hbox/1 and pass the
result as :child. The box reserves one cell on each side for the border
(unless :border is :none); when the region is smaller than that the
border is skipped and the child takes the full region.
:focus_proxy
With focus-aware key routing, :focusable belongs on the interactive widget —
the viewport, the tabs, the text input — because that is what has to receive
the keys. But the box is what a user looks at, so its border goes visually dead
exactly when its contents have focus.
focus_proxy: names a child id to watch, and applies the focus style when
that id is focused:
box(
title: "Log",
focus_proxy: :log,
child: viewport(focusable: :log, offset: m.offset, content_height: n, child: body)
)The box does not become focusable. Focus traversal collects on :focusable
alone, so a proxy is invisible to Tab by construction rather than by being
filtered out afterwards — and Harlock.Focus.current/0 still reports the child.
Setting both :focusable and :focus_proxy on one element is not useful;
:focusable wins.
@spec column(keyword()) :: Harlock.Element.Column.t()
Build a column spec for use inside table/1.
Options:
:title— header label:width— layout constraint (default{:fill, 1}):align—:left|:right|:center:render—fn row -> string | iodata
@spec hbox(keyword()) :: Harlock.Element.t()
Horizontal stack. Children share the box's height; width is split.
Options as vbox/1.
@spec keybar(keyword()) :: Harlock.Element.t()
Single-line bar showing key bindings as [k] label [k] label.
Required:
:bindings— list of{key, label}tuples.keymay be a char like?qor any atom (:tab,:enter); it's rendered viato_string/1.
Optional:
:style—%Style{}(default%Style{reverse: true}):separator— string between bindings (default" "):right— extra right-aligned text (e.g. clock, status)
@spec list( Enumerable.t(), keyword() ) :: Harlock.Element.t()
Single-column table with chrome hidden. :row_id defaults to & &1
because lists are usually homogeneous; pass an explicit :row_id if
yours aren't.
Options:
:render—fn item -> string; defaults toto_string/1- any option accepted by
table/1
@spec overlay(keyword()) :: Harlock.Element.t()
Render over on top of child in a sub-rectangle anchored within the
parent region.
Required options:
:child— the background element (rendered first):over— the foreground element (rendered on top)
Anchor + sizing:
:anchor—:center(default),:top_left,:top_right,:bottom_left,:bottom_right, or{row, col}for absolute placement:width— width of the over region in cells (default: full parent):height— height of the over region in cells (default: full parent)
Focus:
:focus_trap— when true, focus traversal wraps within theoversubtree until the overlay disappears. Prior focus is stashed and restored automatically when the overlay closes.
Overlays nest cleanly: just put another overlay as :over.
@spec progress(keyword()) :: Harlock.Element.t()
Single-line horizontal progress bar.
Required options:
:value— current value (non-negative):max— denominator (positive)
Optional:
:width— explicit bar width in cells (default: full region width):style—%Style{}for the unfilled portion:fill_style—%Style{}for the filled portion
value is clamped to [0, max]. The bar fills
round(value / max * width) cells with █ in fill_style and the
rest with space in style.
@spec select(keyword()) :: Harlock.Element.t()
Dropdown: a one-line control that opens a list of choices.
Required options:
:items— list of{id, label}tuples:value— id of the chosen item, shown on the closed control:open— whether the list is currently open (app-owned)
Optional:
:focusable— focus id; when focused the runtime routes navigation as{:harlock_select, focus_id, id}and the action key as{:harlock_submit, focus_id}:highlight— id highlighted inside the open list, defaulting to:value. Track it separately to letEscapediscard a move.:placeholder— shown closed when:valuematches no item:style—%Style{}for the closed control:marker— glyph marking the control (default"▾","▴"open):max_height— cap the open list's rows (default 8, excluding borders)
The open list draws over whatever follows it in the tree, and flips when it will not fit: above the control near the bottom margin, right-aligned near the right one. So a dropdown on the last row of an 80x24 terminal opens upward rather than off-screen.
select(
items: [{:it, "Italy"}, {:fr, "France"}],
value: m.country,
open: m.open,
focusable: :country
)The app owns :open, so it decides what closes the list. See
Harlock.Select for the bindings, including why Escape falls through.
@spec spacer() :: Harlock.Element.t()
Empty cell that occupies a layout slot. Useful with constraints.
@spec sparkline(keyword()) :: Harlock.Element.t()
Single-line trend line drawn with block glyphs.
Required options:
:values— list of numbers, oldest first
Optional:
:min/:max— pin the scale instead of deriving it from the data:style—%Style{}for the line:glyphs— ramp from lowest to highest, defaulting to~w(▁ ▂ ▃ ▄ ▅ ▆ ▇ █). Pass an ASCII ramp such as~w(_ . - ~ = + * #)for terminals or fonts that render block elements badly.
One cell per value, right-aligned, so the newest sample sits at the right edge and stays there as history scrolls off the left. A series longer than the region keeps its most recent values.
sparkline(values: m.query_times, style: [fg: :cyan])Auto-scaling spans the data, so the line shows shape rather than magnitude —
a series of 100s and a series of 3s look identical. Pin :min and :max when
the absolute level matters, which for a dashboard it usually does. A flat
series draws through the middle of the ramp rather than the bottom, since a
steady value is not the same as a zero one.
The derived range covers the values actually shown, not the whole series, so narrowing the region can change the shape as the scale re-fits to the visible window. Pinning the range removes that effect too.
Complements progress/1 rather than replacing it: a progress bar shows one
fraction, a sparkline shows a history.
@spec spinner(keyword()) :: Harlock.Element.t()
Single-cell animated spinner.
Required options:
:tick— integer; the current animation frame counter (caller-owned in the app's model). Pair with a subscription that increments this on a timer.
Optional:
:frames— list of grapheme strings to cycle through (default: braille spinner["⠋", "⠙", …]):style—%Style{}applied to the rendered frame
Renders Enum.at(frames, rem(tick, length(frames))). The widget
doesn't subscribe to anything itself — wire Harlock.Sub.interval/2
in your app's subs/1 and increment tick in update/2.
@spec statusbar(keyword()) :: Harlock.Element.t()
Single-line bar with left- and right-aligned text. Useful as the pinned-bottom row of a screen.
Options:
:left— string (default""):right— string (default""):style—%Style{}(default%Style{reverse: true})
If left and right together exceed the region width, right is
truncated first.
@spec table(keyword()) :: Harlock.Element.t()
Table primitive.
Required options:
:columns— list ofcolumn/1specs:rows— enumerable of row data, or afn offset, limit -> rowswindow function (see below):row_id— fn(row) -> id. Row identity is by id, not index, so focus and selection survive sort/filter.
Optional:
:focused_row— currently-focused row id:selection—:none|{:single, id}|{:multi, MapSet}:show_header— defaulttrue:offset— first visible row, for a window function (default0):focusable,:focus_trap— same as other elements
Windowed rows
An enumerable is walked, so its length is known and the visible window can be
centred on :focused_row automatically. That is what makes a small table
effortless, and it stays the default.
It also means the whole thing is materialised. For a table backed by a query, a large file, or an unbounded stream, pass a two-argument function instead and it will be asked only for what fits:
table(
columns: cols,
row_id: & &1.id,
offset: m.offset,
rows: fn offset, limit -> MyApp.page(offset, limit) end
)Nothing calls length/1, Enum.drop/2 or Enum.find_index/2 on a window
function, so the cost is one fetch of viewport size no matter how much sits
behind it. Returning fewer rows than limit simply means the end.
Two consequences worth planning for. Auto-centring is impossible — with cursor
or keyset pagination there is no row index to centre on — so :offset is
app-owned, the same way viewport/1 owns its own. And :focused_row still
works, but only styles a row that the current window actually contains.
Harlock has no notion of where the rows come from, deliberately: an
Ecto.Queryable-backed table is a few lines of app code over this function,
and building it in here would mean a terminal UI library depending on a
database library.
@spec tabs(keyword()) :: Harlock.Element.t()
Single-line horizontal tab bar.
Required:
:items— list of{id, label}tuples:active— id of the currently active tab
Optional:
:focusable— focus id; when focused, Left/Right cycle tabs (useHarlock.Tabs.apply_key/3inupdate/2):style—%Style{}for inactive tabs (defaultTheme.get(:header)):active_style—%Style{}for the active tab (defaultTheme.get(:focus)):separator— string between tabs (default" │ ")
Renders only the tab bar — the body for the active tab is rendered separately by the app. Typical pattern:
vbox(
constraints: [length: 1, fill: 1],
children: [
tabs(items: [{:a, "Alpha"}, {:b, "Beta"}], active: m.tab, focusable: :tabs),
case m.tab do
:a -> alpha_body(m)
:b -> beta_body(m)
end
]
)
@spec text( String.t(), keyword() ) :: Harlock.Element.t()
A text element. content is rendered as a single line; callers split
multi-line content themselves.
Options:
:style—%Harlock.Render.Style{}or keyword list of style attrs.
@spec text_input(keyword()) :: Harlock.Element.t()
Single-line text input.
Required options:
:value— the current string contents (caller-owned):cursor— grapheme index of the cursor (0..length):focusable— id for focus traversal
Optional:
:placeholder— shown when value is empty and the input isn't focused:max_length— soft hint; the element doesn't enforce it, butHarlock.TextBuffer.apply_key/3respects it if you wire it in your app:style—%Style{}for the value text:placeholder_style—%Style{}for the placeholder:password— when true, render each grapheme as•
The element is a dumb renderer. The app's update/2 owns the value and
cursor; call Harlock.TextBuffer.apply_key/3 to react to key events
when this input is focused. When focused, the renderer positions the
terminal cursor at the visual column matching :cursor.
@spec textarea(keyword()) :: Harlock.Element.t()
Multi-line text area.
Required options:
:value— the current contents, lines separated by\n(caller-owned):cursor— flat grapheme index of the cursor (0..length):focusable— id for focus traversal
Optional:
:wrap— when true, wrap long lines at the rendered width (defaultfalse, which clips instead):scroll— index of the first visible display row (default0):placeholder— shown when value is empty and the area isn't focused:style—%Style{}for the text:placeholder_style—%Style{}for the placeholder
Like text_input/1 this is a dumb renderer: the app's update/2 owns the
value and cursor, and Harlock.TextArea.apply_key/3 maps key events onto
them. When the area is focused the runtime routes keys automatically and
delivers {:harlock_edit, focus_id, {value, cursor}} — the same message a
text_input produces, because both use the same (value, cursor) shape.
With wrap: true long lines break across display rows at word boundaries,
and ↑ / ↓ / Home / End follow those rows rather than logical lines.
Without it, long lines are clipped.
:scroll is app-owned in the same way viewport/1 owns :offset, and
counts display rows; the renderer adjusts it by the minimum needed to keep
the cursor on screen, so passing nothing still draws a usable cursor. See
Harlock.TextArea.scroll_to_reveal/5 for threading it back onto the model.
@spec tree(keyword()) :: Harlock.Element.t()
Collapsible tree.
Required options:
:nodes— list of node maps; seeHarlock.Treefor the shape:expanded—MapSetor list of expanded node ids:focused— id of the highlighted row
Optional:
:focusable— focus id; when focused the runtime routes movement as{:harlock_select, focus_id, node_id}, expand/collapse as{:harlock_toggle, focus_id, node_id}, andEnteron a leaf as{:harlock_submit, focus_id}:scroll— index of the first visible row (default0):style—%Style{}for rows:focused_style—%Style{}for the highlighted row:guide_style—%Style{}for the├/└guides (default dim):loading_label— suffix on a node awaiting children (default" …")
Expansion is keyed by node id, never by row index: collapsing a node shifts every row below it, so index keys would move the selection to an unrelated node.
tree(
nodes: m.nodes,
expanded: m.expanded,
focused: m.focused,
focusable: :files
)
def update({:harlock_select, :files, id}, m), do: %{m | focused: id}
def update({:harlock_toggle, :files, id}, m), do: toggle(m, id)Nodes whose :children are :unloaded expand asynchronously — flip them to
:loading, return a Cmd, and swap the fetched list in when it arrives.
Rows past the region clip; wrap in a viewport/1 for a tree taller than its
space.
@spec vbox(keyword()) :: Harlock.Element.t()
Vertical stack. Children share the box's width; height is split according
to :constraints.
Options:
:constraints— list of layout constraints, one per child. Defaults to[fill: 1]for each child if not provided.:children— list of child elements.
@spec viewport(keyword()) :: Harlock.Element.t()
Scrollable container.
Required options:
:child— the element to scroll:offset— top-row offset into the child (0-indexed, app-owned):content_height— total rows the child occupies
Optional:
:scrollbar— render a single-column cosmetic scrollbar on the right edge (defaultfalse). The scrollbar consumes one column from the child's available width.:scrollbar_style—%Style{}for the scrollbar track + thumb
The viewport renders the child into a temporary frame of
width × content_height, then blits rows offset..offset+visible_height
into the real region. The app owns the scroll offset; pair with
Harlock.Viewport.apply_key/4 in update/2 to translate scroll-key
events into new offsets.
Vertical-only for now. The child is given full width (minus scrollbar column if enabled) so horizontal layout proceeds normally.