Harlock.Elements (harlock v0.8.0)

Copy Markdown View Source

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; see Harlock.Layout)
  • box/1 — single-child container with border + title + padding
  • spacer/0 — empty element that occupies a layout slot
  • overlay/1 — render a foreground element on top of a background with optional focus_trap
  • viewport/1 — scrollable container

Content

Input

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

box(opts)

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

button(label, opts)

@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: :style merged with Theme.get(:focus)).
  • :handle_keys — false delivers the raw {:key, …} instead.

checkbox(label, opts)

@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 for button/2.

column(opts \\ [])

@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

hbox(opts \\ [])

@spec hbox(keyword()) :: Harlock.Element.t()

Horizontal stack. Children share the box's height; width is split.

Options as vbox/1.

keybar(opts)

@spec keybar(keyword()) :: Harlock.Element.t()

Single-line bar showing key bindings as [k] label [k] label.

Required:

  • :bindings — list of {key, label} tuples. key may be a char like ?q or any atom (:tab, :enter); it's rendered via to_string/1.

Optional:

  • :style — %Style{} (default %Style{reverse: true})
  • :separator — string between bindings (default " ")
  • :right — extra right-aligned text (e.g. clock, status)

list(items, opts \\ [])

@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:

overlay(opts)

@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 the over subtree until the overlay disappears. Prior focus is stashed and restored automatically when the overlay closes.

Overlays nest cleanly: just put another overlay as :over.

progress(opts)

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

select(opts)

@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 let Escape discard a move.
  • :placeholder — shown closed when :value matches 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.

spacer()

@spec spacer() :: Harlock.Element.t()

Empty cell that occupies a layout slot. Useful with constraints.

sparkline(opts)

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

spinner(opts)

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

statusbar(opts \\ [])

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

table(opts)

@spec table(keyword()) :: Harlock.Element.t()

Table primitive.

Required options:

  • :columns — list of column/1 specs
  • :rows — enumerable of row data, or a fn offset, limit -> rows window 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 — default true
  • :offset — first visible row, for a window function (default 0)
  • :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 as tabs and menu do.
  • a window function — there is no row set to walk and no index to centre on, so arrows move :offset and deliver {:harlock_scroll, focus_id, new_offset}, exactly as viewport does.

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.

tabs(opts)

@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 (default Theme.get(:header))
  • :active_style — %Style{} for the active tab (default Theme.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
  ]
)

text(content, opts \\ [])

@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 (default false). Use Harlock.Text.height/2 to size a viewport around wrapped text.
  • :align — :left (default), :center or :right, applied per line.

text_input(opts)

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

textarea(opts)

@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 (default false, which clips instead)
  • :scroll — index of the first visible display row (default 0)
  • :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.

tree(opts)

@spec tree(keyword()) :: Harlock.Element.t()

Collapsible tree.

Required options:

  • :nodes — list of node maps; see Harlock.Tree for the shape
  • :expanded — MapSet or 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}, and Enter on a leaf as {:harlock_submit, focus_id}
  • :scroll — index of the first visible row (default 0)
  • :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.

vbox(opts \\ [])

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

viewport(opts)

@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 (default false). 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.