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}