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 aCmdto run at start.update/2— given an event and the current model, returns the next model, optionally paired with aCmd. Return:quitto exit the app, or{:quit, cmd}to dispatch a lastCmdon the way out.view/1— given the current model, returns an element tree.subs/1(optional) — given the current model, returns theHarlock.Subsubscriptions 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
endEvent 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]}.keyis 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}— focusedviewporthandled a scroll key (:up/:down/:page_up/:page_down/:home/:end), or a focusedtablebacked 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 focusedtabs,menu,select,tree, or list-backedtablemoved its selection, highlight, or focused row:def update({:harlock_select, :nav, id}, m), do: %{m | tab: id}{:harlock_toggle, focus_id, node_id}— a focusedtreeexpanded or collapsed a node. A focusedcheckboxsends the same tuple with its new boolean in the last slot:{:harlock_toggle, :notify, true}. Distinct from:harlock_selectbecause 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 focusedtext_inputortextareachanged 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 focusedtext_input,menu,select, ortreeleaf, or Enter / Space on a focusedbutton:def update({:harlock_submit, :search}, m), do: run_search(m)For a
selectthis 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
tablerow, a tab, a tree node —{:harlock_select, id, item}; - a tree node's expand marker —
{:harlock_toggle, id, node_id}; - a
menuitem —{: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
viewportscrolls three lines as{:harlock_scroll, id, offset}, and over atablemoves 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
Callbacks
@callback init(any()) :: model() | {model(), Harlock.Cmd.t()}
@callback subs(model()) :: [Harlock.Sub.t()]
@callback update(msg(), model()) :: model() | {model(), Harlock.Cmd.t()} | :quit | {:quit, Harlock.Cmd.t()}
@callback view(model()) :: Harlock.Element.t()