Projection and key-event helpers for the Harlock.Elements.tree/1 widget.
The tree stays data. This module flattens it to the rows that are currently visible and maps keys onto that flat list, which is what keeps the renderer simple: it draws rows, and never walks a recursive structure.
Nodes
A node is a map with :id and :label, plus :children:
%{id: :src, label: "src", children: [
%{id: :main, label: "main.ex", children: []}
]}:children is either a list — [] for a leaf — or one of two lazy states:
:unloaded— expandable, contents not fetched yet:loading— a fetch is in flight
Lazy nodes are why this is worth modelling explicitly. A tree over a
filesystem or a remote node cannot load eagerly, so expanding one is a side
effect: the app receives {:harlock_toggle, id, node_id}, flips the node to
:loading, returns a Cmd, and replaces :children with the loaded list when
the result arrives. The widget renders each state without needing to know
which one it is looking at.
Expansion is keyed by id, never by index
expanded is a MapSet (or list) of node ids. Indices would break the moment
a node above collapsed: every key below it would shift by one, and the
selection would jump to an unrelated row.
Guides need the ancestor chain, not just a depth
Each projected row carries ancestors_last — whether each ancestor was the
last among its siblings — as well as its own last_child?. Depth alone is not
enough: to know whether column 2 of a deeply nested row needs a │
continuation or a blank, you have to know whether that ancestor had siblings
still to come. The renderer cannot see siblings, so the projection computes
it.
Summary
Types
One visible row. ancestors_last runs root-first and excludes the row
itself; parent is nil at the top level.
Functions
Map a {:key, key, mods} event onto a tree.
A node's children, defaulting to a leaf when the key is absent.
Whether a node can be expanded at all.
Whether a node is both expandable and currently expanded.
Flatten to the rows currently visible, depth-first.
Types
@type children() :: [tree_node()] | :unloaded | :loading
@type node_id() :: any()
@type row() :: %{ node: tree_node(), depth: non_neg_integer(), last_child?: boolean(), ancestors_last: [boolean()], parent: node_id() | nil }
One visible row. ancestors_last runs root-first and excludes the row
itself; parent is nil at the top level.
Functions
Map a {:key, key, mods} event onto a tree.
focused is the id of the highlighted row.
Up/Down— move between visible rows,{:select, id}Home/End— first / last visible rowRight— expand a collapsed node, or step into an already-expanded oneLeft— collapse an expanded node, or step out to its parentEnter— toggle an expandable node,:submiton a leaf- anything else —
:noop
Returning {:toggle, id} for Right on a collapsed node and {:select, id}
when it is already open is what makes Right feel like one motion: press it
repeatedly and you descend, rather than having to alternate keys.
A node's children, defaulting to a leaf when the key is absent.
Whether a node can be expanded at all.
Lazy nodes count even though nothing is loaded yet — that is the point of them. An empty loaded list does not: there is nothing to show.
@spec expanded?(tree_node(), Enumerable.t()) :: boolean()
Whether a node is both expandable and currently expanded.
@spec visible([tree_node()], Enumerable.t()) :: [row()]
Flatten to the rows currently visible, depth-first.
A node's children appear only when its id is in expanded and its children
are a loaded list — an expanded :unloaded node contributes no rows until the
app swaps the fetched children in.