Skip to main content

iced_swdir_tree/directory_tree/
keyboard.rs

1//! Keyboard navigation adapter.
2//!
3//! [`DirectoryTree::handle_key`] translates a key press into an
4//! appropriate [`DirectoryTreeEvent`], which the application routes
5//! back through the widget's normal `update` flow. Applications are
6//! expected to subscribe to `iced::keyboard::on_key_press` themselves
7//! and pipe the key through this method — the widget stays focus-
8//! neutral on purpose so apps can decide when the tree "has focus"
9//! (e.g. only when it's visible, or only when a sidebar toggle is
10//! on).
11//!
12//! # Bindings
13//!
14//! | Key | Behaviour |
15//! |---|---|
16//! | `Up` / `Down` | Move the selection to the previous / next visible row. |
17//! | `Shift + Up` / `Shift + Down` | Extend the selected range toward the previous / next visible row. |
18//! | `Home` / `End` | Move the selection to the first / last visible row. |
19//! | `Shift + Home` / `Shift + End` | Extend the selected range to the first / last visible row. |
20//! | `Enter` | Toggle the currently-selected directory (no-op on files). |
21//! | `Space` / `Ctrl + Space` | Toggle the currently-active path in or out of the selected set. |
22//! | `Left` | If the selection is an expanded directory → collapse it. Otherwise move the selection to its parent. |
23//! | `Right` | If the selection is a collapsed directory → expand it. If it's an expanded directory with loaded children → move the selection to the first child. Otherwise no-op. |
24//! | `Escape` | If a drag is in progress → cancel it. Otherwise unbound (so apps can still bind Escape for their own UI). |
25//!
26//! "Visible row" is defined the way the view draws the tree: the
27//! root, plus every descendant whose every ancestor is expanded and
28//! loaded. Filtered-out nodes are not visible, and therefore not
29//! traversable with arrow keys.
30
31use std::path::Path;
32
33use iced::keyboard::{self, Modifiers, key::Named};
34
35use super::DirectoryTree;
36use super::drag::DragMsg;
37use super::message::DirectoryTreeEvent;
38use super::node::{TreeNode, VisibleRow};
39use super::selection::SelectionMode;
40
41impl DirectoryTree {
42    /// Translate a key press into the event that keyboard navigation
43    /// should produce.
44    ///
45    /// Returns `None` when the key has no binding in the current
46    /// state (e.g. `Right` on a file, or `Up` when no row is
47    /// selected and the tree is empty). Callers can safely ignore
48    /// the `None` case.
49    ///
50    /// This method is `&self` — it never mutates the tree. The
51    /// returned event, if any, must be fed back through
52    /// [`DirectoryTree::update`] like any other event so the
53    /// existing state-machine (selection set, cache, generation
54    /// counter) stays authoritative.
55    ///
56    /// # Example
57    ///
58    /// ```ignore
59    /// use iced::keyboard;
60    /// // ...in your iced subscription function:
61    /// fn subscription(app: &App) -> iced::Subscription<Message> {
62    ///     keyboard::listen().map(|event| match event {
63    ///         keyboard::Event::KeyPressed { key, modifiers, .. } =>
64    ///             Message::TreeKey(key, modifiers),
65    ///         _ => Message::Noop,
66    ///     })
67    /// }
68    ///
69    /// // ...in your update:
70    /// Message::TreeKey(key, mods) => {
71    ///     if let Some(event) = app.tree.handle_key(&key, mods) {
72    ///         return app.tree.update(event).map(Message::Tree);
73    ///     }
74    ///     Task::none()
75    /// }
76    /// ```
77    pub fn handle_key(
78        &self,
79        key: &keyboard::Key,
80        modifiers: Modifiers,
81    ) -> Option<DirectoryTreeEvent> {
82        // Only `Named` keys are bound at the moment — we don't handle
83        // character keys (typing "a" to jump to entries starting with
84        // "a" is a nice future feature, not a v0.3 one).
85        let keyboard::Key::Named(named) = key else {
86            return None;
87        };
88
89        // Navigation mode: Shift extends, everything else replaces.
90        let nav_mode = if modifiers.shift() {
91            SelectionMode::ExtendRange
92        } else {
93            SelectionMode::Replace
94        };
95
96        // Precompute the flat list of visible rows — the same
97        // ordering the view uses. Most bindings need to know
98        // "where am I in this list" and "what's next / previous".
99        let rows = self.visible_rows();
100
101        match named {
102            Named::ArrowDown => self.move_selection(&rows, Direction::Next, nav_mode),
103            Named::ArrowUp => self.move_selection(&rows, Direction::Prev, nav_mode),
104            Named::Home => rows.first().map(|r| select_event(r, nav_mode)),
105            Named::End => rows.last().map(|r| select_event(r, nav_mode)),
106            Named::Enter => self.enter_action(),
107            // Space and Ctrl+Space both toggle the active path in
108            // and out of the selected set — the standard
109            // tree-widget Space behaviour. This is a deliberate
110            // change from v0.2, where Space re-emitted the current
111            // selection as Replace.
112            Named::Space => self.toggle_active(),
113            Named::ArrowLeft => self.left_action(&rows),
114            Named::ArrowRight => self.right_action(),
115            // v0.4: Escape cancels an in-flight drag. Emitted
116            // unconditionally — if no drag is active, the
117            // `DragMsg::Cancelled` handler in `update` is a no-op.
118            // We only surface the event if a drag is actually in
119            // progress so that apps can still bind Escape to
120            // their own actions when the tree isn't dragging.
121            Named::Escape if self.drag.is_some() => {
122                Some(DirectoryTreeEvent::Drag(DragMsg::Cancelled))
123            }
124            _ => None,
125        }
126    }
127
128    /// Return the event that moves selection along the flat visible-rows list.
129    fn move_selection(
130        &self,
131        rows: &[VisibleRow<'_>],
132        dir: Direction,
133        mode: SelectionMode,
134    ) -> Option<DirectoryTreeEvent> {
135        if rows.is_empty() {
136            return None;
137        }
138        // No active path yet → jump to the first (ArrowDown) or
139        // last (ArrowUp) row. This matches the usual list-widget
140        // idiom. The mode is carried through so Shift+arrow from a
141        // fresh tree still produces an ExtendRange event (which
142        // will fall back to Replace in update() given there's no
143        // anchor yet).
144        let Some(current) = self.active_path.as_deref() else {
145            return match dir {
146                Direction::Next => rows.first().map(|r| select_event(r, mode)),
147                Direction::Prev => rows.last().map(|r| select_event(r, mode)),
148            };
149        };
150        let Some(idx) = rows.iter().position(|r| r.node.path == current) else {
151            return match dir {
152                Direction::Next => rows.first().map(|r| select_event(r, mode)),
153                Direction::Prev => rows.last().map(|r| select_event(r, mode)),
154            };
155        };
156        let next_idx = match dir {
157            Direction::Next => idx.saturating_add(1),
158            Direction::Prev => idx.checked_sub(1)?,
159        };
160        rows.get(next_idx).map(|r| select_event(r, mode))
161    }
162
163    /// Enter → toggle the currently-active directory; no-op on files.
164    fn enter_action(&self) -> Option<DirectoryTreeEvent> {
165        let path = self.active_path.as_deref()?;
166        let node = find(&self.root, path)?;
167        if node.is_dir {
168            Some(DirectoryTreeEvent::Toggled(path.to_path_buf()))
169        } else {
170            None
171        }
172    }
173
174    /// Space → toggle the active path in/out of the selected set.
175    fn toggle_active(&self) -> Option<DirectoryTreeEvent> {
176        let path = self.active_path.as_deref()?;
177        let node = find(&self.root, path)?;
178        Some(DirectoryTreeEvent::Selected(
179            path.to_path_buf(),
180            node.is_dir,
181            SelectionMode::Toggle,
182        ))
183    }
184
185    /// Left:
186    /// * expanded directory → collapse it
187    /// * otherwise → move selection to parent (if visible)
188    fn left_action(&self, rows: &[VisibleRow<'_>]) -> Option<DirectoryTreeEvent> {
189        let path = self.active_path.as_deref()?;
190        let node = find(&self.root, path)?;
191        if node.is_dir && node.is_expanded {
192            return Some(DirectoryTreeEvent::Toggled(path.to_path_buf()));
193        }
194        let current_idx = rows.iter().position(|r| r.node.path == path)?;
195        let current_depth = rows[current_idx].depth;
196        if current_depth == 0 {
197            return None;
198        }
199        let parent = rows[..current_idx]
200            .iter()
201            .rev()
202            .find(|r| r.depth < current_depth)?;
203        Some(select_event(parent, SelectionMode::Replace))
204    }
205
206    /// Right:
207    /// * collapsed directory → expand it
208    /// * expanded directory with loaded children → move selection to first child
209    /// * file → no-op
210    fn right_action(&self) -> Option<DirectoryTreeEvent> {
211        let path = self.active_path.as_deref()?;
212        let node = find(&self.root, path)?;
213        if !node.is_dir {
214            return None;
215        }
216        if !node.is_expanded {
217            return Some(DirectoryTreeEvent::Toggled(path.to_path_buf()));
218        }
219        let first = node.children.first()?;
220        Some(DirectoryTreeEvent::Selected(
221            first.path.clone(),
222            first.is_dir,
223            SelectionMode::Replace,
224        ))
225    }
226}
227
228// ----------------------------------------------------------------
229// Helpers
230// ----------------------------------------------------------------
231
232#[derive(Clone, Copy)]
233enum Direction {
234    Next,
235    Prev,
236}
237
238fn find<'a>(node: &'a TreeNode, target: &Path) -> Option<&'a TreeNode> {
239    if node.path == target {
240        return Some(node);
241    }
242    if !target.starts_with(&node.path) {
243        return None;
244    }
245    for child in &node.children {
246        if let Some(hit) = find(child, target) {
247            return Some(hit);
248        }
249    }
250    None
251}
252
253fn select_event(row: &VisibleRow<'_>, mode: SelectionMode) -> DirectoryTreeEvent {
254    DirectoryTreeEvent::Selected(row.node.path.clone(), row.node.is_dir, mode)
255}
256
257// ----------------------------------------------------------------
258// Tests
259// ----------------------------------------------------------------
260
261#[cfg(test)]
262mod tests;