Harlock.Tree (harlock v0.8.0)

Copy Markdown View Source

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

children()

@type children() :: [tree_node()] | :unloaded | :loading

event()

@type event() :: {:select, node_id()} | {:toggle, node_id()} | :submit | :noop

node_id()

@type node_id() :: any()

row()

@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.

tree_node()

@type tree_node() :: %{
  :id => node_id(),
  :label => String.t(),
  optional(:children) => children()
}

Functions

apply_key(event, nodes, expanded, focused)

@spec apply_key({:key, any(), [atom()]}, [tree_node()], Enumerable.t(), node_id()) ::
  event()

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 row
  • Right — expand a collapsed node, or step into an already-expanded one
  • Left — collapse an expanded node, or step out to its parent
  • Enter — toggle an expandable node, :submit on 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.

children(node)

@spec children(tree_node()) :: children()

A node's children, defaulting to a leaf when the key is absent.

expandable?(node)

@spec expandable?(tree_node()) :: boolean()

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.

expanded?(node, expanded)

@spec expanded?(tree_node(), Enumerable.t()) :: boolean()

Whether a node is both expandable and currently expanded.

visible(nodes, 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.