Harlock.Elements (harlock v0.6.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
  • :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
  • :focusable, :focus_trap — same as other elements

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.