iced-swdir-tree 0.2.0

iced widget for file tree powered by swdir, supporting selection, lazy loading and filtering.
Documentation

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

  • Single-select with per-path persistence. Clicks emit a Selected(PathBuf, bool) event; the cursor survives filter changes and subtree reloads.
  • 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.) via with_executor if 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, / — 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 icons feature 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, HIDDEN attribute plus dotfile fallback on Windows, dotfile elsewhere.

Installation

[dependencies]
iced = "0.14"
iced-swdir-tree = "0.2"

To use real lucide icons instead of the Unicode-symbol fallback:

[dependencies]
iced = "0.14"
iced-swdir-tree = { version = "0.2", features = ["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 std::path::PathBuf;
use iced::{Element, Task};
use iced_swdir_tree::{DirectoryFilter, DirectoryTree, DirectoryTreeEvent};

#[derive(Debug, Clone)]
enum Message {
    Tree(DirectoryTreeEvent),
}

struct App {
    tree: DirectoryTree,
}

impl App {
    fn new() -> (Self, Task<Message>) {
        let tree = DirectoryTree::new(PathBuf::from("."))
            .with_filter(DirectoryFilter::FilesAndFolders);
        (Self { tree }, Task::none())
    }

    fn update(&mut self, message: Message) -> Task<Message> {
        match message {
            Message::Tree(event) => {
                // React to app-level side effects BEFORE forwarding.
                if let DirectoryTreeEvent::Selected(path, is_dir) = &event {
                    println!("selected {:?} (dir={})", path, is_dir);
                }
                self.tree.update(event).map(Message::Tree)
            }
        }
    }

    fn view(&self) -> Element<'_, Message> {
        self.tree.view(Message::Tree)
    }
}

fn main() -> iced::Result {
    iced::application(App::new, App::update, App::view).run()
}

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 iced_swdir_tree::LUCIDE_FONT_BYTES;

fn main() -> iced::Result {
    iced::application(App::new, App::update, App::view)
        .font(LUCIDE_FONT_BYTES)
        .run()
}

Without this registration the icon widgets still render, but as tofu squares — the default system font doesn't have the lucide glyphs.

Configuration

# use std::path::PathBuf;
# use iced_swdir_tree::{DirectoryFilter, DirectoryTree};
let tree = DirectoryTree::new(PathBuf::from("."))
    .with_filter(DirectoryFilter::AllIncludingHidden)
    .with_max_depth(5);
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(), selected_path() Read accessors.

Events

The widget emits DirectoryTreeEvent:

  • Toggled(PathBuf) — the user clicked the caret on a folder.
  • Selected(PathBuf, bool) — the user clicked a row; bool is true for directories, false for files.
  • Loaded(LoadPayload) — internal; a pending scan completed. Parent applications route it straight back into update() without inspecting it.

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 iced::keyboard;

fn subscription(app: &App) -> iced::Subscription<Message> {
    keyboard::listen().map(|event| match event {
        keyboard::Event::KeyPressed { key, modifiers, .. } =>
            Message::TreeKey(key, modifiers),
        _ => Message::Noop,
    })
}

// ...in update:
Message::TreeKey(key, mods) => {
    if let Some(event) = self.tree.handle_key(&key, mods) {
        return self.tree.update(event).map(Message::Tree);
    }
    Task::none()
}
Key Behaviour
/ Move selection to previous / next visible row.
Home / End Jump to first / last visible row.
Enter Toggle the selected directory (no-op on files).
Space Re-emit current selection as a Selected event.
Collapse selected directory, or move to parent.
Expand selected directory, or move to first child.

See examples/keyboard_nav.rs for a complete working app.

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 std::sync::Arc;
use std::future::Future;
use std::pin::Pin;
use iced_swdir_tree::{ScanExecutor, ScanJob, ScanFuture, DirectoryTree};

struct TokioExecutor;

impl ScanExecutor for TokioExecutor {
    fn spawn_blocking(&self, job: ScanJob) -> ScanFuture {
        Box::pin(async move {
            tokio::task::spawn_blocking(job)
                .await
                .expect("scan task panicked")
        })
    }
}

let tree = DirectoryTree::new(root)
    .with_executor(Arc::new(TokioExecutor));

The default behaviour is unchanged if you don't call with_executor — existing v0.1 code keeps working as-is.

Architecture

See ARCHITECTURE.md.

Testing

See DEVELOPMENT.md#testing.