Harlock.App behaviour (harlock v0.8.0)

Copy Markdown View Source

Behaviour for Harlock applications.

An app defines three callbacks, and optionally a fourth:

  • init/1 — returns the initial model from an arbitrary init argument, optionally paired with a Cmd to run at start.
  • update/2 — given an event and the current model, returns the next model, optionally paired with a Cmd. Return :quit to exit the app, or {:quit, cmd} to dispatch a last Cmd on the way out.
  • view/1 — given the current model, returns an element tree.
  • subs/1 (optional) — given the current model, returns the Harlock.Sub subscriptions that should be active.

The simplest app:

defmodule Counter do
  use Harlock.App

  def init(_), do: %{n: 0}

  def update({:key, {:char, ?+}, []}, m), do: %{m | n: m.n + 1}
  def update({:key, {:char, ?q}, []}, _), do: :quit
  def update(_event, m), do: m

  def view(m) do
    vbox(constraints: [length: 1, fill: 1], children: [
      text("Count: #{m.n}"),
      text("(+ to inc, q to quit)")
    ])
  end
end

Event vocabulary

update/2 receives messages from several sources. The runtime guarantees the following tuple shapes; apps pattern-match on these directly.

Tab / Shift-Tab are consumed by the runtime for focus traversal whenever pressing them moves focus — that is, when two or more focusable elements are in scope — and do not reach update/2 in that case. (With nothing to move focus to, the keys fall through as raw {:key, :tab, …} events.) Apps that previously dispatched on {:key, :tab, []} for sub-navigation — for example, cycling a custom highlight — should bind that gesture to a non-Tab key the moment a focusable widget enters the tree, or the binding will be silently shadowed.

Raw input:

  • {:key, key, [modifier()]} — terminal key events. Examples: {:key, :down, []}, {:key, {:char, ?q}, []}, {:key, :tab, [:shift]}. key is an atom for named keys (:up, :enter, :escape, …), {:char, codepoint} for printables, {:f, n} for function keys.
  • {:paste, binary} — text pasted into the terminal, delivered whole (bracketed paste) rather than as a key per character.
  • {:mouse, action, button, col, row, mods} — with mouse reporting on; see "Mouse" below.

Focus-aware widget routing. When a focusable widget (viewport, tabs, text_input, textarea, menu, select, tree, table, button, checkbox) carries a :focusable id and is focused, the runtime translates relevant keys into widget-shaped messages before calling update/2. The raw {:key, …} is swallowed — apps see the routed message or the raw key, never both. Opt out per-element with handle_keys: false. The five messages:

  • {:harlock_scroll, focus_id, new_offset} — focused viewport handled a scroll key (:up/:down/:page_up/:page_down/ :home/:end), or a focused table backed by a window function moved its :offset. The app's clause typically just writes the offset back to the model:

    def update({:harlock_scroll, :log, n}, m),
      do: %{m | log_offset: n}
  • {:harlock_select, focus_id, new_id} — a focused tabs, menu, select, tree, or list-backed table moved its selection, highlight, or focused row:

    def update({:harlock_select, :nav, id}, m), do: %{m | tab: id}
  • {:harlock_toggle, focus_id, node_id} — a focused tree expanded or collapsed a node. A focused checkbox sends the same tuple with its new boolean in the last slot: {:harlock_toggle, :notify, true}. Distinct from :harlock_select because expanding is not selecting, and because a node whose children are not loaded yet turns this into a side effect:

    def update({:harlock_toggle, :files, id}, m) do
      {%{m | nodes: mark_loading(m.nodes, id)}, Cmd.from(fn -> fetch(id) end)}
    end
  • {:harlock_edit, focus_id, {new_value, new_cursor}} — a focused text_input or textarea changed its value or cursor: typing, motion, deletion, readline editing, or a click placing the cursor:

    def update({:harlock_edit, :search, {v, c}}, m),
      do: %{m | search: v, search_cursor: c}
  • {:harlock_submit, focus_id} — the action key was pressed on a focused text_input, menu, select, or tree leaf, or Enter / Space on a focused button:

    def update({:harlock_submit, :search}, m), do: run_search(m)

    For a select this means "open the list" or "commit the highlight" depending on state the app already holds, which is why there is no separate open/close message.

No-op key events (e.g. :up on a viewport already at offset 0, or a modifier-only press in a text input) fall through to update/2 as raw {:key, …} events so apps can still react if they want.

Mouse

With mouse: true passed to Harlock.run/3, mouse events are routed to the element under the pointer, which the runtime finds from where the last frame laid each focusable element out — the topmost one, so an overlay wins over what it covers. The same messages as the keyboard arrive:

  • a left press focuses the element, and depending on where it landed:
    • button — {:harlock_submit, id};
    • checkbox — {:harlock_toggle, id, checked};
    • a table row, a tab, a tree node — {:harlock_select, id, item};
    • a tree node's expand marker — {:harlock_toggle, id, node_id};
    • a menu item — {:harlock_select, id, item} then {:harlock_submit, id}: a click activates, as Enter does;
    • a closed select — {:harlock_submit, id}, so the app opens it; one of its open choices — {:harlock_select, id, item} then {:harlock_submit, id}, so the app commits it;
    • a text_input — {:harlock_edit, id, {value, cursor}} with the cursor at the clicked column;
  • the wheel over a viewport scrolls three lines as {:harlock_scroll, id, offset}, and over a table moves one row as the arrow keys would, without moving focus.

Everything else arrives as the raw event {:mouse, action, button, col, row, mods} — a press on nothing focusable, releases, drags, other buttons, a wheel with nothing left to scroll, and anything outside an open focus trap. col and row are 1-indexed, as the terminal reports them. An element with handle_mouse: false is never the target, but still covers what lies beneath it. One with handle_keys: false is still focused by a click but gets no widget message from the mouse either, since the mouse and the keyboard route through the same element.

Cmd results and Sub-produced messages have whatever shape the app defined via Cmd.map/2 and friends — those are not part of the runtime's vocabulary.

Summary

Types

model()

@type model() :: any()

msg()

@type msg() :: any()

Callbacks

init(any)

@callback init(any()) :: model() | {model(), Harlock.Cmd.t()}

subs(model)

(optional)
@callback subs(model()) :: [Harlock.Sub.t()]

update(msg, model)

@callback update(msg(), model()) ::
  model() | {model(), Harlock.Cmd.t()} | :quit | {:quit, Harlock.Cmd.t()}

view(model)

@callback view(model()) :: Harlock.Element.t()