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.
Layout
vbox/1/hbox/1— vertical / horizontal stacks with layout constraints (:length,:percentage,:fill,:min,:max; seeHarlock.Layout)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_trapviewport/1— scrollable container
Content
text/2— text content: styled runs, newlines, optional wrap and aligntable/1/list/2— row-based primitives with selection and focus highlighting;column/1specifies a table columntree/1— expandable hierarchy with lazily loaded childrenprogress/1,spinner/1,sparkline/1— progress and trendsstatusbar/1,keybar/1— single-line bars
Input
button/2/checkbox/2— focusable controlstext_input/1/textarea/1— single- and multi-line editable texttabs/1,menu/1,select/1— choosing among items
Focus and routing
Elements that accept focus take a :focusable id; the runtime walks the
tree to collect those ids for Tab traversal. A focused widget has its keys
routed for it and the result delivered as a message — see Harlock.App
for the vocabulary. Two options apply to every focusable element:
handle_keys: false delivers raw key events instead, and
handle_mouse: false keeps the element from being a mouse target.
Summary
Functions
A single-child container with a border and optional inner padding.
A button, drawn as [ label ].
A checkbox, drawn as [x] label or [ ] label.
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.
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 button( Harlock.Text.t(), keyword() ) :: Harlock.Element.t()
A button, drawn as [ label ].
When focused, Enter or Space delivers {:harlock_submit, focus_id}:
button("Save", focusable: :save)
def update({:harlock_submit, :save}, m), do: save(m)label is anything text/2 accepts, so part of it can be styled.
Required:
:focusable— focus id. A button that cannot be focused cannot be pressed.
Optional:
:style—%Style{}or keyword list for the button.:focus_style— style when focused (default::stylemerged withTheme.get(:focus)).:handle_keys—falsedelivers the raw{:key, …}instead.
@spec checkbox( Harlock.Text.t(), keyword() ) :: Harlock.Element.t()
A checkbox, drawn as [x] label or [ ] label.
The app owns the checked state. When focused, Space or Enter delivers
{:harlock_toggle, focus_id, checked} carrying the new value, so the
clause only writes it back:
checkbox("Notify me", checked: m.notify, focusable: :notify)
def update({:harlock_toggle, :notify, checked}, m), do: %{m | notify: checked}label is anything text/2 accepts.
Required:
:checked— boolean.:focusable— focus id.
Optional:
:style,:focus_style,:handle_keys— as forbutton/2.
@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):header_style,:row_style,:alt_row_style,:selected_style,:focus_style— styles for the header, rows, alternate rows, selected rows, and the focused row; unset ones come from the theme:focusable,:focus_trap,:handle_keys,:handle_mouse— same as other elements
Auto-routing
A focused table has navigation keys routed for it, and which state they move depends on how rows were supplied — because the two modes have different things to move:
- enumerable rows — the row set is known and the window auto-centres on
:focused_row, so arrows move the focus and deliver{:harlock_select, focus_id, row_id}, exactly astabsandmenudo. - a window function — there is no row set to walk and no index to centre
on, so arrows move
:offsetand deliver{:harlock_scroll, focus_id, new_offset}, exactly asviewportdoes.
Neither wraps: a table is read top-down and is often long. Home goes to the
top in both modes; End only works for enumerable rows, since a window
function is never asked where the last row is.
Scrolling stops when a fetch returns fewer rows than requested, which is the
only end-of-data signal a window function provides. Opt out of routing entirely
with handle_keys: false. See Harlock.Table for the pure helpers.
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.
The function is called during rendering, which bounds what belongs in it.
Reading from a list, an ETS table, or Process.info/2 is fine. A database
query or a call to another node is not: rendering would block on IO, and on a
remote node, on the network. For those, fetch into the model with a Cmd and
let the window function slice what has already arrived — the same arrangement
examples/nodes.exs uses for its process list, which hydrates locally in the
function but fetches supervisor children through a Cmd.
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/Home/End deliver{:harlock_select, focus_id, id}, as does a click on a tab:style—%Style{}for inactive tabs (defaultTheme.get(:header)):active_style—%Style{}for the active tab (defaultTheme.get(:focus)when focused,Theme.get(:header)otherwise):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( Harlock.Text.t(), keyword() ) :: Harlock.Element.t()
A text element.
content is a binary or a list of styled runs — see Harlock.Text:
text("Ready")
text(["CPU ", {"92%", fg: :red, bold: true}, " of 8 cores"])
text(description, wrap: true, align: :center)"\n" starts a new line. Lines past the region's height, and columns past
its width, are clipped.
Options:
:style—%Harlock.Render.Style{}or keyword list of style attrs. Runs with their own style are merged over it.:wrap— wrap at word boundaries to the region's width (defaultfalse). UseHarlock.Text.height/2to size aviewportaround wrapped text.:align—:left(default),:centeror:right, applied per line.
@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:style—%Style{}for the value text:placeholder_style—%Style{}for the placeholder:password— when true, render each grapheme as•
The app's update/2 owns the value and cursor. When the input is focused,
the runtime routes keys for it: edits arrive as
{:harlock_edit, focus_id, {value, cursor}} and Enter as
{:harlock_submit, focus_id}, and a click places the cursor. Use
Harlock.TextBuffer.apply_key/3 directly only with handle_keys: false.
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:focusable,:handle_keys,:handle_mouse— same as other elements
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. When the viewport is
focusable, scroll keys (and the mouse wheel over it) arrive as
{:harlock_scroll, focus_id, new_offset} for update/2 to write back;
Harlock.Viewport.apply_key/4 does the same arithmetic for an app that
sets handle_keys: false.
Vertical-only for now. The child is given full width (minus scrollbar column if enabled) so horizontal layout proceeds normally.