iced_swdir_tree/directory_tree/view.rs
1//! Render a [`DirectoryTree`] as an `iced::Element`.
2//!
3//! The layout is a vertical scrollable column of rows; each row is a
4//! horizontal strip of indentation, caret, icon, and a button that
5//! emits the row's click event. The view delegates icon selection to
6//! the [`icon`](super::icon) module so the `icons` feature toggle never
7//! leaks into view logic.
8//!
9//! ## Virtualization
10//!
11//! Only nodes in collapsed ancestors are skipped (the column shrinks
12//! when they're closed). For very large loaded trees, iced's
13//! `Scrollable` clips off-screen rows at render time — see
14//! `iced::widget::scrollable` — so the cost of keeping them in the
15//! element tree is limited to the layout pass. This is the best we
16//! can do in iced 0.14 without a custom low-level widget, and it
17//! matches the spec's "avoid rendering nodes outside the visible area
18//! whenever possible" language.
19
20use std::path::Path;
21
22use iced::{
23 Alignment, Background, Border, Element, Length, Theme,
24 widget::{Space, button, column, container, mouse_area, row, scrollable, text},
25};
26
27use super::DirectoryTree;
28use super::drag::DragMsg;
29use super::icon::{IconRole, IconTheme, render as icon_render};
30use super::message::DirectoryTreeEvent;
31use super::node::TreeNode;
32
33/// Per-indent-level horizontal padding in logical pixels.
34const INDENT_STEP: f32 = 16.0;
35/// Horizontal gap between the caret, the icon, and the label, in
36/// logical pixels. iced 0.14's `.spacing()` takes `impl Into<Pixels>`;
37/// `f32` implements that conversion.
38const INTRA_ROW_GAP: f32 = 6.0;
39
40impl DirectoryTree {
41 /// Build an `iced::Element` that renders this tree.
42 ///
43 /// `on_event` is the closure that maps the widget's internal
44 /// [`DirectoryTreeEvent`]s into the parent application's own
45 /// message type. See the crate-level docs for a worked example.
46 pub fn view<'a, Message, F>(&'a self, on_event: F) -> Element<'a, Message>
47 where
48 Message: Clone + 'a,
49 F: Fn(DirectoryTreeEvent) -> Message + Copy + 'a,
50 {
51 // Recurse over the tree and collect rows into a single column
52 // inside a scrollable. `column` accepts an iterator, but we
53 // build a Vec explicitly because the recursion depth can
54 // exceed what inference wants to handle for a chained chain.
55 let mut rows: Vec<Element<'a, Message>> = Vec::new();
56 // Snapshot the current drop target so each row can paint its
57 // own highlight if it matches.
58 let drop_target = self.drop_target();
59 // v0.6: if a search is active, hand render_node the set of
60 // visible paths so it can bypass `is_expanded` — a collapsed
61 // ancestor of a match should render as if expanded.
62 let search_visible = self.search.as_ref().map(|s| &s.visible_paths);
63 // v0.7: the icon theme is stored on the tree; hand it
64 // through as `&dyn` so render_node / render_row can query
65 // it without needing to thread feature flags.
66 let icon_theme: &dyn IconTheme = self.icon_theme.as_ref();
67 render_node(
68 &self.root,
69 0,
70 drop_target,
71 search_visible,
72 icon_theme,
73 on_event,
74 &mut rows,
75 );
76
77 let list = column(rows).spacing(2).padding(4).width(Length::Fill);
78
79 scrollable(list)
80 .width(Length::Fill)
81 .height(Length::Fill)
82 .into()
83 }
84}
85
86/// Render a single node and its descendants (if expanded) into `out`.
87///
88/// When `search_visible` is `Some`, search is active: only paths
89/// in that set are rendered, and descent into children happens
90/// regardless of `is_expanded`. When `search_visible` is `None`,
91/// the normal `is_expanded && is_loaded` descent rule applies.
92fn render_node<'a, Message, F>(
93 node: &'a TreeNode,
94 depth: u32,
95 drop_target: Option<&Path>,
96 search_visible: Option<&std::collections::HashSet<std::path::PathBuf>>,
97 icon_theme: &dyn IconTheme,
98 on_event: F,
99 out: &mut Vec<Element<'a, Message>>,
100) where
101 Message: Clone + 'a,
102 F: Fn(DirectoryTreeEvent) -> Message + Copy + 'a,
103{
104 // v0.6 search: skip nodes outside the visible set entirely.
105 if let Some(visible) = search_visible
106 && !visible.contains(&node.path)
107 {
108 return;
109 }
110 let is_drop_target = drop_target == Some(node.path.as_path());
111 out.push(render_row(
112 node,
113 depth,
114 is_drop_target,
115 icon_theme,
116 on_event,
117 ));
118
119 // Descent rule:
120 // - Search active: always descend (children are gated by the
121 // `visible` check above, so we correctly skip non-match
122 // siblings while still reaching deeper matches).
123 // - Search inactive: normal is_expanded && is_loaded rule.
124 let descend = match search_visible {
125 Some(_) => node.is_dir,
126 None => node.is_dir && node.is_expanded && node.is_loaded,
127 };
128 if descend {
129 for child in &node.children {
130 render_node(
131 child,
132 depth + 1,
133 drop_target,
134 search_visible,
135 icon_theme,
136 on_event,
137 out,
138 );
139 }
140 }
141}
142
143/// Render a single row of the tree.
144fn render_row<'a, Message, F>(
145 node: &'a TreeNode,
146 depth: u32,
147 is_drop_target: bool,
148 icon_theme: &dyn IconTheme,
149 on_event: F,
150) -> Element<'a, Message>
151where
152 Message: Clone + 'a,
153 F: Fn(DirectoryTreeEvent) -> Message + Copy + 'a,
154{
155 // Visible label: the entry's file name, with a fallback to the
156 // full path for the root (whose file_name() may be None, e.g.
157 // `/` on Unix or `C:\` on Windows).
158 let label_str: String = match node.path.file_name() {
159 Some(n) => n.to_string_lossy().into_owned(),
160 None => node.path.display().to_string(),
161 };
162
163 // The folder/file icon.
164 let type_icon: Element<'a, Message> = if node.error.is_some() {
165 icon_render::<Message>(icon_theme, IconRole::Error)
166 } else if node.is_dir {
167 if node.is_expanded {
168 icon_render::<Message>(icon_theme, IconRole::FolderOpen)
169 } else {
170 icon_render::<Message>(icon_theme, IconRole::FolderClosed)
171 }
172 } else {
173 icon_render::<Message>(icon_theme, IconRole::File)
174 };
175
176 // The label itself. Permission-denied rows render in a muted
177 // foreground so the user sees at a glance that the node is
178 // unreadable rather than merely empty. iced 0.14 doesn't expose
179 // a single "dimmed" helper, so we set a literal mid-grey that
180 // works acceptably on both light and dark themes.
181 let label_widget = {
182 let t = text(label_str).size(14);
183 if node.error.is_some() {
184 t.color(iced::Color::from_rgb(0.55, 0.55, 0.55))
185 } else {
186 t
187 }
188 };
189
190 // --- Caret (the fold/unfold affordance) ----------------------
191 //
192 // We split the row into two click targets *side by side* rather
193 // than nesting a caret button inside a selection button: iced's
194 // button-inside-button hit-testing is undefined and can swallow
195 // the inner press. The caret handles Toggled; the rest of the
196 // row (icon + label inside a second button) handles Selected.
197 let caret: Element<'a, Message> = if node.is_dir {
198 let caret_role = if node.is_expanded {
199 IconRole::CaretDown
200 } else {
201 IconRole::CaretRight
202 };
203 let path = node.path.clone();
204 button(icon_render::<Message>(icon_theme, caret_role))
205 .padding(2)
206 .style(button::text)
207 .on_press(on_event(DirectoryTreeEvent::Toggled(path)))
208 .into()
209 } else {
210 // Files: fixed-size placeholder so the icon column aligns
211 // with the directory rows above and below.
212 Space::new()
213 .width(Length::Fixed(20.0))
214 .height(Length::Fixed(20.0))
215 .into()
216 };
217
218 // --- Selection body (icon + label) ---------------------------
219 let selection_body = row![
220 type_icon,
221 Space::new().width(Length::Fixed(4.0)),
222 label_widget,
223 ]
224 .spacing(INTRA_ROW_GAP)
225 .align_y(Alignment::Center);
226
227 // --- Row hitbox (selection + drag-and-drop) ------------------
228 //
229 // v0.4: we used to wrap `selection_body` in a `button` whose
230 // `on_press` emitted `Selected(..., Replace)` directly. That
231 // worked for single-click selection but made drag-and-drop
232 // impossible, for two reasons:
233 //
234 // 1. iced 0.14's `button::on_press` fires on mouse-*up*, not
235 // mouse-*down*, so we can't detect the start of a drag
236 // gesture from the button alone.
237 // 2. Even if we could, a mouse-down that immediately fires
238 // `Selected(..., Replace)` would collapse any existing
239 // multi-selection down to the pressed row before the drag
240 // state machine had a chance to snapshot the current set
241 // of sources — breaking multi-item drag.
242 //
243 // The fix is twofold. First, wrap the body in a `mouse_area`,
244 // whose four event handlers (press / release / enter / exit)
245 // are what the drag state machine in `update::on_drag` needs.
246 // Second, defer selection: mouse-down emits `Drag(Pressed)`
247 // (not `Selected`), and `on_drag` emits a delayed
248 // `Selected(..., Replace)` only if the user releases on the
249 // same row — i.e., it was a click, not a drag. See
250 // `drag.rs` for the full state machine.
251 //
252 // Visual style is now provided by a styled `container` wrapper
253 // rather than by `button`. We replicate the two states `button`
254 // previously gave us — normal and primary (selected) — and add
255 // a third for the current drop target during an in-flight
256 // drag.
257 let is_selected = node.is_selected;
258 let path = node.path.clone();
259 let is_dir = node.is_dir;
260 // Clone once per handler to satisfy Fn borrow semantics.
261 let path_for_press = path.clone();
262 let path_for_enter = path.clone();
263 let path_for_exit = path.clone();
264 let path_for_release = path;
265
266 let styled_body = container(selection_body)
267 .width(Length::Fill)
268 .padding(2)
269 .style(move |theme: &Theme| {
270 let palette = theme.extended_palette();
271 if is_selected {
272 container::Style {
273 background: Some(Background::Color(palette.primary.base.color)),
274 text_color: Some(palette.primary.base.text),
275 border: Border {
276 radius: 3.0.into(),
277 ..Default::default()
278 },
279 ..Default::default()
280 }
281 } else if is_drop_target {
282 // Drop-target highlight: soft success-coloured
283 // fill plus a 1.5-px outline, so even users with
284 // weak colour vision can see where the drop will
285 // land. Using the theme's `success` palette rather
286 // than a hard-coded green keeps dark themes
287 // readable.
288 container::Style {
289 background: Some(Background::Color(palette.success.weak.color)),
290 text_color: Some(palette.success.weak.text),
291 border: Border {
292 color: palette.success.strong.color,
293 width: 1.5,
294 radius: 3.0.into(),
295 },
296 ..Default::default()
297 }
298 } else {
299 container::Style::default()
300 }
301 });
302
303 let select_area = mouse_area(styled_body)
304 .on_press(on_event(DirectoryTreeEvent::Drag(DragMsg::Pressed(
305 path_for_press,
306 is_dir,
307 ))))
308 .on_enter(on_event(DirectoryTreeEvent::Drag(DragMsg::Entered(
309 path_for_enter,
310 ))))
311 .on_exit(on_event(DirectoryTreeEvent::Drag(DragMsg::Exited(
312 path_for_exit,
313 ))))
314 .on_release(on_event(DirectoryTreeEvent::Drag(DragMsg::Released(
315 path_for_release,
316 ))));
317
318 // Left indent. Using a Space rather than padding so the selection
319 // highlight runs the full visible row width — padding would
320 // shrink the highlight by the indent amount.
321 let indent_px = INDENT_STEP * depth as f32;
322 let indent = Space::new().width(Length::Fixed(indent_px));
323
324 container(
325 row![indent, caret, select_area]
326 .spacing(INTRA_ROW_GAP)
327 .align_y(Alignment::Center),
328 )
329 .width(Length::Fill)
330 .into()
331}
332
333/// (Kept for future debugging.) Format a path for display in a row's
334/// tooltip.
335#[allow(dead_code)]
336fn display_path(path: &Path) -> String {
337 path.display().to_string()
338}