Harlock.Elements (harlock v0.7.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.

Primitives

  • text/2 — single-line text content
  • text_input/1 — single-line editable input (paired with Harlock.TextBuffer)
  • vbox/1 / hbox/1 — vertical / horizontal stacks with layout constraints (:length, :percentage, :fill)
  • 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
  • table/1 / list/2 — row-based primitives with selection and focus highlighting
  • column/1 — column spec for table/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

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.

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

  • :renderfn 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)
  • :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.

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 cycle tabs (use Harlock.Tabs.apply_key/3 in update/2)
  • :style%Style{} for inactive tabs (default Theme.get(:header))
  • :active_style%Style{} for the active tab (default Theme.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
  ]
)

text(content, opts \\ [])

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

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
  • :max_length — soft hint; the element doesn't enforce it, but Harlock.TextBuffer.apply_key/3 respects 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.

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
  • :expandedMapSet 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

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.