iced_swdir_tree/directory_tree/drag.rs
1//! v0.4: drag-and-drop state machine.
2//!
3//! The widget tracks an in-flight drag through a small state
4//! machine and emits a [`DragCompleted`](crate::DirectoryTreeEvent::DragCompleted)
5//! event when the user releases over a valid drop target. The
6//! widget itself never touches the filesystem — the application is
7//! responsible for actually moving / copying / symlinking / etc. the
8//! dragged paths. This keeps the widget a pure UI layer that works
9//! equally well for local files, network shares, zip archives, or
10//! any other hierarchical backend the app exposes.
11//!
12//! # Valid drop target
13//!
14//! A path `T` is a valid drop target for a drag carrying source set
15//! `S = {s1, s2, ...}` iff:
16//!
17//! 1. `T` is a directory — you can't drop into a file.
18//! 2. `T` is not itself a member of `S` — dropping A onto A is a no-op.
19//! 3. `T` is not a descendant of any member of `S` — dropping
20//! `/foo` onto `/foo/bar` would create a circular move.
21//!
22//! Validity is recomputed on every [`DragMsg::Entered`] event; the
23//! view reads `DragState::hover` to paint a highlight on the current
24//! drop target.
25//!
26//! # Deferred selection
27//!
28//! Because drags start with a mouse-down on a row and the same
29//! mouse-down would otherwise collapse a multi-selection to that
30//! single row (via v0.3's `SelectionMode::Replace`), the widget
31//! uses the standard "deferred selection" pattern: the view emits
32//! [`DragMsg::Pressed`] on mouse-down — which does **not** change
33//! the selection — and [`DragMsg::Released`] on mouse-up. If the
34//! release is on the same row as the press (i.e., the gesture was
35//! a click, not a drag), the widget dispatches a
36//! [`Selected(_, _, SelectionMode::Replace)`](crate::DirectoryTreeEvent::Selected)
37//! event at that point. This matches Windows Explorer / macOS
38//! Finder / VS Code behaviour: clicking an already-selected item
39//! doesn't clobber the multi-selection until the user lets go
40//! without having moved.
41
42use std::path::{Path, PathBuf};
43
44/// Opaque drag-machinery event produced by the widget's internal
45/// mouse-area instrumentation.
46///
47/// Applications should treat these as opaque payloads and route
48/// them back to [`DirectoryTree::update`](crate::DirectoryTree::update)
49/// unchanged — exactly like
50/// [`LoadPayload`](crate::LoadPayload). Apps generally never
51/// construct these variants by hand.
52#[derive(Debug, Clone)]
53pub enum DragMsg {
54 /// Mouse button was pressed on a row. The bool indicates
55 /// whether the row is a directory (relevant for valid-target
56 /// checks later if that row happens to be the release point).
57 Pressed(PathBuf, bool),
58 /// Cursor entered a row while a drag is in progress. The
59 /// widget decides whether the row is a valid drop target.
60 Entered(PathBuf),
61 /// Cursor left a row while a drag is in progress.
62 Exited(PathBuf),
63 /// Mouse button was released on a row. The widget inspects its
64 /// drag state to decide whether this was a click (same row as
65 /// press → emit a delayed `Selected`), a successful drop
66 /// (hover target set → emit `DragCompleted`), or a cancelled
67 /// drag (release on non-target → quietly clear state).
68 Released(PathBuf),
69 /// External cancellation signal. Emitted by the widget itself
70 /// when the user presses `Escape` while a drag is in flight,
71 /// or by the application if it wants to abort a drag for its
72 /// own reasons (e.g. a modal opened). Clearing drag state is
73 /// idempotent, so this is safe to call speculatively.
74 Cancelled,
75}
76
77/// In-progress drag state. Crate-internal — held on
78/// [`DirectoryTree`](crate::DirectoryTree) and mutated by the
79/// update layer.
80#[derive(Debug, Clone)]
81pub(crate) struct DragState {
82 /// The paths being dragged.
83 ///
84 /// At drag start this is the current selected set if the
85 /// pressed row is in the selection, otherwise just the pressed
86 /// row on its own. This matches Explorer/Finder behaviour:
87 /// pressing on an unselected row always drags only that row,
88 /// regardless of what was selected before.
89 pub(crate) sources: Vec<PathBuf>,
90 /// The path that was actually pressed. Used to tell "click"
91 /// (release on same row) from "drag" (release elsewhere).
92 pub(crate) primary: PathBuf,
93 /// Whether the primary row is a directory. Stashed at press
94 /// time so the same-row-release branch can emit a correctly-
95 /// typed `Selected` without re-looking-up the node.
96 pub(crate) primary_is_dir: bool,
97 /// The currently-hovered row, iff it is a valid drop target.
98 /// `None` when the cursor is over an invalid target (a file,
99 /// a descendant of a source, one of the sources themselves)
100 /// or over empty space between rows.
101 pub(crate) hover: Option<PathBuf>,
102}
103
104impl DragState {
105 /// Would `target` (of the given `is_dir`-ness) be a valid drop
106 /// destination for the current drag?
107 ///
108 /// See the [module-level docs](self) for the three rules.
109 pub(crate) fn is_valid_target(&self, target: &Path, target_is_dir: bool) -> bool {
110 if !target_is_dir {
111 return false;
112 }
113 // Can't drop onto one of the sources.
114 if self.sources.iter().any(|s| s == target) {
115 return false;
116 }
117 // Can't drop into a descendant of any source (circular).
118 // `starts_with` does component-wise comparison, so
119 // `"/a/b".starts_with("/a")` is true but
120 // `"/ab".starts_with("/a")` is false — safe.
121 if self.sources.iter().any(|s| target.starts_with(s)) {
122 return false;
123 }
124 true
125 }
126}
127
128#[cfg(test)]
129mod tests;