A live file-system tree widget that reads directories lazily as nodes are expanded.
The root directory is expanded by default. Directories are rendered in blue with a ▼
or ▶ indicator; files are rendered in the default foreground colour. Selecting a file
(Enter, Space, or click) updates :selected_file and calls :on_file_select if provided.
Horizontal scrolling is available when path names are wider than the widget via the left/right arrow keys.
Component tag
Tag :directory_tree, built by Drafter.App as {:directory_tree, opts}:
directory_tree(opts)There is no positional argument; every prop comes from opts via
from_component_opts/2, which wraps :on_select and :on_file_select with
Drafter.Widget.Callback so they may be given as atom event names.
Options
:path- absolute root path to display. DefaultFile.cwd!(). Only this directory starts expanded:show_hidden-boolean/0, include entries starting with.. Defaultfalse:on_select- atom event name or(String.t() -> term())called with the path of any selected item, file or directory. Defaultnil. A non-nilreturn value is emitted as an action:on_file_select- atom event name or(String.t() -> term())called only when a file is selected. Defaultnil. A non-nilreturn value is emitted as an action:target- atom or string identifier. Defaultnil. Carried on the state but never read, and not re-read byupdate/2:style-map/0of style overrides. Default%{}:class- theme class atom or list of them, reachingmount/1as:classes. Default[]:handles- list of event types to respond to. Default[:keyboard, :mouse_up, :scroll]. Removing:mouse_upor:scrollmakes the corresponding handler consume the event without acting on it:height- read only bypreferred_height/2, never bymount/1. Default:auto
update/2 re-reads :path, :show_hidden, :style, :classes, :app_module,
:on_select, :on_file_select and :handles. A new :path collapses everything
but the new root and resets the cursor and scroll offset. :target is mount-only.
Widget value
Drafter.get_widget_value/1 is not implemented for this widget and returns nil;
the selection is reported through :on_select and :on_file_select.
Key bindings
↑/↓— move cursor one item up/down, scrolling the viewport to follow. Landing on a file fires:on_file_selectEnter— expand/collapse directory, or select fileSpace— toggle directory expand/collapse; select file←/→— scroll view horizontally by two columns, clamped at0on the left and unbounded on the right- Mouse click — move cursor and activate item
- Mouse scroll — move cursor 3 items at a time
Every other key is consumed and leaves the state unchanged, so nothing bubbles out of a focused tree.
Usage
directory_tree(path: "/home/user/projects", on_file_select: :file_opened)
Summary
Functions
The registry tag for this widget.
Turns the {:directory_tree, opts} element into a props map for mount/1.
Callback implementation for Drafter.Widget.handle_event/2.
Moves the cursor, activates the item under it, or scrolls horizontally.
Moves the cursor to the item on row y and activates it.
Moves the cursor three items per wheel notch.
Builds the tree state from props, with only the root directory expanded.
Records the rect's height as the viewport height used by cursor scrolling.
opts[:height], or :auto when it is absent, letting the layout give the tree
whatever space is left.
Draws the visible slice of the tree into rect.
Callback implementation for Drafter.Widget.unmount/1.
Folds fresh props into state.
Narrows the props a re-render feeds to update/2 to :path, :show_hidden,
:on_select, :on_file_select, :target and, when present, :handles. :style
and :classes stay as mounted.
Types
@type t() :: %Drafter.Widget.DirectoryTree{ app_module: module() | nil, classes: [atom()], cursor_pos: non_neg_integer(), expanded_dirs: MapSet.t(String.t()), focused: boolean(), h_scroll_offset: term(), handles: [atom()], hovered: boolean(), on_file_select: (String.t() -> any()) | nil, on_select: (String.t() -> any()) | nil, path: String.t(), scroll_offset: non_neg_integer(), selected_file: String.t() | nil, show_hidden: boolean(), style: map(), target: atom() | String.t() | nil, viewport_height: non_neg_integer() }
@type tree_item() :: %{path: String.t(), type: :dir | :file, depth: non_neg_integer()}
Functions
@spec component_tag() :: :directory_tree
The registry tag for this widget.
iex> Drafter.Widget.DirectoryTree.component_tag()
:directory_tree
@spec from_component_opts( term(), keyword() ) :: Drafter.Widget.props()
Turns the {:directory_tree, opts} element into a props map for mount/1.
The positional argument is ignored. :class is normalised into :classes,
:on_select and :on_file_select are wrapped by
Drafter.Widget.Callback.wrap_1/1, and :__app_module__ becomes :app_module.
:handles is included only when given, so its default lives in mount/1.
iex> props = Drafter.Widget.DirectoryTree.from_component_opts(nil, path: "/tmp")
iex> {props.path, props.show_hidden, props.on_select, props.classes, Map.has_key?(props, :handles)}
{"/tmp", false, nil, [], false}
Callback implementation for Drafter.Widget.handle_event/2.
@spec handle_key(Drafter.Widget.key(), t() | Drafter.Widget.props()) :: {:ok, t()} | {:ok, t(), [term()]}
Moves the cursor, activates the item under it, or scrolls horizontally.
:up and :down return {:ok, state, actions}, where actions carries the
:on_file_select result when the cursor lands on a file. :enter and :" "
toggle a directory or select a file, also returning {:ok, state, actions}.
:left and :right move :h_scroll_offset by two columns and return
{:ok, state}. Every other key returns {:ok, state} unchanged, so nothing
bubbles.
iex> t = Drafter.Widget.DirectoryTree.mount(%{path: "/tmp"})
iex> {:ok, moved} = Drafter.Widget.DirectoryTree.handle_key(:right, t)
iex> {:ok, back} = Drafter.Widget.DirectoryTree.handle_key(:left, moved)
iex> {moved.h_scroll_offset, back.h_scroll_offset}
{2, 0}
iex> t = Drafter.Widget.DirectoryTree.mount(%{path: "/tmp"})
iex> {:ok, ^t} = Drafter.Widget.DirectoryTree.handle_key(:escape, t)
iex> t.cursor_pos
0
@spec handle_mouse_up(integer(), integer(), t() | Drafter.Widget.props()) :: {:ok, t()} | {:ok, t(), [term()]}
Moves the cursor to the item on row y and activates it.
y is counted from the top of the widget, so the item is
scroll_offset + y. A release past the last item, or one arriving when
:mouse_up is not in the widget's :handles, returns {:ok, state} unchanged.
@spec handle_scroll(:up | :down, t() | Drafter.Widget.props()) :: {:ok, t()} | {:ok, t(), [term()]}
Moves the cursor three items per wheel notch.
Returns {:ok, state} unchanged when :scroll is not in the widget's :handles,
so the event is consumed either way.
@spec mount(Drafter.Widget.props()) :: t()
Builds the tree state from props, with only the root directory expanded.
:selected_file, :cursor_pos and both scroll offsets always start at nil/0.
iex> t = Drafter.Widget.DirectoryTree.mount(%{path: "/tmp", show_hidden: true})
iex> {t.path, MapSet.to_list(t.expanded_dirs), t.show_hidden, t.cursor_pos}
{"/tmp", ["/tmp"], true, 0}
iex> t = Drafter.Widget.DirectoryTree.mount(%{path: "/tmp"})
iex> {t.selected_file, t.scroll_offset, t.h_scroll_offset, t.viewport_height, t.handles}
{nil, 0, 0, 10, [:keyboard, :mouse_up, :scroll]}
@spec on_rect_change(Drafter.Widget.rect(), t()) :: t()
Records the rect's height as the viewport height used by cursor scrolling.
Called by the runtime whenever the widget's rect changes.
iex> t = Drafter.Widget.DirectoryTree.mount(%{path: "/tmp"})
iex> Drafter.Widget.DirectoryTree.on_rect_change(%{x: 0, y: 0, width: 40, height: 25}, t).viewport_height
25
@spec preferred_height( term(), keyword() ) :: pos_integer() | :auto
opts[:height], or :auto when it is absent, letting the layout give the tree
whatever space is left.
iex> Drafter.Widget.DirectoryTree.preferred_height(nil, [])
:auto
iex> Drafter.Widget.DirectoryTree.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.
Accepts either a t/0 or a raw props map, which is mounted first. Directories
are read from disk on every call, so an unreadable directory simply contributes no
children. Emits at most rect.height strips starting at :scroll_offset, and one
blank strip when nothing is visible.
Callback implementation for Drafter.Widget.unmount/1.
@spec update(Drafter.Widget.props(), t()) :: t()
Folds fresh props into state.
Re-reads :path, :show_hidden, :style, :classes, :app_module,
:on_select, :on_file_select and :handles. A new :path collapses every
expanded directory except the new root and resets :cursor_pos and
:scroll_offset to 0. :target, :selected_file, :h_scroll_offset and
:viewport_height are left alone.
iex> t = Drafter.Widget.DirectoryTree.mount(%{path: "/tmp"})
iex> moved = Drafter.Widget.DirectoryTree.update(%{path: "/"}, t)
iex> {moved.path, MapSet.to_list(moved.expanded_dirs), moved.cursor_pos}
{"/", ["/"], 0}
@spec update_props_from_mount(Drafter.Widget.props(), t(), keyword()) :: Drafter.Widget.props()
Narrows the props a re-render feeds to update/2 to :path, :show_hidden,
:on_select, :on_file_select, :target and, when present, :handles. :style
and :classes stay as mounted.