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 swaps in {:loaded, children} as a 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.