Skip to main content

dioxus_swdir_tree_core/
keyboard.rs

1//! Framework-neutral keyboard navigation for [`crate::DirectoryTree`].
2//!
3//! [`handle_key`] is **read-only**: it inspects tree state and returns an
4//! optional [`crate::DirectoryTreeEvent`] without mutating anything.
5//! The host dispatches the event back through `on_toggled` / `on_selected`,
6//! keeping a single mutation funnel and making the function trivially
7//! testable without any async infrastructure.
8//!
9//! The view crate maps Dioxus `KeyboardEvent` values onto [`TreeKey`] and
10//! [`Modifiers`]; other embedding layers can supply their own mapping.
11
12use std::path::PathBuf;
13
14use crate::DirectoryTree;
15use crate::drag::DragMsg;
16use crate::event::DirectoryTreeEvent;
17use crate::selection::SelectionMode;
18
19// ── Public types ──────────────────────────────────────────────────────────────
20
21/// A bound key — the framework-neutral representation of a key press.
22///
23/// The view crate maps each `dioxus::prelude::Key` variant to one of
24/// these; other embedding layers supply their own mapping.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum TreeKey {
27    Up,
28    Down,
29    Home,
30    End,
31    Enter,
32    /// Space and Ctrl+Space are both mapped here (S4.6, S4.7).
33    Space,
34    Left,
35    Right,
36    /// Escape — only produces an event when drag is active (RFC 008).
37    /// Deliberately `None` when no drag is in progress (S4.10).
38    Escape,
39}
40
41/// Active modifier keys at the time of the key press.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
43pub struct Modifiers {
44    pub shift: bool,
45    pub ctrl: bool,
46}
47
48// ── handle_key ────────────────────────────────────────────────────────────────
49
50/// Translate a key press into a [`DirectoryTreeEvent`], or `None` when
51/// the key is unbound (hosts may handle it themselves).
52///
53/// All movement is computed over [`DirectoryTree::visible_rows`] relative
54/// to `active_path`. If `active_path` is not currently visible, movement
55/// keys and all keys that need an active node no-op; `Home` and `End`
56/// still work.
57///
58/// # Escape
59///
60/// Escape is unbound in v0.4 because drag is not yet active (RFC 008).
61/// It always returns `None` here; the host can safely bind it for other
62/// purposes.
63pub fn handle_key(
64    tree: &DirectoryTree,
65    key: TreeKey,
66    modifiers: Modifiers,
67) -> Option<DirectoryTreeEvent> {
68    let rows = tree.visible_rows();
69
70    // Index of the currently active row (None if active_path is invisible).
71    let active_idx: Option<usize> = tree
72        .selected_path()
73        .and_then(|active| rows.iter().position(|(node, _)| node.path == active));
74
75    match (key, modifiers.shift) {
76        // ── Arrow keys ────────────────────────────────────────────────
77        (TreeKey::Up, false) => {
78            let idx = active_idx?.checked_sub(1)?;
79            let (node, _) = &rows[idx];
80            Some(selected(
81                node.path.clone(),
82                node.is_dir,
83                SelectionMode::Replace,
84            ))
85        }
86
87        (TreeKey::Up, true) => {
88            // Shift+Up
89            let idx = active_idx?.checked_sub(1)?;
90            let (node, _) = &rows[idx];
91            Some(selected(
92                node.path.clone(),
93                node.is_dir,
94                SelectionMode::ExtendRange,
95            ))
96        }
97
98        (TreeKey::Down, false) => {
99            let idx = active_idx? + 1;
100            let (node, _) = rows.get(idx)?;
101            Some(selected(
102                node.path.clone(),
103                node.is_dir,
104                SelectionMode::Replace,
105            ))
106        }
107
108        (TreeKey::Down, true) => {
109            // Shift+Down
110            let idx = active_idx? + 1;
111            let (node, _) = rows.get(idx)?;
112            Some(selected(
113                node.path.clone(),
114                node.is_dir,
115                SelectionMode::ExtendRange,
116            ))
117        }
118
119        // ── Home / End ────────────────────────────────────────────────
120        (TreeKey::Home, false) => {
121            let (node, _) = rows.first()?;
122            Some(selected(
123                node.path.clone(),
124                node.is_dir,
125                SelectionMode::Replace,
126            ))
127        }
128
129        (TreeKey::Home, true) => {
130            let (node, _) = rows.first()?;
131            Some(selected(
132                node.path.clone(),
133                node.is_dir,
134                SelectionMode::ExtendRange,
135            ))
136        }
137
138        (TreeKey::End, false) => {
139            let (node, _) = rows.last()?;
140            Some(selected(
141                node.path.clone(),
142                node.is_dir,
143                SelectionMode::Replace,
144            ))
145        }
146
147        (TreeKey::End, true) => {
148            let (node, _) = rows.last()?;
149            Some(selected(
150                node.path.clone(),
151                node.is_dir,
152                SelectionMode::ExtendRange,
153            ))
154        }
155
156        // ── Enter — toggle active directory ──────────────────────────
157        (TreeKey::Enter, _) => {
158            let idx = active_idx?;
159            let (node, _) = &rows[idx];
160            if !node.is_dir {
161                return None;
162            }
163            Some(DirectoryTreeEvent::Toggled(node.path.clone()))
164        }
165
166        // ── Space / Ctrl+Space — toggle-select active row ─────────────
167        (TreeKey::Space, _) => {
168            let idx = active_idx?;
169            let (node, _) = &rows[idx];
170            Some(selected(
171                node.path.clone(),
172                node.is_dir,
173                SelectionMode::Toggle,
174            ))
175        }
176
177        // ── Left — collapse or move to parent ─────────────────────────
178        (TreeKey::Left, _) => {
179            let idx = active_idx?;
180            let (node, _) = &rows[idx];
181            if node.is_dir && node.is_expanded {
182                // Collapse the expanded directory.
183                Some(DirectoryTreeEvent::Toggled(node.path.clone()))
184            } else {
185                // Move to parent — no-op at the tree root.
186                if node.path == tree.config().root_path {
187                    return None;
188                }
189                let parent: PathBuf = node.path.parent()?.to_path_buf();
190                Some(selected(parent, true, SelectionMode::Replace))
191            }
192        }
193
194        // ── Right — expand or move to first child ─────────────────────
195        (TreeKey::Right, _) => {
196            let idx = active_idx?;
197            let (node, _) = &rows[idx];
198            if !node.is_dir {
199                return None;
200            }
201            if !node.is_expanded {
202                // Expand the collapsed directory.
203                Some(DirectoryTreeEvent::Toggled(node.path.clone()))
204            } else {
205                // Move to the first visible child (the next row that is a
206                // direct child of this node).
207                let next_idx = idx + 1;
208                let (next_node, _) = rows.get(next_idx)?;
209                if next_node.path.parent() != Some(node.path.as_path()) {
210                    return None; // directory is expanded but has no visible children
211                }
212                Some(selected(
213                    next_node.path.clone(),
214                    next_node.is_dir,
215                    SelectionMode::Replace,
216                ))
217            }
218        }
219
220        // ── Escape — unbound until drag (RFC 008) ────────────────────
221        // ── Escape — cancels drag if active; unbound otherwise (S7.4)
222        (TreeKey::Escape, _) => {
223            if tree.drag_state().is_some() {
224                Some(DirectoryTreeEvent::Drag(DragMsg::Cancelled))
225            } else {
226                None
227            }
228        }
229    }
230}
231
232// ── Private helpers ───────────────────────────────────────────────────────────
233
234#[inline]
235fn selected(path: PathBuf, is_dir: bool, mode: SelectionMode) -> DirectoryTreeEvent {
236    DirectoryTreeEvent::Selected { path, is_dir, mode }
237}