Skip to main content

nightshade_api/web/
keymap.rs

1//! Global keyboard dispatch for registered commands, including multi-key chords.
2//!
3//! [`KeymapProvider`] listens for `keydown`, matches against each command's
4//! keybinding, and runs the first match; [`pretty_binding`] renders a binding
5//! string for display.
6
7use 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
99/// Formats a raw binding string into a readable label, capitalizing keys and normalizing modifier aliases (for example `"mod+shift+p"` becomes `"Ctrl+Shift+P"`).
100pub 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/// Installs a global `keydown` listener that matches keys against registered command bindings and runs the first match, supporting chord sequences (for example `g d`).
174///
175/// Wrap your app in this once. When `which_key` is enabled (the default) and a
176/// chord prefix is pending, it renders an overlay listing the possible
177/// completions. Bindings are ignored while a text input is focused unless a
178/// modifier is held or a chord is already in progress.
179#[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(&current.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(&registry.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}