Skip to main content

drag_drop/
drag_drop.rs

1//! Drag-and-drop demo — move files and folders between nodes.
2//!
3//! Run with:
4//!
5//! ```sh
6//! cargo run --example drag_drop -- /path/to/scratch
7//! ```
8//!
9//! Defaults to a *freshly created* scratch directory under the OS
10//! temp dir so you can't lose real data while experimenting. The
11//! scratch dir is populated with a few folders and files on first
12//! run so there's something to drag around.
13//!
14//! # What the example demonstrates
15//!
16//! The `DirectoryTree` widget itself performs **no** filesystem
17//! work: it emits a `DirectoryTreeEvent::DragCompleted { sources,
18//! destination }` event when the user drops one or more paths onto
19//! a valid folder, and the application decides what to actually do
20//! with them. This example handles the event by calling
21//! [`std::fs::rename`] on each source, then refreshes both the
22//! source-parent folders and the destination folder so the widget
23//! picks up the new layout on screen.
24//!
25//! # Multi-item drag and Esc-to-cancel
26//!
27//! Exactly as in `examples/multi_select.rs`, modifier tracking is
28//! done at the application layer so that Shift/Ctrl-click build up
29//! a multi-selection. Pressing the mouse on an already-selected row
30//! drags the whole set; pressing on a row outside the selection
31//! drags just that row. `Escape` cancels an in-flight drag (the
32//! widget's built-in binding — see `keyboard.rs`).
33
34use std::collections::HashSet;
35use std::fs;
36use std::path::{Path, PathBuf};
37
38use iced::keyboard::{self, Modifiers};
39use iced::widget::{Column, column, container, scrollable, text};
40use iced::{Element, Length, Subscription, Task};
41use iced_swdir_tree::{DirectoryFilter, DirectoryTree, DirectoryTreeEvent, SelectionMode};
42
43#[derive(Debug, Clone)]
44enum Message {
45    Tree(DirectoryTreeEvent),
46    ModifiersChanged(Modifiers),
47    Key(keyboard::Key, Modifiers),
48}
49
50struct App {
51    tree: DirectoryTree,
52    modifiers: Modifiers,
53    /// Last status line to show under the tree. Either a
54    /// drag-preview ("Drop N items onto X?") or the result of the
55    /// most recent move operation ("Moved 3 items into X").
56    status: String,
57}
58
59impl App {
60    fn new() -> (Self, Task<Message>) {
61        let root = resolve_root();
62        let root_for_task = root.clone();
63        let tree = DirectoryTree::new(root).with_filter(DirectoryFilter::FilesAndFolders);
64        (
65            Self {
66                tree,
67                modifiers: Modifiers::default(),
68                status: "Drag a row onto a folder to move it. \
69                         Shift/Ctrl-click for multi-select. \
70                         Esc cancels an in-flight drag."
71                    .to_string(),
72            },
73            Task::done(Message::Tree(DirectoryTreeEvent::Toggled(root_for_task))),
74        )
75    }
76
77    fn update(&mut self, message: Message) -> Task<Message> {
78        match message {
79            // As in the multi-select example, rewrite the built-in
80            // view's `Replace`-only `Selected` events using the
81            // current modifier state. Keyboard events come through
82            // `handle_key` with the correct mode already.
83            Message::Tree(DirectoryTreeEvent::Selected(path, is_dir, _)) => {
84                let mode = SelectionMode::from_modifiers(self.modifiers);
85                let event = DirectoryTreeEvent::Selected(path, is_dir, mode);
86                self.tree.update(event).map(Message::Tree)
87            }
88            // The headline case: the user released the mouse over a
89            // valid drop target. Perform the actual filesystem
90            // operation, then refresh affected folders so the tree
91            // view reflects the new layout.
92            Message::Tree(DirectoryTreeEvent::DragCompleted {
93                sources,
94                destination,
95            }) => {
96                let outcome = move_paths(&sources, &destination);
97                self.status = outcome.summary();
98                // The set of folders that need re-scanning: the
99                // destination (for the newly-arrived entries) and
100                // every source's parent (for the departed entries).
101                let mut to_refresh: HashSet<PathBuf> = HashSet::new();
102                to_refresh.insert(destination);
103                for s in &sources {
104                    if let Some(parent) = s.parent() {
105                        to_refresh.insert(parent.to_path_buf());
106                    }
107                }
108                // Issue a collapse+expand for each affected folder.
109                // A collapse followed by a `Toggled` on the same
110                // path is the simplest way in v0.4 to invalidate
111                // the cached children and re-scan from scratch.
112                let tasks: Vec<Task<Message>> = to_refresh
113                    .into_iter()
114                    .flat_map(|p| {
115                        [
116                            Task::done(Message::Tree(DirectoryTreeEvent::Toggled(p.clone()))),
117                            Task::done(Message::Tree(DirectoryTreeEvent::Toggled(p))),
118                        ]
119                    })
120                    .collect();
121                Task::batch(tasks)
122            }
123            Message::Tree(event) => self.tree.update(event).map(Message::Tree),
124            Message::ModifiersChanged(m) => {
125                self.modifiers = m;
126                Task::none()
127            }
128            Message::Key(key, mods) => {
129                if let Some(event) = self.tree.handle_key(&key, mods) {
130                    return self.tree.update(event).map(Message::Tree);
131                }
132                Task::none()
133            }
134        }
135    }
136
137    fn subscription(&self) -> Subscription<Message> {
138        keyboard::listen().map(|event| match event {
139            keyboard::Event::KeyPressed { key, modifiers, .. } => Message::Key(key, modifiers),
140            keyboard::Event::ModifiersChanged(modifiers) => Message::ModifiersChanged(modifiers),
141            _ => Message::Key(
142                keyboard::Key::Named(keyboard::key::Named::F35),
143                Modifiers::default(),
144            ),
145        })
146    }
147
148    fn view(&self) -> Element<'_, Message> {
149        // While a drag is in progress, override the static status
150        // line with a live preview of where the drop will land.
151        let live_status = if self.tree.is_dragging() {
152            match self.tree.drop_target() {
153                Some(dest) => format!(
154                    "Drop {} onto {}?",
155                    describe_sources(self.tree.drag_sources()),
156                    short_name(dest),
157                ),
158                None => format!(
159                    "Dragging {} — hover over a folder",
160                    describe_sources(self.tree.drag_sources()),
161                ),
162            }
163        } else {
164            self.status.clone()
165        };
166
167        let status = Column::new().push(text(live_status).size(13)).spacing(2);
168
169        container(
170            column![
171                scrollable(self.tree.view(Message::Tree)).height(Length::Fill),
172                status,
173            ]
174            .spacing(8.0)
175            .padding(8.0),
176        )
177        .width(Length::Fill)
178        .height(Length::Fill)
179        .into()
180    }
181}
182
183/// Determine the root path to browse.
184///
185/// If the user passes a path on the command line, use that.
186/// Otherwise, create (or reuse) a scratch directory under the OS
187/// temp dir populated with some example files, so that this
188/// example is always "safe" to run and produces something to drag
189/// around.
190fn resolve_root() -> PathBuf {
191    if let Some(arg) = std::env::args().nth(1) {
192        return PathBuf::from(arg);
193    }
194    let scratch = std::env::temp_dir().join("iced-swdir-tree-drag-demo");
195    let _ = fs::create_dir_all(&scratch);
196    // Populate once. If the user already has files in here we
197    // leave them alone; the demo just has more to look at.
198    for folder in &["inbox", "archive", "drafts"] {
199        let _ = fs::create_dir_all(scratch.join(folder));
200    }
201    for (name, body) in &[
202        ("notes.txt", "drop me somewhere"),
203        ("todo.md", "- try dragging this into `inbox`\n"),
204        ("ideas.txt", "multi-select me with Ctrl or Shift"),
205    ] {
206        let p = scratch.join(name);
207        if !p.exists() {
208            let _ = fs::write(p, body);
209        }
210    }
211    scratch
212}
213
214/// Result of `move_paths`: how many moves succeeded and how many
215/// failed. `dest` lets us compose a nice status message.
216struct MoveOutcome {
217    moved: usize,
218    failed: Vec<(PathBuf, std::io::Error)>,
219    dest: PathBuf,
220}
221
222impl MoveOutcome {
223    fn summary(&self) -> String {
224        match (self.moved, self.failed.len()) {
225            (n, 0) => format!(
226                "Moved {} item{} into {}",
227                n,
228                plural(n),
229                short_name(&self.dest)
230            ),
231            (0, f) => format!(
232                "Failed to move {} item{} into {}: {}",
233                f,
234                plural(f),
235                short_name(&self.dest),
236                self.failed[0].1,
237            ),
238            (n, f) => format!(
239                "Moved {} into {}, {} failed (e.g. {}: {})",
240                n,
241                short_name(&self.dest),
242                f,
243                short_name(&self.failed[0].0),
244                self.failed[0].1,
245            ),
246        }
247    }
248}
249
250/// Move each path in `sources` into `dest`.
251///
252/// Uses `std::fs::rename` which is atomic within a single
253/// filesystem. Real apps might want to fall back to copy+delete
254/// across mount points, preserve mtimes, etc. — this example keeps
255/// it short.
256fn move_paths(sources: &[PathBuf], dest: &Path) -> MoveOutcome {
257    let mut moved = 0;
258    let mut failed = Vec::new();
259    for src in sources {
260        let Some(name) = src.file_name() else {
261            continue;
262        };
263        let target = dest.join(name);
264        // Guard against overwriting: if the target exists, skip.
265        if target.exists() {
266            failed.push((
267                src.clone(),
268                std::io::Error::new(
269                    std::io::ErrorKind::AlreadyExists,
270                    "destination already has an entry with that name",
271                ),
272            ));
273            continue;
274        }
275        match fs::rename(src, &target) {
276            Ok(()) => moved += 1,
277            Err(e) => failed.push((src.clone(), e)),
278        }
279    }
280    MoveOutcome {
281        moved,
282        failed,
283        dest: dest.to_path_buf(),
284    }
285}
286
287/// Describe the drag-sources slice for status-bar display.
288/// "notes.txt" for 1 item, "3 items" for more.
289fn describe_sources(sources: &[PathBuf]) -> String {
290    match sources {
291        [] => "nothing".into(),
292        [p] => short_name(p),
293        _ => format!("{} items", sources.len()),
294    }
295}
296
297fn short_name(p: &Path) -> String {
298    p.file_name()
299        .map(|s| s.to_string_lossy().into_owned())
300        .unwrap_or_else(|| p.display().to_string())
301}
302
303fn plural(n: usize) -> &'static str {
304    if n == 1 { "" } else { "s" }
305}
306
307fn main() -> iced::Result {
308    iced::application(App::new, App::update, App::view)
309        .subscription(App::subscription)
310        .title("iced-swdir-tree · drag-and-drop example")
311        .run()
312}