iced-swdir-tree
A reusable iced widget for displaying a directory tree with selection, lazy loading, filtering and asynchronous traversal.
Built on top of swdir's scan_dir for
single-level, non-recursive directory listings — ideal for GUI trees that
expand one folder at a time.
Features
- Multi-select with Shift/Ctrl-click and Shift-arrow range extension. A per-path authoritative set survives filter changes and subtree reloads; see Multi-select.
- Drag-and-drop between nodes. Drag one or more selected paths
onto another folder; the widget emits a
DragCompleted { sources, destination }event and the app performs the actual move/copy/ upload/whatever. The widget performs no filesystem operations itself. See Drag-and-drop. - Parallel pre-expansion. Opt into
with_prefetch_limit(N)and the widget will speculatively scan the firstNfolder-children of any folder the user expands, in parallel via the executor, so clicking any of them is instant. One level deep only (no cascade). See Parallel pre-expansion. - Incremental search.
tree.set_search_query(q)narrows the visible rows to basename-substring matches plus their ancestor chain, so users see tree context alongside their hits. Selection survives the filter. See Incremental search. - Lazy loading. Only the root is created eagerly; child folders are scanned on first expand.
- Non-blocking. Directory traversal runs on a worker thread through
iced::Task::perform; the UI thread never stalls on disk I/O. Plug in your own executor (tokio,smol, etc.) viawith_executorif you don't want the per-expansion thread-spawn default. - Three display filters.
FoldersOnly,FilesAndFolders(default),AllIncludingHidden. Filter changes are applied from an in-memory cache, so switching is instant — no re-scan. Expansion state and selection survive the swap. - Keyboard navigation. Arrow keys,
Home/End,Enter,Space,←/→, plus Shift-modified variants for range extension andEscapeto cancel a drag — see Keyboard navigation. - Stale-result handling. Every scan carries a generation counter, so a collapse/re-expand cycle safely discards in-flight results from the cancelled round-trip.
- Error tolerance. Permission denials, missing paths, and symlink cycles are surfaced as per-node errors that the view greys out — no panics, no UI freezes.
- Optional lucide icons. Disabled by default; enable the
iconsfeature to pull in real vector glyphs. The public API is identical in both modes. - Cross-platform. Hidden-file detection follows OS conventions: dotfile
on Unix,
HIDDENattribute plus dotfile fallback on Windows, dotfile elsewhere.
Installation
[]
= "0.14"
= "0.6"
To use real lucide icons instead of the Unicode-symbol fallback:
[]
= "0.14"
= { = "0.6", = ["icons"] }
The crate works without your application adding swdir directly — the
widget internally wraps it and exposes the pieces you need through its own
API.
Quick start
use PathBuf;
use ;
use ;
For a working example with a filter picker and a selection status bar, see
examples/basic.rs. For the lucide-icons version, see
examples/with_icons.rs.
Using the icons feature
When icons is enabled, register the bundled lucide TTF with iced at
startup:
use LUCIDE_FONT_BYTES;
Without this registration the icon widgets still render, but as tofu squares — the default system font doesn't have the lucide glyphs.
Configuration
# use PathBuf;
# use ;
let tree = new
.with_filter
.with_max_depth;
| Method | Purpose |
|---|---|
new(root) |
Build a tree rooted at root. Only the root is eagerly created. |
with_filter(f) |
Builder form of set_filter. |
with_max_depth(d) |
Refuse to load below depth d (0 = root children only). |
with_executor(e) |
Route blocking scans through a custom ScanExecutor. |
set_filter(f) |
Change the filter at runtime. Re-derives from cache; no I/O. |
handle_key(k, m) |
Translate a keyboard event into a DirectoryTreeEvent — see Keyboard navigation. |
filter(), max_depth(), root_path() |
Config accessors. |
selected_path() |
Most recently touched path (v0.2 single-select accessor). |
selected_paths() |
The full selected set (v0.3 multi-select). |
anchor_path() |
Pivot for SelectionMode::ExtendRange. |
is_selected(path) |
Membership check. |
Events
The widget emits DirectoryTreeEvent:
Toggled(PathBuf)— the user clicked the caret on a folder.Selected(PathBuf, bool, SelectionMode)— the user selected a row;boolistruefor directories,falsefor files, and theSelectionModecontrols how the event composes with any existing selection.Loaded(LoadPayload)— internal; a pending scan completed. Parent applications route it straight back intoupdate()without inspecting it.
Multi-select
The widget keeps a full selected set, a "most-recent-action" active
path, and an anchor path for Shift-range extension.
[SelectionMode] — exported from the crate root — controls how each
click composes:
| Mode | Effect |
|---|---|
Replace |
Clear the set; the new path becomes the only selection. Updates both active and anchor. |
Toggle |
Add if absent, remove if present. Updates both active and anchor. |
ExtendRange |
Replace the set with the visible rows between anchor and target, inclusive. Only active moves. Falls back to Replace if no anchor is set. |
View-level click behaviour
iced 0.14's button::on_press cannot observe modifier keys, so the
widget's built-in view always emits SelectionMode::Replace on
click. Applications that want real multi-select track modifier
state separately and rewrite the event in their own update handler:
use ;
use ;
// In your update:
Tree =>
ModifiersChanged =>
// In your subscription:
See examples/multi_select.rs for a
complete working app with a live selection-count status bar.
Drag-and-drop
The widget tracks drag gestures internally and emits a
DragCompleted { sources, destination } event when the user
releases over a valid folder. The widget does not touch the
filesystem — your app reacts to DragCompleted and performs the
actual move / copy / upload / whatever, then re-scans affected
folders so the view reflects the new layout.
match message
Pressing the mouse on a row that's already in the selection drags
the whole selected set; pressing on an unselected row drags only
that row. Escape cancels an in-flight drag. If the mouse is
released outside the tree (or over empty space), the drag stays
active until Escape or an app-initiated cancel — deliberately
chosen to match native file-browser behaviour.
Three read-only accessors let your UI reflect drag state:
tree.is_dragging; // bool
tree.drag_sources; // &[PathBuf]
tree.drop_target; // Option<&Path> — hovered valid folder
See examples/drag_drop.rs for a complete
working app with fs::rename on drop, post-move refresh, and a
live drag-preview status bar.
Parallel pre-expansion
Apps on a fast executor (tokio, smol, rayon) usually have more I/O
capacity than one-folder-per-gesture uses. with_prefetch_limit(N)
opts into parallel pre-expansion: whenever a user expands a folder
and its children come back, the widget speculatively fires scan
tasks for the first N of those children that are folders. Those
scans populate the cache but do not auto-expand anything —
is_loaded = true without is_expanded = true. When the user
later clicks to expand one of the pre-fetched folders, no I/O
happens: it's an instant fast-path re-expand.
use DirectoryTree;
use Arc;
let tree = new
.with_executor
.with_prefetch_limit;
Pass 0 (or don't call with_prefetch_limit at all) to disable
prefetch — that's the default and matches v0.1–0.4 behaviour
exactly. Prefetch is one level deep: a folder that loaded via
prefetch does not itself trigger further prefetches, so the I/O
budget is per_parent scans per user expansion, not
per_parent ^ depth. It also respects with_max_depth(..):
children past the cap are skipped rather than scanned.
Sensible values depend on your executor. On the default
ThreadExecutor (one std::thread::spawn per scan), keep it
modest (5–25) — each prefetch becomes a real OS thread. On a
bounded tokio/smol pool, a higher value is free: excess tasks just
queue behind the pool's worker cap.
Incremental search
Wire an iced::widget::text_input into the tree via
DirectoryTree::set_search_query. The widget narrows its visible
rows to basename-substring matches (case-insensitive) plus every
ancestor of every match — so users see where matches live in the
tree, not just isolated filenames.
// In update:
SearchChanged =>
// In view:
text_input.on_input
Four accessors drive a "N matches" status line or a clear button:
tree.is_searching; // bool
tree.search_query; // Option<&str> (original casing)
tree.search_match_count; // usize — excludes ancestor rows
tree.clear_search; // drop the query
Semantics:
- Case-insensitive basename substring match. The path components ("/src/…") don't match — only the filename at each level does.
- Empty string = cleared search. There is no "searching for an empty string" state.
- Already-loaded nodes only. Matches inside unloaded folders
don't appear until the folder loads. Combine with
with_prefetch_limit(N)for broader coverage without the user expanding everything manually. - Sees through collapsed-but-loaded folders. A match deep inside a collapsed subtree still shows up; ancestors render as if expanded.
- Selection survives. Hidden-by-search selections are preserved and reappear when the query clears.
One documented limitation: clicking a folder during search does
NOT escape the filter. The view stays narrowed to matches plus
ancestors. To explore outside the match set, clear the search
first. See examples/search.rs for a
complete working app with text-input, counter, and expand-all
button.
Keyboard navigation
DirectoryTree::handle_key(&Key, Modifiers) -> Option<DirectoryTreeEvent>
translates a key press into the right event. The widget stays
focus-neutral — you decide when the tree has focus and subscribe
to the key stream yourself:
use keyboard;
// ...in update:
TreeKey =>
| Key | Behaviour |
|---|---|
↑ / ↓ |
Move selection to previous / next visible row. |
Shift + ↑ / ↓ |
Extend the selected range toward the previous / next row. |
Home / End |
Jump to first / last visible row. |
Shift + Home / End |
Extend the range to the first / last row. |
Enter |
Toggle the selected directory (no-op on files). |
Space / Ctrl + Space |
Toggle the active path in or out of the selected set. |
← |
Collapse selected directory, or move to parent. |
→ |
Expand selected directory, or move to first child. |
Esc |
Cancel an in-flight drag (only bound during drag, so apps can still use Esc for their own UI otherwise). |
See examples/keyboard_nav.rs for a
single-select navigation demo and
examples/multi_select.rs for
multi-select with Shift/Ctrl-click.
Custom scan executor
By default the widget spawns one std::thread per folder expansion
via ThreadExecutor. Apps that already run a blocking-task pool
(tokio, smol, rayon, ...) can route through it by implementing
ScanExecutor:
use Arc;
use Future;
use Pin;
use ;
;
let tree = new
.with_executor;
The default behaviour is unchanged if you don't call
with_executor — existing v0.1 code keeps working as-is.
Architecture
See ARCHITECTURE.md.