A hierarchical tree widget that renders nested nodes with expand/collapse controls.
Expanded nodes show a ▼ prefix; collapsed nodes with children show ▶; leaf nodes
show an indent. Optional icons are displayed after the expansion character when
:show_icons is enabled and a node provides an :icon field.
The cursor moves through the currently visible (flattened) display list, which only includes children of expanded parent nodes.
Node format
Each node in the :data list is a map with the following fields:
:id— identifier used to track expansion and selection. Default: eight random bytes, regenerated every time the data is normalised, so a node without an explicit:idloses its expansion and selection state on the nextupdate/2.:label— display string. Default"Unnamed".:children— list of child nodes in the same format. Default[]; a non-list is replaced by[].:icon— string drawn before the label. Defaultnil.:metadata— arbitrary map stored on the node and passed to callbacks. Default%{}.
Every other key, :expanded among them, is dropped by normalisation. Nodes
therefore always start collapsed and are expanded from the key bindings alone.
Shorthand formats are also accepted:
- a bare string becomes a node with that label and no children
{"label", children}becomes a node with that label and those children, wherechildrenmay be a list or a map- a map of nodes is turned into a list with
Map.to_list/1, so each entry arrives as a{key, value}tuple
Component tag
Tag :tree, built by Drafter.App as {:tree, opts}:
tree(opts)There is no positional argument; the nodes are passed as data: in opts.
from_component_opts/2 wraps :on_select, :on_expand and
:on_node_highlight with Drafter.Widget.Callback, so each may be given as an
atom event name. :width and :height default to the rect the parent allocated.
Options
:data- list of root nodes, or a map that is turned into one. Default[]. Anything else normalises to[].:selection_mode-:none | :single | :multiple. Default:single.:on_select- atom event name or([node] -> term())called with the list of selected nodes on selection change. Defaultnil.:on_expand- atom event name or((node, boolean()) -> term())called when a node is expanded or collapsed. Defaultnil.:on_node_highlight- atom event name or(node -> term())called when the cursor moves to a different node. Defaultnil.:show_icons-boolean/0, draw node:iconfields. Defaulttrue.:indent_size-pos_integer/0spaces per depth level. Default2.:width-pos_integer/0widget width in columns. Default80when mounting directly, and the allocated rect width through the element. Held on the state;render/2uses the rect it is given.:height-pos_integer/0widget height in rows. Default20when mounting directly, and the allocated rect height through the element. Read byhandle_scroll/2and the cursor's scroll adjustment, not byrender/2.:focused-boolean/0read bymount/1. Defaultfalse. Every key binding requires it.:heightas an element option is also whatpreferred_height/2returns, defaulting to:autorather than to a row count.
These style maps are read by mount/1 and update/2 only; the tree/1 element
does not forward them, and render/2 overlays the current theme on top of them:
:style- base row style. Default%{fg: {200, 200, 200}, bg: {30, 30, 30}}.:selected_style- style for selected rows. Default%{fg: {255, 255, 255}, bg: {0, 120, 215}}.:cursor_style- style for the row under the cursor. Default%{fg: {255, 255, 255}, bg: {50, 100, 200}, bold: true}.:expanded_style- style for the▼marker. Default%{fg: {100, 200, 100}, bg: {30, 30, 30}}.:collapsed_style- style for the▶marker. Default%{fg: {200, 200, 100}, bg: {30, 30, 30}}.
mount/1 always starts the cursor at 0 with nothing expanded, nothing
selected and no scroll; none of those can be seeded from props. update/2
re-normalises :data and clamps the cursor into it, but leaves :expanded_nodes
and :selected_nodes alone. Through the component tree
update_props_from_mount/3 always passes :on_select, :on_expand,
:on_node_highlight, :selection_mode, :show_icons, :indent_size and
:data, and :width and :height only when they changed.
Widget value
Drafter.get_widget_value/1 returns the selected node ids as a list.
Key bindings
↑/↓— move cursor through visible nodes←— collapse the current node→— expand the current nodeEnter— toggle expand/collapse of the current nodeSpace— toggle selection of the current node+— expand current node-— collapse current node*— expand all nodes/— collapse all nodesShift+←/Shift+→— collapse or expand the current node- Mouse click — move cursor and toggle expand/collapse; this one does not require focus
Every key binding requires the widget to be focused; unfocused, all of them fall
through to {:noreply, state}.
Usage
tree(
data: [
%{id: :lib, label: "lib", children: [
%{id: :app, label: "app.ex"},
%{id: :router, label: "router.ex"}
]},
%{id: :test, label: "test", children: []}
],
on_select: fn nodes -> IO.inspect(nodes) end
)
Summary
Functions
The component tag this widget registers under.
Builds the props map for a {:tree, opts} element.
Handles the tree's own events, replacing the dispatch use Drafter.Widget would
otherwise generate.
Scrolls the viewport by three rows per wheel step, without moving the cursor.
Builds the widget state from props.
The number of rows the element asks for: opts[:height], default :auto.
Draws the visible slice of the tree into rect, always returning exactly
rect.height strips.
Callback implementation for Drafter.Widget.unmount/1.
Replaces the state fields named in props, keeping the current value for any key
that is absent.
Narrows a re-render to the props that may safely change after mount.
Types
@type selection_mode() :: :none | :single | :multiple
@type t() :: %Drafter.Widget.Tree{ collapsed_style: Drafter.Draw.Segment.style(), cursor_index: non_neg_integer(), cursor_style: Drafter.Draw.Segment.style(), data: [tree_node()], expanded_nodes: MapSet.t(), expanded_style: Drafter.Draw.Segment.style(), focused: boolean(), height: pos_integer(), indent_size: pos_integer(), on_expand: (tree_node(), boolean() -> term()) | nil, on_node_highlight: (tree_node() -> term()) | nil, on_select: ([tree_node()] -> term()) | nil, scroll_offset: non_neg_integer(), selected_nodes: MapSet.t(), selected_style: Drafter.Draw.Segment.style(), selection_mode: selection_mode(), show_icons: boolean(), style: Drafter.Draw.Segment.style(), width: pos_integer() }
Functions
@spec component_tag() :: :tree
The component tag this widget registers under.
iex> Drafter.Widget.Tree.component_tag()
:tree
@spec from_component_opts( term(), keyword() ) :: Drafter.Widget.props()
Builds the props map for a {:tree, opts} element.
The positional argument is ignored; the nodes come from opts[:data].
:on_select and :on_node_highlight go through
Drafter.Widget.Callback.wrap_1/1 and :on_expand through wrap_2/1, so an
atom becomes a closure that dispatches an app event. :width and :height fall
back to opts[:__rect__], itself defaulting to %{width: 80, height: 20}. The
five style maps are not forwarded.
iex> props = Drafter.Widget.Tree.from_component_opts(nil, data: [%{id: :a, label: "a"}])
iex> {props.selection_mode, props.show_icons, props.indent_size, props.width, props.height}
{:single, true, 2, 80, 20}
iex> props = Drafter.Widget.Tree.from_component_opts(nil, on_select: :picked)
iex> {props.data, is_function(props.on_select, 1)}
{[], true}
Handles the tree's own events, replacing the dispatch use Drafter.Widget would
otherwise generate.
Recognised events are the key bindings listed in the module doc — all of which
require :focused — plus a mouse up, a mouse wheel, {:focus} and {:blur},
which do not. Anything else returns {:noreply, state}, and so does a cursor
move that is already at either end and a collapse or expand that would not change
anything.
A cursor move calls :on_node_highlight, expanding or collapsing calls
:on_expand with the node and its new state, and toggling a selection calls
:on_select with every selected node.
iex> data = [%{id: :a, label: "a"}, %{id: :b, label: "b"}]
iex> state = Drafter.Widget.Tree.mount(%{data: data, focused: true})
iex> {:ok, moved} = Drafter.Widget.Tree.handle_event({:key, :down}, state)
iex> moved.cursor_index
1
iex> data = [%{id: :a, label: "a"}]
iex> state = Drafter.Widget.Tree.mount(%{data: data, focused: true})
iex> Drafter.Widget.Tree.handle_event({:key, :up}, state) == {:noreply, state}
true
iex> data = [%{id: :a, label: "a", children: [%{id: :b, label: "b"}]}]
iex> state = Drafter.Widget.Tree.mount(%{data: data, focused: true})
iex> {:ok, expanded} = Drafter.Widget.Tree.handle_event({:key, :right}, state)
iex> MapSet.to_list(expanded.expanded_nodes)
[:a]
iex> data = [%{id: :a, label: "a"}]
iex> state = Drafter.Widget.Tree.mount(%{data: data, focused: true})
iex> {:ok, selected} = Drafter.Widget.Tree.handle_event({:key, :" "}, state)
iex> MapSet.to_list(selected.selected_nodes)
[:a]
iex> data = [%{id: :a, label: "a"}, %{id: :b, label: "b"}]
iex> state = Drafter.Widget.Tree.mount(%{data: data})
iex> Drafter.Widget.Tree.handle_event({:key, :down}, state) == {:noreply, state}
true
Scrolls the viewport by three rows per wheel step, without moving the cursor.
Always returns {:ok, new_state}. Scrolling up stops at 0; scrolling down
stops at visible_row_count - height, using the state's :height, not the rect.
Works whether or not the widget is focused.
iex> data = Enum.map(1..20, fn n -> %{id: n, label: "node"} end)
iex> state = Drafter.Widget.Tree.mount(%{data: data, height: 5})
iex> {:ok, down} = Drafter.Widget.Tree.handle_scroll(:down, state)
iex> down.scroll_offset
3
iex> state = Drafter.Widget.Tree.mount(%{data: [%{id: :a, label: "a"}]})
iex> {:ok, down} = Drafter.Widget.Tree.handle_scroll(:down, state)
iex> down.scroll_offset
0
@spec mount(Drafter.Widget.props()) :: t()
Builds the widget state from props.
:data is normalised into tree_node/0 maps. Because normalisation drops
every key it does not know, an :expanded field on an input node is gone by the
time the expansion set is built, so :expanded_nodes always starts empty.
:cursor_index, :selected_nodes and :scroll_offset likewise always start
empty and cannot be seeded from props.
iex> state = Drafter.Widget.Tree.mount(%{data: [%{id: :lib, label: "lib"}]})
iex> state.data
[%{id: :lib, label: "lib", children: [], icon: nil, metadata: %{}}]
iex> state = Drafter.Widget.Tree.mount(%{data: [%{id: :lib, label: "lib", expanded: true}]})
iex> MapSet.to_list(state.expanded_nodes)
[]
iex> state = Drafter.Widget.Tree.mount(%{})
iex> {state.data, state.cursor_index, state.selection_mode, state.show_icons, state.indent_size}
{[], 0, :single, true, 2}
iex> state = Drafter.Widget.Tree.mount(%{data: [{"lib", [%{id: :app, label: "app.ex"}]}]})
iex> [root] = state.data
iex> {root.label, Enum.map(root.children, & &1.label)}
{"lib", ["app.ex"]}
@spec preferred_height( term(), keyword() ) :: pos_integer() | :auto
The number of rows the element asks for: opts[:height], default :auto.
Unlike the other widgets this returns an atom when no height is given, leaving the row count to the layout.
iex> Drafter.Widget.Tree.preferred_height(nil, [])
:auto
iex> Drafter.Widget.Tree.preferred_height(nil, height: 12)
12
@spec render(t() | Drafter.Widget.props(), Drafter.Widget.rect()) :: [ Drafter.Draw.Strip.t() ]
Draws the visible slice of the tree into rect, always returning exactly
rect.height strips.
state may be a plain props map, in which case it is passed through mount/1
first. Only children of expanded nodes appear. Rows start at :scroll_offset;
when the flattened list is taller than rect.height the last column becomes a
scrollbar and the rows are cropped one narrower. Theme colours are merged over
the state's style maps first.
Callback implementation for Drafter.Widget.unmount/1.
@spec update(Drafter.Widget.props(), t()) :: t()
Replaces the state fields named in props, keeping the current value for any key
that is absent.
:data is re-normalised on every call, whether or not props carries it, and
the cursor is clamped to the resulting visible row count. :expanded_nodes,
:selected_nodes and :scroll_offset are left alone, so expansion survives new
data only for nodes that keep the same explicit :id.
iex> data = [%{id: :a, label: "a"}, %{id: :b, label: "b"}]
iex> state = %{Drafter.Widget.Tree.mount(%{data: data}) | cursor_index: 1}
iex> updated = Drafter.Widget.Tree.update(%{data: [%{id: :a, label: "a"}]}, state)
iex> {length(updated.data), updated.cursor_index}
{1, 0}
iex> state = Drafter.Widget.Tree.mount(%{data: [%{id: :a, label: "a"}]})
iex> Drafter.Widget.Tree.update(%{show_icons: false}, state).show_icons
false
@spec update_props_from_mount(Drafter.Widget.props(), t(), keyword()) :: Drafter.Widget.props()
Narrows a re-render to the props that may safely change after mount.
Always passes :on_select, :on_expand, :on_node_highlight,
:selection_mode, :show_icons, :indent_size and :data. Adds :width and
:height only when they differ from the mounted state.
iex> props = Drafter.Widget.Tree.from_component_opts(nil, data: [%{id: :a, label: "a"}])
iex> state = Drafter.Widget.Tree.mount(props)
iex> Drafter.Widget.Tree.update_props_from_mount(props, state, []) |> Map.keys() |> Enum.sort()
[:data, :indent_size, :on_expand, :on_node_highlight, :on_select, :selection_mode, :show_icons]
iex> props = Drafter.Widget.Tree.from_component_opts(nil, __rect__: %{width: 40, height: 8})
iex> state = Drafter.Widget.Tree.mount(%{})
iex> result = Drafter.Widget.Tree.update_props_from_mount(props, state, [])
iex> {result.width, result.height}
{40, 8}