Skip to main content

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}