Harlock.Tree (harlock v0.6.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 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

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.