Skip to main content

xei_core/
which_key.rs

1//! Which-key style chord maps — discoverable prefixes for Normal mode.
2//!
3//! Instant feedback after a short delay (see [`WhichKeyState::ready`]) so fast
4//! typists never see a flash. Space is the leader key with nested menus.
5
6use std::time::{Duration, Instant};
7
8/// Delay before the chord popup appears.
9pub const WHICH_KEY_DELAY_MS: u64 = 220;
10
11/// One row in a which-key menu: key label + description.
12#[derive(Debug, Clone, Copy)]
13pub struct ChordHint {
14    pub key: &'static str,
15    pub desc: &'static str,
16}
17
18impl ChordHint {
19    pub const fn new(key: &'static str, desc: &'static str) -> Self {
20        Self { key, desc }
21    }
22}
23
24/// Active which-key / leader session.
25#[derive(Debug, Clone)]
26pub struct WhichKeyState {
27    /// Leader path after Space. `None` = leader inactive.
28    /// `Some("")` = Space root; `Some("f")` = Space-f files menu; etc.
29    pub leader: Option<String>,
30    /// Title shown on the popup (e.g. `g`, `SPC f`, `Ctrl+W`).
31    pub title: String,
32    /// When this prefix session started (for delay).
33    pub started: Option<Instant>,
34}
35
36impl Default for WhichKeyState {
37    fn default() -> Self {
38        Self {
39            leader: None,
40            title: String::new(),
41            started: None,
42        }
43    }
44}
45
46impl WhichKeyState {
47    pub fn clear(&mut self) {
48        self.leader = None;
49        self.title.clear();
50        self.started = None;
51    }
52
53    pub fn is_leader(&self) -> bool {
54        self.leader.is_some()
55    }
56
57    /// Begin a non-leader prefix (g, z, d, …). Caller also fills `pending_hints`.
58    pub fn begin_prefix(&mut self, title: &str) {
59        self.leader = None;
60        self.title = title.to_string();
61        self.started = Some(Instant::now());
62    }
63
64    /// Open Space leader at root.
65    pub fn begin_leader(&mut self) {
66        self.leader = Some(String::new());
67        self.title = "SPC".into();
68        self.started = Some(Instant::now());
69    }
70
71    /// Enter a submenu under the leader (`f`, `g`, …).
72    pub fn enter_leader_sub(&mut self, key: char, title_suffix: &str) {
73        self.leader = Some(key.to_string());
74        self.title = format!("SPC {title_suffix}");
75        self.started = Some(Instant::now());
76    }
77
78    /// True when the popup should paint (delay elapsed).
79    pub fn ready(&self) -> bool {
80        match self.started {
81            Some(t) => t.elapsed() >= Duration::from_millis(WHICH_KEY_DELAY_MS),
82            None => false,
83        }
84    }
85
86    /// Force ready (for tests or immediate show).
87    pub fn force_ready(&mut self) {
88        self.started = Some(Instant::now() - Duration::from_millis(WHICH_KEY_DELAY_MS + 1));
89    }
90}
91
92// ── Static maps ────────────────────────────────────────────────────────────
93
94static MAP_G: &[ChordHint] = &[
95    ChordHint::new("g", "go to top"),
96    ChordHint::new("d", "go to definition"),
97    ChordHint::new("p", "peek definition"),
98    ChordHint::new("r", "references"),
99    ChordHint::new("C", "call hierarchy"),
100    ChordHint::new("I", "incoming calls"),
101    ChordHint::new("H", "outgoing calls"),
102    ChordHint::new("O", "document symbols"),
103    ChordHint::new("b", "git blame panel"),
104    ChordHint::new("t", "next tab"),
105    ChordHint::new("T", "prev tab"),
106];
107
108static MAP_Z: &[ChordHint] = &[
109    ChordHint::new("a", "toggle fold"),
110    ChordHint::new("c", "close fold"),
111    ChordHint::new("o", "open fold"),
112    ChordHint::new("M", "close all folds"),
113    ChordHint::new("R", "open all folds"),
114];
115
116static MAP_BRACKET_CLOSE: &[ChordHint] = &[
117    ChordHint::new("d", "next diagnostic"),
118    ChordHint::new("c", "next git change"),
119];
120
121static MAP_BRACKET_OPEN: &[ChordHint] = &[
122    ChordHint::new("d", "prev diagnostic"),
123    ChordHint::new("c", "prev git change"),
124];
125
126static MAP_CTRL_W: &[ChordHint] = &[
127    ChordHint::new("v", "vertical split"),
128    ChordHint::new("s", "horizontal split"),
129    ChordHint::new("w", "other pane"),
130    ChordHint::new("q", "close split"),
131    ChordHint::new("=", "equalize"),
132    ChordHint::new("h/l", "focus left/right"),
133    ChordHint::new("j/k", "focus down/up"),
134    ChordHint::new("</>", "resize"),
135];
136
137static MAP_REGISTER: &[ChordHint] = &[
138    ChordHint::new("a-z", "named register"),
139    ChordHint::new("A-Z", "append named"),
140    ChordHint::new("+/*", "system clipboard"),
141    ChordHint::new("\"", "unnamed"),
142];
143
144static MAP_MARK_SET: &[ChordHint] = &[ChordHint::new("a-z", "set mark")];
145static MAP_MARK_JUMP_LINE: &[ChordHint] = &[ChordHint::new("a-z", "jump to mark (line)")];
146static MAP_MARK_JUMP_EXACT: &[ChordHint] = &[ChordHint::new("a-z", "jump to mark (exact)")];
147static MAP_MACRO_RECORD: &[ChordHint] = &[ChordHint::new("a-z", "record macro")];
148static MAP_MACRO_PLAY: &[ChordHint] = &[
149    ChordHint::new("a-z", "play macro"),
150    ChordHint::new("@", "repeat last"),
151];
152
153static MAP_OP_DELETE: &[ChordHint] = &[
154    ChordHint::new("d", "delete line"),
155    ChordHint::new("w", "word"),
156    ChordHint::new("iw", "inner word"),
157    ChordHint::new("$", "to end of line"),
158    ChordHint::new("i\"", "in quotes"),
159    ChordHint::new("ib", "in parens"),
160    ChordHint::new("G", "to EOF"),
161    ChordHint::new("gg", "to BOF"),
162];
163
164static MAP_OP_CHANGE: &[ChordHint] = &[
165    ChordHint::new("c", "change line"),
166    ChordHint::new("w", "word"),
167    ChordHint::new("iw", "inner word"),
168    ChordHint::new("$", "to eol"),
169    ChordHint::new("i\"", "in quotes"),
170    ChordHint::new("ib", "in parens"),
171];
172
173static MAP_OP_YANK: &[ChordHint] = &[
174    ChordHint::new("y", "yank line"),
175    ChordHint::new("w", "word"),
176    ChordHint::new("iw", "inner word"),
177    ChordHint::new("$", "to eol"),
178    ChordHint::new("i\"", "in quotes"),
179];
180
181static MAP_TEXTOBJECT: &[ChordHint] = &[
182    ChordHint::new("w", "word"),
183    ChordHint::new("W", "WORD"),
184    ChordHint::new("\"/'", "quotes"),
185    ChordHint::new("b )", "parens"),
186    ChordHint::new("B }", "braces"),
187    ChordHint::new("[ ]", "brackets"),
188    ChordHint::new("t", "tag (html)"),
189];
190
191static MAP_SPACE_ROOT: &[ChordHint] = &[
192    ChordHint::new("f", "files…"),
193    ChordHint::new("b", "buffers…"),
194    ChordHint::new("g", "git…"),
195    ChordHint::new("l", "lsp…"),
196    ChordHint::new("d", "debug…"),
197    ChordHint::new("w", "window…"),
198    ChordHint::new("s", "search…"),
199    ChordHint::new("c", "code…"),
200    ChordHint::new("t", "toggle…"),
201    ChordHint::new("h", "help / settings"),
202    ChordHint::new("p", "command palette"),
203    ChordHint::new("/", "find in files"),
204    ChordHint::new(",", "settings"),
205    ChordHint::new("e", "file explorer"),
206    ChordHint::new(";", "XLC command"),
207];
208
209static MAP_SPACE_D: &[ChordHint] = &[
210    ChordHint::new("d", "debug panel focus"),
211    ChordHint::new("s", "start / continue (F5)"),
212    ChordHint::new("b", "toggle breakpoint (F9)"),
213    ChordHint::new("n", "step over (F10)"),
214    ChordHint::new("i", "step into (F11)"),
215    ChordHint::new("o", "step out (Shift+F11)"),
216    ChordHint::new("p", "pause (F6)"),
217    ChordHint::new("x", "stop (Shift+F5)"),
218    ChordHint::new("r", "restart"),
219    ChordHint::new("c", "launch.json configs"),
220    ChordHint::new("a", "attach help"),
221];
222
223static MAP_SPACE_F: &[ChordHint] = &[
224    ChordHint::new("f", "quick open file"),
225    ChordHint::new("e", "toggle explorer"),
226    ChordHint::new("s", "save"),
227    ChordHint::new("S", "save as (:w)"),
228    ChordHint::new("p", "pretty preview"),
229    ChordHint::new("r", "reload from disk"),
230];
231
232static MAP_SPACE_B: &[ChordHint] = &[
233    ChordHint::new("n", "next tab"),
234    ChordHint::new("p", "prev tab"),
235    ChordHint::new("d", "close buffer"),
236    ChordHint::new("b", "quick open"),
237    ChordHint::new("1-9", "goto tab (if open)"),
238];
239
240static MAP_SPACE_G: &[ChordHint] = &[
241    ChordHint::new("g", "git workbench"),
242    ChordHint::new("s", "source control"),
243    ChordHint::new("b", "blame panel"),
244    ChordHint::new("r", "interactive rebase"),
245    ChordHint::new("v", "PR review (selected)"),
246    ChordHint::new("f", "fetch"),
247    ChordHint::new("p", "pull"),
248    ChordHint::new("P", "push"),
249];
250
251static MAP_SPACE_L: &[ChordHint] = &[
252    ChordHint::new("d", "definition"),
253    ChordHint::new("r", "references"),
254    ChordHint::new("c", "call hierarchy"),
255    ChordHint::new("h", "hover (K)"),
256    ChordHint::new("a", "code actions"),
257    ChordHint::new("f", "format document"),
258    ChordHint::new("o", "outline / symbols"),
259    ChordHint::new("R", "rename"),
260    ChordHint::new("n", "next diagnostic"),
261    ChordHint::new("p", "prev diagnostic"),
262];
263
264static MAP_SPACE_W: &[ChordHint] = &[
265    ChordHint::new("v", "vertical split"),
266    ChordHint::new("s", "horizontal split"),
267    ChordHint::new("w", "other pane"),
268    ChordHint::new("q", "close split"),
269    ChordHint::new("=", "equalize"),
270    ChordHint::new("t", "terminal split"),
271];
272
273static MAP_SPACE_S: &[ChordHint] = &[
274    ChordHint::new("s", "search in buffer"),
275    ChordHint::new("S", "search backward"),
276    ChordHint::new("f", "find in files"),
277    ChordHint::new("o", "document symbols"),
278    ChordHint::new("w", "workspace symbols"),
279];
280
281static MAP_SPACE_C: &[ChordHint] = &[
282    ChordHint::new("a", "code actions"),
283    ChordHint::new("f", "format"),
284    ChordHint::new("r", "rename"),
285    ChordHint::new("d", "definition"),
286    ChordHint::new("R", "references"),
287];
288
289static MAP_SPACE_T: &[ChordHint] = &[
290    ChordHint::new("b", "blame panel"),
291    ChordHint::new("e", "explorer"),
292    ChordHint::new("t", "terminal side"),
293    ChordHint::new("T", "terminal full"),
294    ChordHint::new("i", "inlay hints"),
295    ChordHint::new("l", "code lens"),
296    ChordHint::new("r", "relative numbers"),
297    ChordHint::new("p", "pretty preview"),
298];
299
300static MAP_SPACE_H: &[ChordHint] = &[
301    ChordHint::new("h", "settings · help"),
302    ChordHint::new(",", "settings"),
303    ChordHint::new("k", "key hints on/off"),
304    ChordHint::new("s", "screensaver"),
305];
306
307pub fn map_g() -> &'static [ChordHint] {
308    MAP_G
309}
310pub fn map_z() -> &'static [ChordHint] {
311    MAP_Z
312}
313pub fn map_bracket_close() -> &'static [ChordHint] {
314    MAP_BRACKET_CLOSE
315}
316pub fn map_bracket_open() -> &'static [ChordHint] {
317    MAP_BRACKET_OPEN
318}
319pub fn map_ctrl_w() -> &'static [ChordHint] {
320    MAP_CTRL_W
321}
322pub fn map_register() -> &'static [ChordHint] {
323    MAP_REGISTER
324}
325pub fn map_mark_set() -> &'static [ChordHint] {
326    MAP_MARK_SET
327}
328pub fn map_mark_jump_line() -> &'static [ChordHint] {
329    MAP_MARK_JUMP_LINE
330}
331pub fn map_mark_jump_exact() -> &'static [ChordHint] {
332    MAP_MARK_JUMP_EXACT
333}
334pub fn map_macro_record() -> &'static [ChordHint] {
335    MAP_MACRO_RECORD
336}
337pub fn map_macro_play() -> &'static [ChordHint] {
338    MAP_MACRO_PLAY
339}
340pub fn map_operator_delete() -> &'static [ChordHint] {
341    MAP_OP_DELETE
342}
343pub fn map_operator_change() -> &'static [ChordHint] {
344    MAP_OP_CHANGE
345}
346pub fn map_operator_yank() -> &'static [ChordHint] {
347    MAP_OP_YANK
348}
349pub fn map_textobject() -> &'static [ChordHint] {
350    MAP_TEXTOBJECT
351}
352pub fn map_space_root() -> &'static [ChordHint] {
353    MAP_SPACE_ROOT
354}
355pub fn map_space_f() -> &'static [ChordHint] {
356    MAP_SPACE_F
357}
358pub fn map_space_b() -> &'static [ChordHint] {
359    MAP_SPACE_B
360}
361pub fn map_space_g() -> &'static [ChordHint] {
362    MAP_SPACE_G
363}
364pub fn map_space_l() -> &'static [ChordHint] {
365    MAP_SPACE_L
366}
367pub fn map_space_w() -> &'static [ChordHint] {
368    MAP_SPACE_W
369}
370pub fn map_space_s() -> &'static [ChordHint] {
371    MAP_SPACE_S
372}
373pub fn map_space_c() -> &'static [ChordHint] {
374    MAP_SPACE_C
375}
376pub fn map_space_d() -> &'static [ChordHint] {
377    MAP_SPACE_D
378}
379pub fn map_space_t() -> &'static [ChordHint] {
380    MAP_SPACE_T
381}
382pub fn map_space_h() -> &'static [ChordHint] {
383    MAP_SPACE_H
384}
385
386/// Convert static map → app hint vec.
387pub fn as_hints(map: &[ChordHint]) -> Vec<(&'static str, &'static str)> {
388    map.iter().map(|h| (h.key, h.desc)).collect()
389}
390
391/// Hints for the current leader path (`""` root, `"f"`, …).
392pub fn leader_hints(path: &str) -> Vec<(&'static str, &'static str)> {
393    let map = match path {
394        "" => MAP_SPACE_ROOT,
395        "f" => MAP_SPACE_F,
396        "b" => MAP_SPACE_B,
397        "g" => MAP_SPACE_G,
398        "l" => MAP_SPACE_L,
399        "w" => MAP_SPACE_W,
400        "s" => MAP_SPACE_S,
401        "c" => MAP_SPACE_C,
402        "d" => MAP_SPACE_D,
403        "t" => MAP_SPACE_T,
404        "h" => MAP_SPACE_H,
405        _ => MAP_SPACE_ROOT,
406    };
407    as_hints(map)
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413
414    #[test]
415    fn delay_gate() {
416        let mut w = WhichKeyState::default();
417        w.begin_leader();
418        assert!(!w.ready());
419        w.force_ready();
420        assert!(w.ready());
421    }
422
423    #[test]
424    fn leader_maps_nonempty() {
425        assert!(!map_space_root().is_empty());
426        assert!(!leader_hints("f").is_empty());
427        assert!(!map_g().is_empty());
428    }
429}