Skip to main content

typ_core/
keymap.rs

1//! Chord string → `Action`, as data rather than control flow.
2//!
3//! Bindings live in a table because three things need to read them: the input
4//! loop, help text, and — once the vim layer lands — a second table swapped in
5//! wholesale. A `match` on `KeyCode` can be read by exactly one of those.
6
7use std::collections::BTreeMap;
8
9use anyhow::{Context, Result, anyhow};
10
11use crate::{Action, Direction, KeyChord, Motion};
12
13#[derive(Debug, Clone)]
14pub struct Keymap {
15    /// Canonical chord string → action. `BTreeMap` so `bindings_for` and any
16    /// help listing come out in a stable order rather than a hash order that
17    /// changes between runs.
18    bindings: BTreeMap<String, Action>,
19}
20
21/// The non-modal defaults, shaped like what someone arriving from a GUI editor
22/// already has in their fingers.
23const DEFAULTS: &[(&str, Action)] = &[
24    (
25        "left",
26        Action::Move {
27            motion: Motion::Left,
28            extend: false,
29        },
30    ),
31    (
32        "shift+left",
33        Action::Move {
34            motion: Motion::Left,
35            extend: true,
36        },
37    ),
38    (
39        "right",
40        Action::Move {
41            motion: Motion::Right,
42            extend: false,
43        },
44    ),
45    (
46        "shift+right",
47        Action::Move {
48            motion: Motion::Right,
49            extend: true,
50        },
51    ),
52    (
53        "up",
54        Action::Move {
55            motion: Motion::Up,
56            extend: false,
57        },
58    ),
59    (
60        "shift+up",
61        Action::Move {
62            motion: Motion::Up,
63            extend: true,
64        },
65    ),
66    (
67        "down",
68        Action::Move {
69            motion: Motion::Down,
70            extend: false,
71        },
72    ),
73    (
74        "shift+down",
75        Action::Move {
76            motion: Motion::Down,
77            extend: true,
78        },
79    ),
80    (
81        "ctrl+left",
82        Action::Move {
83            motion: Motion::WordLeft,
84            extend: false,
85        },
86    ),
87    (
88        "ctrl+shift+left",
89        Action::Move {
90            motion: Motion::WordLeft,
91            extend: true,
92        },
93    ),
94    (
95        "ctrl+right",
96        Action::Move {
97            motion: Motion::WordRight,
98            extend: false,
99        },
100    ),
101    (
102        "ctrl+shift+right",
103        Action::Move {
104            motion: Motion::WordRight,
105            extend: true,
106        },
107    ),
108    (
109        "home",
110        Action::Move {
111            motion: Motion::LineStart,
112            extend: false,
113        },
114    ),
115    (
116        "shift+home",
117        Action::Move {
118            motion: Motion::LineStart,
119            extend: true,
120        },
121    ),
122    (
123        "end",
124        Action::Move {
125            motion: Motion::LineEnd,
126            extend: false,
127        },
128    ),
129    (
130        "shift+end",
131        Action::Move {
132            motion: Motion::LineEnd,
133            extend: true,
134        },
135    ),
136    (
137        "pageup",
138        Action::Move {
139            motion: Motion::PageUp,
140            extend: false,
141        },
142    ),
143    (
144        "shift+pageup",
145        Action::Move {
146            motion: Motion::PageUp,
147            extend: true,
148        },
149    ),
150    (
151        "pagedown",
152        Action::Move {
153            motion: Motion::PageDown,
154            extend: false,
155        },
156    ),
157    (
158        "shift+pagedown",
159        Action::Move {
160            motion: Motion::PageDown,
161            extend: true,
162        },
163    ),
164    (
165        "ctrl+home",
166        Action::Move {
167            motion: Motion::DocumentStart,
168            extend: false,
169        },
170    ),
171    (
172        "ctrl+shift+home",
173        Action::Move {
174            motion: Motion::DocumentStart,
175            extend: true,
176        },
177    ),
178    (
179        "ctrl+end",
180        Action::Move {
181            motion: Motion::DocumentEnd,
182            extend: false,
183        },
184    ),
185    (
186        "ctrl+shift+end",
187        Action::Move {
188            motion: Motion::DocumentEnd,
189            extend: true,
190        },
191    ),
192    (
193        "backspace",
194        Action::Delete {
195            direction: Direction::Backward,
196            by_word: false,
197        },
198    ),
199    (
200        "ctrl+backspace",
201        Action::Delete {
202            direction: Direction::Backward,
203            by_word: true,
204        },
205    ),
206    (
207        "delete",
208        Action::Delete {
209            direction: Direction::Forward,
210            by_word: false,
211        },
212    ),
213    (
214        "ctrl+delete",
215        Action::Delete {
216            direction: Direction::Forward,
217            by_word: true,
218        },
219    ),
220    ("enter", Action::InsertNewline),
221    ("ctrl+z", Action::Undo),
222    ("ctrl+y", Action::Redo),
223    ("ctrl+a", Action::SelectAll),
224    ("ctrl+l", Action::SelectLine),
225    // VS Code, Sublime and ttt all put select-next-occurrence on Ctrl+D. TYPE
226    // has no chord *sequences*, so Ctrl+K L for select-all is unavailable and
227    // this takes VS Code's other binding for it.
228    ("ctrl+d", Action::SelectNextOccurrence),
229    ("ctrl+shift+l", Action::SelectAllOccurrences),
230    ("esc", Action::CollapseSelections),
231    ("ctrl+alt+up", Action::AddCursor(Direction::Backward)),
232    ("ctrl+alt+down", Action::AddCursor(Direction::Forward)),
233    ("ctrl+s", Action::Save),
234    ("ctrl+q", Action::Quit),
235    // Tab indents, because no code editor is usable otherwise. Focus moves to
236    // F6, which browsers and IDEs already use for pane cycling and which
237    // survives every terminal — Ctrl+Tab is bound too, but a terminal without
238    // the kitty keyboard protocol cannot tell it apart from a bare Tab.
239    //
240    // Consequence, accepted: Tab does nothing in the file tree, which has no
241    // indent concept and no named actions of its own yet. F6 works from both
242    // panels, so nothing became unreachable. Architecture §5 already records
243    // naming the tree's primitives as M4 work.
244    ("tab", Action::Indent),
245    ("shift+tab", Action::Outdent),
246    ("f6", Action::FocusNext),
247    ("ctrl+tab", Action::FocusNext),
248    ("ctrl+g", Action::GotoLine),
249    ("ctrl+f", Action::SearchOpen),
250    ("f3", Action::SearchNext),
251    ("shift+f3", Action::SearchPrevious),
252    ("ctrl+h", Action::ReplaceOpen),
253    ("ctrl+c", Action::Copy),
254    ("ctrl+x", Action::Cut),
255    ("ctrl+v", Action::Paste),
256    // The Insert trio, because a terminal may swallow Ctrl+C before TYPE ever
257    // sees it and a user who cannot copy has no way to discover why.
258    ("ctrl+insert", Action::Copy),
259    ("shift+delete", Action::Cut),
260    ("shift+insert", Action::Paste),
261    // Bound because people reach for them, though whether they ever arrive is
262    // the terminal's decision on two counts. Most emulators bind Ctrl+Shift+C/V
263    // to their *own* copy and paste and never forward the key. And in the legacy
264    // encoding a Ctrl+letter chord collapses to one control byte that carries no
265    // shift bit at all, so the terminal could not report the difference even if
266    // it wanted to — that needs the kitty keyboard protocol, which arrives with
267    // capability detection at M2.5. Windows is the exception: its console API
268    // reports full modifier state, so these work there today.
269    //
270    // Harmless where they are swallowed: the chord that does arrive is plain
271    // ctrl+c, which is already bound to the same action.
272    ("ctrl+shift+c", Action::Copy),
273    ("ctrl+shift+x", Action::Cut),
274    ("ctrl+shift+v", Action::Paste),
275];
276
277impl Keymap {
278    pub fn default_bindings() -> Self {
279        Self {
280            bindings: DEFAULTS
281                .iter()
282                .map(|(chord, action)| ((*chord).to_string(), *action))
283                .collect(),
284        }
285    }
286
287    pub fn lookup(&self, chord: &KeyChord) -> Option<Action> {
288        self.bindings.get(&chord.canonical).copied()
289    }
290
291    /// Chords bound to an action, for help text and the future palette.
292    pub fn bindings_for(&self, action: Action) -> Vec<&str> {
293        self.bindings
294            .iter()
295            .filter(|(_, a)| **a == action)
296            .map(|(chord, _)| chord.as_str())
297            .collect()
298    }
299
300    /// Apply a user config over the current bindings.
301    ///
302    /// Parsed into a staging list first, so a config with one bad line changes
303    /// nothing. A half-applied keymap is worse than a rejected one: the user
304    /// cannot tell which half took effect.
305    pub fn merge_toml(&mut self, src: &str) -> Result<()> {
306        let table: BTreeMap<String, String> =
307            toml::from_str(src).context("parsing the keybinding table")?;
308
309        let mut staged: Vec<(String, Option<Action>)> = Vec::new();
310        for (chord, action_name) in table {
311            if action_name.is_empty() {
312                // An empty action unbinds, which a user needs in order to free
313                // a chord their terminal or window manager wants for itself.
314                staged.push((chord, None));
315                continue;
316            }
317            let action = Action::from_name(&action_name)
318                .ok_or_else(|| anyhow!("{chord} is bound to an unknown action: {action_name}"))?;
319            staged.push((chord, Some(action)));
320        }
321
322        for (chord, action) in staged {
323            match action {
324                Some(action) => {
325                    self.bindings.insert(chord, action);
326                }
327                None => {
328                    self.bindings.remove(&chord);
329                }
330            }
331        }
332        Ok(())
333    }
334}
335
336impl Default for Keymap {
337    fn default() -> Self {
338        Self::default_bindings()
339    }
340}