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;