nightshade_api/web/
keymap.rs1use leptos::prelude::*;
8use wasm_bindgen::JsCast;
9use web_sys::KeyboardEvent;
10
11use crate::web::command::{Command, use_commands};
12
13#[derive(Clone, PartialEq, Eq)]
14struct Combo {
15 modifier: bool,
16 alt: bool,
17 shift: bool,
18 key: String,
19}
20
21fn parse_combo(text: &str) -> Combo {
22 let mut combo = Combo {
23 modifier: false,
24 alt: false,
25 shift: false,
26 key: String::new(),
27 };
28 for token in text.split('+') {
29 let token = token.trim();
30 match token.to_lowercase().as_str() {
31 "ctrl" | "control" | "cmd" | "command" | "meta" | "super" | "mod" => {
32 combo.modifier = true;
33 }
34 "alt" | "option" => combo.alt = true,
35 "shift" => combo.shift = true,
36 "" => {}
37 other => combo.key = other.to_string(),
38 }
39 }
40 combo
41}
42
43fn parse_binding(text: &str) -> Vec<Combo> {
44 text.split_whitespace().map(parse_combo).collect()
45}
46
47fn event_combo(event: &KeyboardEvent) -> Combo {
48 Combo {
49 modifier: event.ctrl_key() || event.meta_key(),
50 alt: event.alt_key(),
51 shift: event.shift_key(),
52 key: event.key().to_lowercase(),
53 }
54}
55
56fn combo_matches(binding: &Combo, event: &Combo) -> bool {
57 binding.modifier == event.modifier
58 && binding.alt == event.alt
59 && (!binding.shift || event.shift)
60 && binding.key == event.key
61}
62
63fn sequence_matches(binding: &[Combo], pending: &[Combo]) -> bool {
64 binding.len() == pending.len()
65 && binding
66 .iter()
67 .zip(pending)
68 .all(|(left, right)| combo_matches(left, right))
69}
70
71fn sequence_prefixes(binding: &[Combo], pending: &[Combo]) -> bool {
72 binding.len() > pending.len()
73 && binding
74 .iter()
75 .zip(pending)
76 .all(|(left, right)| combo_matches(left, right))
77}
78
79fn is_bare_modifier(key: &str) -> bool {
80 matches!(key, "control" | "shift" | "alt" | "meta")
81}
82
83fn editable_focus() -> bool {
84 let Some(element) = web_sys::window()
85 .and_then(|window| window.document())
86 .and_then(|document| document.active_element())
87 else {
88 return false;
89 };
90 let tag = element.tag_name().to_lowercase();
91 if matches!(tag.as_str(), "input" | "textarea" | "select") {
92 return true;
93 }
94 element
95 .dyn_ref::<web_sys::HtmlElement>()
96 .is_some_and(|element| element.is_content_editable())
97}
98
99pub fn pretty_binding(binding: &str) -> String {
101 binding
102 .split_whitespace()
103 .map(|combo| {
104 combo
105 .split('+')
106 .map(|token| match token.trim().to_lowercase().as_str() {
107 "ctrl" | "control" | "cmd" | "command" | "meta" | "super" | "mod" => {
108 "Ctrl".to_string()
109 }
110 "alt" | "option" => "Alt".to_string(),
111 "shift" => "Shift".to_string(),
112 other if other.chars().count() == 1 => other.to_uppercase(),
113 other => {
114 let mut chars = other.chars();
115 match chars.next() {
116 Some(first) => {
117 first.to_uppercase().collect::<String>() + chars.as_str()
118 }
119 None => String::new(),
120 }
121 }
122 })
123 .collect::<Vec<_>>()
124 .join("+")
125 })
126 .collect::<Vec<_>>()
127 .join(" ")
128}
129
130const CHORD_RESET_MS: i32 = 900;
131
132fn combo_text(combo: &Combo) -> String {
133 let mut parts = Vec::new();
134 if combo.modifier {
135 parts.push("Ctrl".to_string());
136 }
137 if combo.alt {
138 parts.push("Alt".to_string());
139 }
140 if combo.shift {
141 parts.push("Shift".to_string());
142 }
143 let key = if combo.key.chars().count() == 1 {
144 combo.key.to_uppercase()
145 } else {
146 let mut chars = combo.key.chars();
147 match chars.next() {
148 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
149 None => String::new(),
150 }
151 };
152 parts.push(key);
153 parts.join("+")
154}
155
156fn collect_pending(commands: &[Command], pending: &[Combo], out: &mut Vec<(String, String)>) {
157 for command in commands {
158 if let Some(binding) = &command.keybinding {
159 let parsed = parse_binding(binding);
160 if sequence_prefixes(&parsed, pending) {
161 let remaining = parsed[pending.len()..]
162 .iter()
163 .map(combo_text)
164 .collect::<Vec<_>>()
165 .join(" ");
166 out.push((remaining, command.title.clone()));
167 }
168 }
169 collect_pending(&command.children, pending, out);
170 }
171}
172
173#[component]
180pub fn KeymapProvider(
181 #[prop(default = true)] which_key: bool,
182 children: Children,
183) -> impl IntoView {
184 let registry = use_commands();
185 let pending = RwSignal::new(Vec::<Combo>::new());
186 let generation = StoredValue::new(0u32);
187
188 let handle = window_event_listener(leptos::ev::keydown, move |event: KeyboardEvent| {
189 let current = event_combo(&event);
190 if is_bare_modifier(¤t.key) {
191 return;
192 }
193
194 let bindings = registry
195 .commands_untracked()
196 .into_iter()
197 .filter_map(|action| {
198 action
199 .keybinding
200 .as_ref()
201 .map(|binding| (action.id.clone(), parse_binding(binding)))
202 })
203 .collect::<Vec<_>>();
204 if bindings.is_empty() {
205 return;
206 }
207
208 let editing = editable_focus();
209 if editing && !current.modifier && !current.alt && pending.with(Vec::is_empty) {
210 return;
211 }
212
213 let mut sequence = pending.get_untracked();
214 sequence.push(current);
215
216 if let Some((id, _)) = bindings
217 .iter()
218 .find(|(_, binding)| sequence_matches(binding, &sequence))
219 {
220 event.prevent_default();
221 pending.set(Vec::new());
222 registry.run(id);
223 return;
224 }
225
226 let has_prefix = bindings
227 .iter()
228 .any(|(_, binding)| sequence_prefixes(binding, &sequence));
229 if has_prefix {
230 event.prevent_default();
231 pending.set(sequence);
232 schedule_reset(pending, generation);
233 return;
234 }
235
236 let last = sequence.pop().expect("sequence was just pushed");
237 let restart = vec![last];
238 if bindings
239 .iter()
240 .any(|(_, binding)| sequence_prefixes(binding, &restart))
241 {
242 event.prevent_default();
243 pending.set(restart);
244 schedule_reset(pending, generation);
245 } else {
246 pending.set(Vec::new());
247 }
248 });
249
250 on_cleanup(move || handle.remove());
251
252 let candidates = move || {
253 let pending = pending.get();
254 if pending.is_empty() {
255 return Vec::new();
256 }
257 let mut out = Vec::new();
258 collect_pending(®istry.commands(), &pending, &mut out);
259 out
260 };
261
262 view! {
263 {children()}
264 <Show when=move || which_key && !pending.get().is_empty() fallback=|| ()>
265 <div class="nightshade-whichkey">
266 <div class="nightshade-whichkey-prefix">
267 {move || {
268 pending.get().iter().map(combo_text).collect::<Vec<_>>().join(" ")
269 }}
270 </div>
271 <div class="nightshade-whichkey-list">
272 {move || {
273 candidates()
274 .into_iter()
275 .map(|(keys, title)| {
276 view! {
277 <div class="nightshade-whichkey-row">
278 <kbd class="nightshade-whichkey-keys">{keys}</kbd>
279 <span>{title}</span>
280 </div>
281 }
282 })
283 .collect_view()
284 }}
285 </div>
286 </div>
287 </Show>
288 }
289}
290
291fn schedule_reset(pending: RwSignal<Vec<Combo>>, generation: StoredValue<u32>) {
292 let current = generation.get_value().wrapping_add(1);
293 generation.set_value(current);
294 set_timeout(
295 move || {
296 if generation.get_value() == current {
297 pending.set(Vec::new());
298 }
299 },
300 std::time::Duration::from_millis(CHORD_RESET_MS as u64),
301 );
302}
303
304#[cfg(test)]
305mod tests {
306 use super::{
307 Combo, combo_matches, parse_binding, pretty_binding, sequence_matches, sequence_prefixes,
308 };
309
310 fn event(modifier: bool, key: &str) -> Combo {
311 Combo {
312 modifier,
313 alt: false,
314 shift: false,
315 key: key.to_string(),
316 }
317 }
318
319 #[test]
320 fn modifier_combos_parse_and_match() {
321 let binding = parse_binding("Ctrl+K");
322 assert_eq!(binding.len(), 1);
323 assert!(combo_matches(&binding[0], &event(true, "k")));
324 assert!(!combo_matches(&binding[0], &event(false, "k")));
325 }
326
327 #[test]
328 fn chord_sequences_match_and_prefix() {
329 let binding = parse_binding("g d");
330 let step_one = vec![event(false, "g")];
331 let full = vec![event(false, "g"), event(false, "d")];
332 assert!(sequence_prefixes(&binding, &step_one));
333 assert!(sequence_matches(&binding, &full));
334 assert!(!sequence_matches(&binding, &step_one));
335 }
336
337 #[test]
338 fn pretty_binding_is_readable() {
339 assert_eq!(pretty_binding("mod+shift+p"), "Ctrl+Shift+P");
340 assert_eq!(pretty_binding("g d"), "G D");
341 }
342}