Skip to main content

max / shop

15.5 KB · 439 lines History Blame Raw
1 //! Which chords shop keeps for itself, and how the user takes them back.
2 //!
3 //! Shop sees a key before the program inside it does, so every binding here is
4 //! a key the shell never receives. That is fine for a small conventional set —
5 //! nobody misses Ctrl+Shift+C — and stops being fine the moment shop wants a
6 //! chord of its own, because the user's multiplexer prefix is a key shop has no
7 //! right to. tmux is where a chord goes to die.
8 //!
9 //! So `none` is the load-bearing value in this module, not a convenience. A
10 //! config that can only *re*bind cannot give a key back, and every default here
11 //! has to be surrenderable:
12 //!
13 //! ```toml
14 //! [keys]
15 //! copy = "ctrl+shift+y" # move it
16 //! paste = "none" # or give it up entirely
17 //! ```
18 //!
19 //! # Scope
20 //!
21 //! A table of chords, not a mode system and not a command language. The actions
22 //! are a closed enum shop defines; there are no user-defined actions and no
23 //! scripting, and adding one is a code change on purpose.
24
25 use std::collections::HashMap;
26
27 use smithay_client_toolkit::seat::keyboard::Keysym;
28
29 use shop_xkb::Mods;
30
31 /// Something shop does instead of forwarding the key.
32 ///
33 /// The name in the config is the snake_case of the variant, which
34 /// [`Action::from_name`] and [`Action::name`] are the two halves of.
35 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
36 pub(crate) enum Action {
37 ScrollPageUp,
38 ScrollPageDown,
39 Copy,
40 Paste,
41 /// Hand the whole buffer, scrollback included, to the emit runner.
42 EmitBuffer,
43 /// Hand the visible screen to the emit runner.
44 EmitScreen,
45 /// Hand the current selection to the emit runner.
46 EmitSelection,
47 }
48
49 impl Action {
50 /// Every action, so the config parser can reject an unknown key by name
51 /// rather than ignoring it.
52 const ALL: [Self; 7] = [
53 Self::ScrollPageUp,
54 Self::ScrollPageDown,
55 Self::Copy,
56 Self::Paste,
57 Self::EmitBuffer,
58 Self::EmitScreen,
59 Self::EmitSelection,
60 ];
61
62 pub(crate) fn name(self) -> &'static str {
63 match self {
64 Self::ScrollPageUp => "scroll_page_up",
65 Self::ScrollPageDown => "scroll_page_down",
66 Self::Copy => "copy",
67 Self::Paste => "paste",
68 Self::EmitBuffer => "emit_buffer",
69 Self::EmitScreen => "emit_screen",
70 Self::EmitSelection => "emit_selection",
71 }
72 }
73
74 fn from_name(name: &str) -> Option<Self> {
75 Self::ALL.into_iter().find(|a| a.name() == name)
76 }
77 }
78
79 /// A key plus the exact modifier state it fires under.
80 ///
81 /// Exact, not "at least": a chord of `shift+page_up` does not fire while alt is
82 /// also held. The old hardcoded test was `shift && !ctrl`, which quietly said
83 /// yes to alt+shift+PageUp. Exactness is what makes a binding table
84 /// predictable — a user who binds two chords on the same key with different
85 /// modifiers gets both, and neither shadows the other.
86 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
87 pub(crate) struct Chord {
88 mods: Mods,
89 key: Keysym,
90 }
91
92 impl Chord {
93 /// A chord, with the key folded to its lowercase form.
94 ///
95 /// Shift makes xkb hand over the capital, so `ctrl+shift+c` arrives as `C`
96 /// while the config says `c`. Folding both sides at construction covers
97 /// every key, including the ones a layout or a stuck lock makes
98 /// unpredictable, rather than matching capitals case by case.
99 fn new(mods: Mods, key: Keysym) -> Self {
100 let key = key
101 .key_char()
102 .filter(|c| c.is_uppercase())
103 .and_then(|c| c.to_lowercase().next())
104 .map_or(key, Keysym::from_char);
105 Self { mods, key }
106 }
107 }
108
109 /// The chord table shop matches every key press against.
110 #[derive(Debug, Clone)]
111 pub(crate) struct Bindings {
112 map: HashMap<Chord, Action>,
113 }
114
115 impl Default for Bindings {
116 /// The set shop shipped hardcoded, now expressed as defaults so there is
117 /// one mechanism rather than two.
118 ///
119 /// Ctrl+Shift is the terminal's half of the keyboard by long convention,
120 /// precisely because Ctrl alone belongs to the program: Ctrl+C has to stay
121 /// the interrupt, so copy takes the shifted form. Shift+Page is the
122 /// conventional scrollback binding, and the shell has no use for it.
123 fn default() -> Self {
124 let shift = Mods {
125 shift: true,
126 ..Mods::default()
127 };
128 let ctrl_shift = Mods {
129 shift: true,
130 ctrl: true,
131 ..Mods::default()
132 };
133 let map = [
134 (Chord::new(shift, Keysym::Page_Up), Action::ScrollPageUp),
135 (Chord::new(shift, Keysym::Page_Down), Action::ScrollPageDown),
136 (Chord::new(ctrl_shift, Keysym::c), Action::Copy),
137 (Chord::new(ctrl_shift, Keysym::v), Action::Paste),
138 // Only the buffer emit gets a default chord. It is the motivating
139 // case — the reason shop has no scrollback search — and one new
140 // key is the least shop can take to make the feature reachable.
141 // emit_screen and emit_selection ship unbound: they are cheap to
142 // bind and expensive to take, since every default here is a key
143 // the shell never sees.
144 (Chord::new(ctrl_shift, Keysym::e), Action::EmitBuffer),
145 ];
146 Self {
147 map: map.into_iter().collect(),
148 }
149 }
150 }
151
152 impl Bindings {
153 /// Apply a `[keys]` table over the defaults.
154 ///
155 /// Every entry names an action and gives it a chord, or `none` to leave the
156 /// action unbound. An unreadable entry is reported and skipped, keeping the
157 /// default: this is the terminal somebody opens to fix their config, so it
158 /// has to come up.
159 pub(crate) fn from_table(table: &toml::Table) -> Self {
160 let mut bindings = Self::default();
161 for (name, value) in table {
162 let Some(action) = Action::from_name(name) else {
163 tracing::warn!(key = %name, "ignoring an unknown [keys] action");
164 continue;
165 };
166 let Some(spec) = value.as_str() else {
167 tracing::warn!(key = %name, "ignoring a [keys] entry that is not a string");
168 continue;
169 };
170 // Rebinding moves the action, so its old chord has to go or the
171 // key stays consumed and the user cannot tell why.
172 bindings.map.retain(|_, bound| *bound != action);
173 if spec.eq_ignore_ascii_case("none") {
174 continue;
175 }
176 match parse_chord(spec) {
177 Some(chord) => {
178 bindings.map.insert(chord, action);
179 }
180 None => {
181 tracing::warn!(
182 key = %name, chord = %spec,
183 "ignoring an unparseable chord; the action is now unbound",
184 );
185 }
186 }
187 }
188 bindings
189 }
190
191 /// The action a key press triggers, if shop keeps this chord.
192 pub(crate) fn action(&self, mods: Mods, key: Keysym) -> Option<Action> {
193 self.map.get(&Chord::new(mods, key)).copied()
194 }
195 }
196
197 /// `ctrl+shift+page_up` into a chord.
198 ///
199 /// Modifiers in any order, the key last. Returns `None` for anything it cannot
200 /// read rather than guessing, because a chord that silently became a different
201 /// chord is worse than one that did not bind.
202 fn parse_chord(spec: &str) -> Option<Chord> {
203 let mut mods = Mods::default();
204 let mut parts = spec.split('+').map(str::trim).peekable();
205 let mut key = None;
206 while let Some(part) = parts.next() {
207 // The last segment is the key; everything before it is a modifier.
208 // Splitting this way means a literal `+` binds as the key it is.
209 if parts.peek().is_none() {
210 key = keysym_from_name(part);
211 break;
212 }
213 match part.to_ascii_lowercase().as_str() {
214 "ctrl" | "control" => mods.ctrl = true,
215 "shift" => mods.shift = true,
216 "alt" | "meta" => mods.alt = true,
217 "logo" | "super" | "cmd" => mods.logo = true,
218 _ => return None,
219 }
220 }
221 Some(Chord::new(mods, key?))
222 }
223
224 /// A key name into a keysym.
225 ///
226 /// One character is that character. Longer names come from the table below,
227 /// which is deliberately short: these are the keys worth taking from the
228 /// program inside shop, not the whole X keysym space.
229 fn keysym_from_name(name: &str) -> Option<Keysym> {
230 let mut chars = name.chars();
231 if let (Some(c), None) = (chars.next(), chars.next()) {
232 return Some(Keysym::from_char(c));
233 }
234 let named = match name.to_ascii_lowercase().as_str() {
235 "page_up" | "pageup" | "prior" => Keysym::Page_Up,
236 "page_down" | "pagedown" | "next" => Keysym::Page_Down,
237 "home" => Keysym::Home,
238 "end" => Keysym::End,
239 "insert" => Keysym::Insert,
240 "delete" | "del" => Keysym::Delete,
241 "up" => Keysym::Up,
242 "down" => Keysym::Down,
243 "left" => Keysym::Left,
244 "right" => Keysym::Right,
245 "tab" => Keysym::Tab,
246 "return" | "enter" => Keysym::Return,
247 "escape" | "esc" => Keysym::Escape,
248 "space" => Keysym::space,
249 "backspace" => Keysym::BackSpace,
250 "f1" => Keysym::F1,
251 "f2" => Keysym::F2,
252 "f3" => Keysym::F3,
253 "f4" => Keysym::F4,
254 "f5" => Keysym::F5,
255 "f6" => Keysym::F6,
256 "f7" => Keysym::F7,
257 "f8" => Keysym::F8,
258 "f9" => Keysym::F9,
259 "f10" => Keysym::F10,
260 "f11" => Keysym::F11,
261 "f12" => Keysym::F12,
262 _ => return None,
263 };
264 Some(named)
265 }
266
267 #[cfg(test)]
268 mod tests {
269 use super::*;
270
271 fn mods(spec: &str) -> Mods {
272 Mods {
273 shift: spec.contains('s'),
274 ctrl: spec.contains('c'),
275 alt: spec.contains('a'),
276 logo: spec.contains('l'),
277 }
278 }
279
280 fn table(src: &str) -> toml::Table {
281 src.parse().expect("test config parses")
282 }
283
284 #[test]
285 fn the_defaults_are_the_set_shop_shipped_hardcoded() {
286 let b = Bindings::default();
287 assert_eq!(
288 b.action(mods("s"), Keysym::Page_Up),
289 Some(Action::ScrollPageUp)
290 );
291 assert_eq!(
292 b.action(mods("s"), Keysym::Page_Down),
293 Some(Action::ScrollPageDown)
294 );
295 assert_eq!(b.action(mods("cs"), Keysym::c), Some(Action::Copy));
296 assert_eq!(b.action(mods("cs"), Keysym::v), Some(Action::Paste));
297 }
298
299 #[test]
300 fn shift_hands_over_the_capital_and_it_still_matches() {
301 // What the old `Keysym::c | Keysym::C` arm did by hand, now for every
302 // key rather than the two somebody remembered.
303 let b = Bindings::default();
304 assert_eq!(b.action(mods("cs"), Keysym::C), Some(Action::Copy));
305 assert_eq!(b.action(mods("cs"), Keysym::V), Some(Action::Paste));
306 }
307
308 #[test]
309 fn modifiers_match_exactly() {
310 let b = Bindings::default();
311 // Ctrl alone belongs to the program: Ctrl+C is the interrupt.
312 assert_eq!(b.action(mods("c"), Keysym::c), None);
313 assert_eq!(b.action(mods(""), Keysym::Page_Up), None);
314 // And a modifier the chord does not name is not "close enough".
315 assert_eq!(b.action(mods("sa"), Keysym::Page_Up), None);
316 assert_eq!(b.action(mods("csl"), Keysym::c), None);
317 }
318
319 #[test]
320 fn none_gives_the_key_back() {
321 // The load-bearing case: shop stops consuming it, so it reaches tmux.
322 let b = Bindings::from_table(&table(r#"copy = "none""#));
323 assert_eq!(b.action(mods("cs"), Keysym::c), None);
324 // and only that one
325 assert_eq!(b.action(mods("cs"), Keysym::v), Some(Action::Paste));
326 }
327
328 #[test]
329 fn none_is_accepted_in_any_case() {
330 let b = Bindings::from_table(&table(r#"copy = "None""#));
331 assert_eq!(b.action(mods("cs"), Keysym::c), None);
332 }
333
334 #[test]
335 fn rebinding_vacates_the_old_chord() {
336 // Otherwise the old key stays consumed and never reaches the shell,
337 // with nothing in the config explaining why.
338 let b = Bindings::from_table(&table(r#"copy = "ctrl+shift+y""#));
339 assert_eq!(b.action(mods("cs"), Keysym::y), Some(Action::Copy));
340 assert_eq!(b.action(mods("cs"), Keysym::c), None);
341 }
342
343 #[test]
344 fn a_chord_can_take_a_key_another_action_had() {
345 let b = Bindings::from_table(&table("copy = \"none\"\npaste = \"ctrl+shift+c\""));
346 assert_eq!(b.action(mods("cs"), Keysym::c), Some(Action::Paste));
347 assert_eq!(b.action(mods("cs"), Keysym::v), None);
348 }
349
350 #[test]
351 fn modifier_order_and_spelling_do_not_matter() {
352 for spec in [
353 "ctrl+shift+y",
354 "shift+ctrl+y",
355 "CTRL+SHIFT+y",
356 "control+shift+y",
357 " ctrl + shift + y ",
358 ] {
359 let b = Bindings::from_table(&table(&format!("copy = {spec:?}")));
360 assert_eq!(
361 b.action(mods("cs"), Keysym::y),
362 Some(Action::Copy),
363 "{spec}"
364 );
365 }
366 }
367
368 #[test]
369 fn named_keys_and_their_aliases_parse() {
370 for (spec, key) in [
371 ("shift+page_up", Keysym::Page_Up),
372 ("shift+pageup", Keysym::Page_Up),
373 ("shift+prior", Keysym::Page_Up),
374 ("shift+home", Keysym::Home),
375 ("shift+f5", Keysym::F5),
376 ("shift+space", Keysym::space),
377 ("shift+escape", Keysym::Escape),
378 ] {
379 let b = Bindings::from_table(&table(&format!("copy = {spec:?}")));
380 assert_eq!(b.action(mods("s"), key), Some(Action::Copy), "{spec}");
381 }
382 }
383
384 #[test]
385 fn a_bare_key_with_no_modifiers_is_legal() {
386 // Ill-advised, and not shop's business to forbid: the user asked.
387 let b = Bindings::from_table(&table(r#"copy = "f5""#));
388 assert_eq!(b.action(mods(""), Keysym::F5), Some(Action::Copy));
389 }
390
391 #[test]
392 fn an_unparseable_chord_unbinds_rather_than_keeping_the_default() {
393 // The user clearly meant to move it. Silently leaving the old chord
394 // live would consume a key they think they freed.
395 let b = Bindings::from_table(&table(r#"copy = "ctrl+shift+nosuchkey""#));
396 assert_eq!(b.action(mods("cs"), Keysym::c), None);
397 }
398
399 #[test]
400 fn an_unknown_modifier_does_not_silently_become_a_key() {
401 let b = Bindings::from_table(&table(r#"copy = "hyper+shift+c""#));
402 assert_eq!(b.action(mods("s"), Keysym::c), None);
403 assert_eq!(b.action(mods("cs"), Keysym::c), None);
404 }
405
406 #[test]
407 fn an_unknown_action_leaves_the_defaults_alone() {
408 let b = Bindings::from_table(&table(r#"scrollback_search = "ctrl+shift+f""#));
409 assert_eq!(b.action(mods("cs"), Keysym::c), Some(Action::Copy));
410 }
411
412 #[test]
413 fn a_non_string_entry_leaves_its_default_alone() {
414 let b = Bindings::from_table(&table("copy = 7"));
415 assert_eq!(b.action(mods("cs"), Keysym::c), Some(Action::Copy));
416 }
417
418 #[test]
419 fn every_action_round_trips_through_its_config_name() {
420 for action in Action::ALL {
421 assert_eq!(Action::from_name(action.name()), Some(action));
422 }
423 }
424
425 #[test]
426 fn every_action_is_bindable_by_name() {
427 // A default nobody can reach from the config is a default nobody can
428 // give back, which is the whole point of this module.
429 for action in Action::ALL {
430 let b = Bindings::from_table(&table(&format!("{} = \"none\"", action.name())));
431 assert!(
432 !b.map.values().any(|bound| *bound == action),
433 "{} could not be unbound",
434 action.name()
435 );
436 }
437 }
438 }
439