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: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:focusable,:focus_trap— same as other elements
@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.