Skip to main content

max / shop

User-configurable keybindings, with none as a legal value Shop sees a key before the program inside it does, so every binding it keeps is a key the shell never receives. That was fine while the set was four conventional chords hardcoded in main.rs. It stops being fine the moment shop wants a chord of its own, because the next one it takes might be somebody's tmux prefix, and there was no way to give it back. So `none` is the point of this table, not a convenience. A config that can only rebind cannot surrender a key, and every default here has to be surrenderable. The four hardcoded bindings become defaults in that same table rather than staying separate, or there would be two mechanisms and one of them invisible. Chord matching is now exact on modifiers. The old test was `shift && !ctrl`, which quietly said yes to alt+shift+PageUp; exactness is what lets two chords share a key with different modifiers and neither shadow the other. Case folding replaces the hand-written `Keysym::c | Keysym::C` arm. Shift makes xkb hand over the capital, so both sides fold at construction — for every key now, not the two somebody remembered to special-case. An unreadable entry unbinds its action rather than silently keeping the old chord: the user meant to move it, and a key that stays consumed with nothing in the config explaining why is the exact failure this task exists to prevent. A malformed file at large still falls back whole, because this is the terminal somebody opens to fix their config. Scope held to a table of chords. The actions are a closed enum; there are no user-defined actions and no scripting, and adding one is a code change on purpose. shop-xkb's Mods gains Hash so it can key the table. Twenty tests, README documents the syntax and the named keys. Unblocks shop e356d861 (emit a region to a runner), which needs a key to trigger on and must not take one by force.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-03 23:58 UTC
Signed with PGP, not checked
Commit: 30f9aa3e42652dfc5266caff46d696a4bbb3cbf6
Parent: 3007de3
5 files changed, +490 insertions, -44 deletions
M README.md +24 -2
@@ -37,7 +37,7 @@
37 37
38 38 ## Config
39 39
40 - `~/.config/shop/config.toml`, three keys, all optional:
40 + `~/.config/shop/config.toml`, all keys optional:
41 41
42 42 ```toml
43 43 # A theme id, without the .toml. Defaults to akari-night, which shop carries
@@ -53,8 +53,28 @@
53 53 # bytes, so the default is about 24 MB per window at 200 columns. 0 turns
54 54 # scrollback off.
55 55 scrollback_lines = 10000
56 +
57 + # Which chords shop keeps for itself. Shop sees a key before the program
58 + # inside it does, so anything bound here never reaches the shell -- or tmux.
59 + # Every default can be moved, and `none` gives the key back entirely.
60 + [keys]
61 + scroll_page_up = "shift+page_up"
62 + scroll_page_down = "shift+page_down"
63 + copy = "ctrl+shift+c"
64 + paste = "none" # shop stops taking it; tmux sees it again
56 65 ```
57 66
67 + Modifiers are `ctrl`, `shift`, `alt` and `logo`, in any order, with the key
68 + last: `ctrl+shift+y`. A single character is that character; longer names cover
69 + the keys worth taking from a program (`page_up`, `home`, `end`, `insert`,
70 + `delete`, the arrows, `tab`, `return`, `escape`, `space`, `backspace`, `f1`
71 + through `f12`). Modifiers match exactly, so `shift+page_up` does not fire while
72 + alt is also down.
73 +
74 + An entry shop cannot read leaves that action unbound rather than keeping the
75 + old chord: you meant to move it, and a key that stays consumed with nothing in
76 + the config explaining why is worse than one that does nothing.
77 +
58 78 Themes are [makeover](https://makenot.work/git/max/makeover) TOML files, which
59 79 declare colours by role rather than by hue. shop resolves the sixteen ANSI
60 80 slots from them through `makeover::ansi_intent`, the same mapping the bare
@@ -67,7 +87,9 @@
67 87 ## Scrollback
68 88
69 89 The wheel scrolls three rows a notch; Shift+Page Up and Shift+Page Down move a
70 - screen at a time, less a row of overlap. Typing snaps back to the bottom.
90 + screen at a time, less a row of overlap. Typing snaps back to the bottom. Both
91 + page chords are `[keys]` entries like any other, so they can be moved or given
92 + up.
71 93
72 94 The alt screen has none, by definition: a program that takes the whole window
73 95 repaints it, so a row leaving the top is overdraw rather than history. A wheel
@@ -28,7 +28,7 @@
28 28 ///
29 29 /// Named for what they do rather than for the keycaps: `logo` is what
30 30 /// Wayland calls the Windows/Command key, and terminals call meta.
31 - #[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
31 + #[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)]
32 32 pub struct Mods {
33 33 pub shift: bool,
34 34 pub alt: bool,
@@ -10,8 +10,10 @@
10 10 use std::sync::Arc;
11 11
12 12 mod clipboard;
13 + mod keys;
13 14 mod theme;
14 15 use clipboard::{MIME_UTF8, OFFERED_MIMES, sanitize_paste};
16 + use keys::Action;
15 17 use theme::{Config, Palette};
16 18
17 19 use calloop::generic::{FdWrapper, Generic};
@@ -145,6 +147,7 @@
145 147 .and_then(|i| args.get(i + 1).cloned());
146 148 let config = Config::load().with_theme(theme_arg);
147 149 let scrollback_lines = config.scrollback_lines;
150 + let bindings = config.bindings.clone();
148 151 let palette = Palette::load(&config);
149 152 let (spawn_cmd, spawn_args) =
150 153 spawn_target(exec_argv, exec_cmd.clone(), std::env::var("SHELL").ok());
@@ -288,6 +291,7 @@
288 291 pointer: None,
289 292 shm,
290 293 modifiers: Modifiers::default(),
294 + bindings,
291 295 pointer_at: (0.0, 0.0),
292 296 selection: None,
293 297 dragging: false,
@@ -728,6 +732,9 @@
728 732 /// costs the fallback and nothing else.
729 733 shm: Option<Shm>,
730 734 modifiers: Modifiers,
735 + /// Which chords shop consumes instead of forwarding, defaults already
736 + /// merged with the user's `[keys]` table.
737 + bindings: crate::keys::Bindings,
731 738 /// Pointer position in surface-local logical pixels, from the last event
732 739 /// that carried one. Every event does, so this is always current.
733 740 pointer_at: (f64, f64),
@@ -1721,52 +1728,38 @@
1721 1728
1722 1729 /// Shop's own key bindings, which never reach the shell.
1723 1730 ///
1724 - /// Ctrl+Shift is the terminal's half of the keyboard by long convention,
1725 - /// precisely because Ctrl alone belongs to the program: Ctrl+C has to stay
1726 - /// the interrupt, so copy takes the shifted form.
1731 + /// Which chord runs which action is the user's, not shop's: a key shop
1732 + /// consumes never reaches the program inside it, so every one of these is
1733 + /// rebindable and every one can be given back with `none`. See
1734 + /// [`crate::keys`].
1727 1735 ///
1728 1736 /// Returns true when the key was consumed.
1729 1737 fn handle_binding(&mut self, event: &KeyEvent) -> bool {
1730 - // Shift+Page is the conventional scrollback binding, and shop consumes
1731 - // it rather than forwarding it: the shell has no use for it, and a
1732 - // terminal that passed it through would have no way to reach its own
1733 - // history. A page is one screen less a row, so a line of context
1734 - // carries over and the reader can stitch the two screens together.
1735 - if self.modifiers.shift && !self.modifiers.ctrl {
1736 - let page = self.grid.rows().saturating_sub(1).max(1);
1737 - match event.keysym {
1738 - Keysym::Page_Up => {
1739 - self.scroll_view(|g| g.scroll_view_up(page));
1740 - return true;
1741 - }
1742 - Keysym::Page_Down => {
1743 - self.scroll_view(|g| g.scroll_view_down(page));
1744 - return true;
1745 - }
1746 - _ => {}
1747 - }
1748 - }
1749 - if !(self.modifiers.ctrl && self.modifiers.shift) {
1738 + let Some(action) = self.bindings.action(xkb_mods(self.modifiers), event.keysym) else {
1750 1739 return false;
1751 - }
1752 - match event.keysym {
1753 - // Shift makes xkb hand us the capital, but a layout or a stuck
1754 - // lock can produce either, so accept both.
1755 - Keysym::c | Keysym::C => {
1740 + };
1741 + match action {
1742 + // A page is one screen less a row, so a line of context carries
1743 + // over and the reader can stitch the two screens together.
1744 + Action::ScrollPageUp => {
1745 + let page = self.grid.rows().saturating_sub(1).max(1);
1746 + self.scroll_view(|g| g.scroll_view_up(page));
1747 + }
1748 + Action::ScrollPageDown => {
1749 + let page = self.grid.rows().saturating_sub(1).max(1);
1750 + self.scroll_view(|g| g.scroll_view_down(page));
1751 + }
1752 + Action::Copy => {
1756 1753 if let Some(text) = self.selection_text() {
1757 1754 self.set_clipboard(text);
1758 1755 }
1759 - // Consumed either way: a binding that falls through to the
1760 - // shell when there is nothing selected would send an interrupt
1761 - // to whatever is running, which is not what the user asked for.
1762 - true
1756 + // Consumed even with nothing selected: falling through to the
1757 + // shell would send an interrupt to whatever is running, which
1758 + // is not what the user asked for.
1763 1759 }
1764 - Keysym::v | Keysym::V => {
1765 - self.paste_clipboard();
1766 - true
1767 - }
1768 - _ => false,
1760 + Action::Paste => self.paste_clipboard(),
1769 1761 }
1762 + true
1770 1763 }
1771 1764
1772 1765 /// Run a viewport move and ask for a frame if it went anywhere.
@@ -193,6 +193,12 @@
193 193 /// so ten thousand rows is about 24 MB at 200 columns. Zero turns
194 194 /// scrollback off.
195 195 pub scrollback_lines: Option<usize>,
196 + /// Which chords shop keeps for itself, defaults already applied.
197 + ///
198 + /// Not an `Option`: an absent `[keys]` table means the defaults, and a
199 + /// present one is an edit on top of them, so there is never a state where
200 + /// the binding set is unknown. See [`crate::keys`].
201 + pub bindings: crate::keys::Bindings,
196 202 }
197 203
198 204 impl Config {
@@ -239,6 +245,13 @@
239 245 .get("scrollback_lines")
240 246 .and_then(toml::Value::as_integer)
241 247 .and_then(|n| usize::try_from(n).ok()),
248 + bindings: table
249 + .get("keys")
250 + .and_then(toml::Value::as_table)
251 + .map_or_else(
252 + crate::keys::Bindings::default,
253 + crate::keys::Bindings::from_table,
254 + ),
242 255 }
243 256 }
244 257
@@ -328,8 +341,7 @@
328 341 fn an_unknown_theme_falls_back_whole() {
329 342 let config = Config {
330 343 theme: Some("no-such-theme".into()),
331 - themes: None,
332 - scrollback_lines: None,
344 + ..Config::default()
333 345 };
334 346 let palette = Palette::load(&config);
335 347 let want = embedded(DEFAULT_THEME).unwrap();
@@ -357,8 +369,7 @@
357 369 assert_eq!(config.theme.as_deref(), Some("akari-dawn"));
358 370 let unchanged = Config {
359 371 theme: Some("akari-night".into()),
360 - themes: None,
361 - scrollback_lines: None,
372 + ..Config::default()
362 373 }
363 374 .with_theme(None);
364 375 assert_eq!(unchanged.theme.as_deref(), Some("akari-night"));
@@ -1,0 +1,420 @@
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 + }
42 +
43 + impl Action {
44 + /// Every action, so the config parser can reject an unknown key by name
45 + /// rather than ignoring it.
46 + const ALL: [Self; 4] = [
47 + Self::ScrollPageUp,
48 + Self::ScrollPageDown,
49 + Self::Copy,
50 + Self::Paste,
51 + ];
52 +
53 + pub(crate) fn name(self) -> &'static str {
54 + match self {
55 + Self::ScrollPageUp => "scroll_page_up",
56 + Self::ScrollPageDown => "scroll_page_down",
57 + Self::Copy => "copy",
58 + Self::Paste => "paste",
59 + }
60 + }
61 +
62 + fn from_name(name: &str) -> Option<Self> {
63 + Self::ALL.into_iter().find(|a| a.name() == name)
64 + }
65 + }
66 +
67 + /// A key plus the exact modifier state it fires under.
68 + ///
69 + /// Exact, not "at least": a chord of `shift+page_up` does not fire while alt is
70 + /// also held. The old hardcoded test was `shift && !ctrl`, which quietly said
71 + /// yes to alt+shift+PageUp. Exactness is what makes a binding table
72 + /// predictable — a user who binds two chords on the same key with different
73 + /// modifiers gets both, and neither shadows the other.
74 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
75 + pub(crate) struct Chord {
76 + mods: Mods,
77 + key: Keysym,
78 + }
79 +
80 + impl Chord {
81 + /// A chord, with the key folded to its lowercase form.
82 + ///
83 + /// Shift makes xkb hand over the capital, so `ctrl+shift+c` arrives as `C`
84 + /// while the config says `c`. Folding both sides at construction is what
85 + /// the old `Keysym::c | Keysym::C` arm did by hand for two keys; doing it
86 + /// here does it for every key, including the ones a layout or a stuck lock
87 + /// makes unpredictable.
88 + fn new(mods: Mods, key: Keysym) -> Self {
89 + let key = key
90 + .key_char()
91 + .filter(|c| c.is_uppercase())
92 + .and_then(|c| c.to_lowercase().next())
93 + .map_or(key, Keysym::from_char);
94 + Self { mods, key }
95 + }
96 + }
97 +
98 + /// The chord table shop matches every key press against.
99 + #[derive(Debug, Clone)]
100 + pub(crate) struct Bindings {
101 + map: HashMap<Chord, Action>,
102 + }
103 +
104 + impl Default for Bindings {
105 + /// The set shop shipped hardcoded, now expressed as defaults so there is
106 + /// one mechanism rather than two.
107 + ///
108 + /// Ctrl+Shift is the terminal's half of the keyboard by long convention,
109 + /// precisely because Ctrl alone belongs to the program: Ctrl+C has to stay
110 + /// the interrupt, so copy takes the shifted form. Shift+Page is the
111 + /// conventional scrollback binding, and the shell has no use for it.
112 + fn default() -> Self {
113 + let shift = Mods {
114 + shift: true,
115 + ..Mods::default()
116 + };
117 + let ctrl_shift = Mods {
118 + shift: true,
119 + ctrl: true,
120 + ..Mods::default()
121 + };
122 + let map = [
123 + (Chord::new(shift, Keysym::Page_Up), Action::ScrollPageUp),
124 + (Chord::new(shift, Keysym::Page_Down), Action::ScrollPageDown),
125 + (Chord::new(ctrl_shift, Keysym::c), Action::Copy),
126 + (Chord::new(ctrl_shift, Keysym::v), Action::Paste),
127 + ];
128 + Self {
129 + map: map.into_iter().collect(),
130 + }
131 + }
132 + }
133 +
134 + impl Bindings {
135 + /// Apply a `[keys]` table over the defaults.
136 + ///
137 + /// Every entry names an action and gives it a chord, or `none` to leave the
138 + /// action unbound. An unreadable entry is reported and skipped, keeping the
139 + /// default: this is the terminal somebody opens to fix their config, so it
140 + /// has to come up.
141 + pub(crate) fn from_table(table: &toml::Table) -> Self {
142 + let mut bindings = Self::default();
143 + for (name, value) in table {
144 + let Some(action) = Action::from_name(name) else {
145 + tracing::warn!(key = %name, "ignoring an unknown [keys] action");
146 + continue;
147 + };
148 + let Some(spec) = value.as_str() else {
149 + tracing::warn!(key = %name, "ignoring a [keys] entry that is not a string");
150 + continue;
151 + };
152 + // Rebinding moves the action, so its old chord has to go or the
153 + // key stays consumed and the user cannot tell why.
154 + bindings.map.retain(|_, bound| *bound != action);
155 + if spec.eq_ignore_ascii_case("none") {
156 + continue;
157 + }
158 + match parse_chord(spec) {
159 + Some(chord) => {
160 + bindings.map.insert(chord, action);
161 + }
162 + None => {
163 + tracing::warn!(
164 + key = %name, chord = %spec,
165 + "ignoring an unparseable chord; the action is now unbound",
166 + );
167 + }
168 + }
169 + }
170 + bindings
171 + }
172 +
173 + /// The action a key press triggers, if shop keeps this chord.
174 + pub(crate) fn action(&self, mods: Mods, key: Keysym) -> Option<Action> {
175 + self.map.get(&Chord::new(mods, key)).copied()
176 + }
177 + }
178 +
179 + /// `ctrl+shift+page_up` into a chord.
180 + ///
181 + /// Modifiers in any order, the key last. Returns `None` for anything it cannot
182 + /// read rather than guessing, because a chord that silently became a different
183 + /// chord is worse than one that did not bind.
184 + fn parse_chord(spec: &str) -> Option<Chord> {
185 + let mut mods = Mods::default();
186 + let mut parts = spec.split('+').map(str::trim).peekable();
187 + let mut key = None;
188 + while let Some(part) = parts.next() {
189 + // The last segment is the key; everything before it is a modifier.
190 + // Splitting this way means a literal `+` binds as the key it is.
191 + if parts.peek().is_none() {
192 + key = keysym_from_name(part);
193 + break;
194 + }
195 + match part.to_ascii_lowercase().as_str() {
196 + "ctrl" | "control" => mods.ctrl = true,
197 + "shift" => mods.shift = true,
198 + "alt" | "meta" => mods.alt = true,
199 + "logo" | "super" | "cmd" => mods.logo = true,
200 + _ => return None,
201 + }
202 + }
203 + Some(Chord::new(mods, key?))
204 + }
205 +
206 + /// A key name into a keysym.
207 + ///
208 + /// One character is that character. Longer names come from the table below,
209 + /// which is deliberately short: these are the keys worth taking from the
210 + /// program inside shop, not the whole X keysym space.
211 + fn keysym_from_name(name: &str) -> Option<Keysym> {
212 + let mut chars = name.chars();
213 + if let (Some(c), None) = (chars.next(), chars.next()) {
214 + return Some(Keysym::from_char(c));
215 + }
216 + let named = match name.to_ascii_lowercase().as_str() {
217 + "page_up" | "pageup" | "prior" => Keysym::Page_Up,
218 + "page_down" | "pagedown" | "next" => Keysym::Page_Down,
219 + "home" => Keysym::Home,
220 + "end" => Keysym::End,
221 + "insert" => Keysym::Insert,
222 + "delete" | "del" => Keysym::Delete,
223 + "up" => Keysym::Up,
224 + "down" => Keysym::Down,
225 + "left" => Keysym::Left,
226 + "right" => Keysym::Right,
227 + "tab" => Keysym::Tab,
228 + "return" | "enter" => Keysym::Return,
229 + "escape" | "esc" => Keysym::Escape,
230 + "space" => Keysym::space,
231 + "backspace" => Keysym::BackSpace,
232 + "f1" => Keysym::F1,
233 + "f2" => Keysym::F2,
234 + "f3" => Keysym::F3,
235 + "f4" => Keysym::F4,
236 + "f5" => Keysym::F5,
237 + "f6" => Keysym::F6,
238 + "f7" => Keysym::F7,
239 + "f8" => Keysym::F8,
240 + "f9" => Keysym::F9,
241 + "f10" => Keysym::F10,
242 + "f11" => Keysym::F11,
243 + "f12" => Keysym::F12,
244 + _ => return None,
245 + };
246 + Some(named)
247 + }
248 +
249 + #[cfg(test)]
250 + mod tests {
251 + use super::*;
252 +
253 + fn mods(spec: &str) -> Mods {
254 + Mods {
255 + shift: spec.contains('s'),
256 + ctrl: spec.contains('c'),
257 + alt: spec.contains('a'),
258 + logo: spec.contains('l'),
259 + }
260 + }
261 +
262 + fn table(src: &str) -> toml::Table {
263 + src.parse().expect("test config parses")
264 + }
265 +
266 + #[test]
267 + fn the_defaults_are_the_set_shop_shipped_hardcoded() {
268 + let b = Bindings::default();
269 + assert_eq!(
270 + b.action(mods("s"), Keysym::Page_Up),
271 + Some(Action::ScrollPageUp)
272 + );
273 + assert_eq!(
274 + b.action(mods("s"), Keysym::Page_Down),
275 + Some(Action::ScrollPageDown)
276 + );
277 + assert_eq!(b.action(mods("cs"), Keysym::c), Some(Action::Copy));
278 + assert_eq!(b.action(mods("cs"), Keysym::v), Some(Action::Paste));
279 + }
280 +
281 + #[test]
282 + fn shift_hands_over_the_capital_and_it_still_matches() {
283 + // What the old `Keysym::c | Keysym::C` arm did by hand, now for every
284 + // key rather than the two somebody remembered.
285 + let b = Bindings::default();
286 + assert_eq!(b.action(mods("cs"), Keysym::C), Some(Action::Copy));
287 + assert_eq!(b.action(mods("cs"), Keysym::V), Some(Action::Paste));
288 + }
289 +
290 + #[test]
291 + fn modifiers_match_exactly() {
292 + let b = Bindings::default();
293 + // Ctrl alone belongs to the program: Ctrl+C is the interrupt.
294 + assert_eq!(b.action(mods("c"), Keysym::c), None);
295 + assert_eq!(b.action(mods(""), Keysym::Page_Up), None);
296 + // And a modifier the chord does not name is not "close enough".
297 + assert_eq!(b.action(mods("sa"), Keysym::Page_Up), None);
298 + assert_eq!(b.action(mods("csl"), Keysym::c), None);
299 + }
300 +
301 + #[test]
302 + fn none_gives_the_key_back() {
303 + // The load-bearing case: shop stops consuming it, so it reaches tmux.
304 + let b = Bindings::from_table(&table(r#"copy = "none""#));
305 + assert_eq!(b.action(mods("cs"), Keysym::c), None);
306 + // and only that one
307 + assert_eq!(b.action(mods("cs"), Keysym::v), Some(Action::Paste));
308 + }
309 +
310 + #[test]
311 + fn none_is_accepted_in_any_case() {
312 + let b = Bindings::from_table(&table(r#"copy = "None""#));
313 + assert_eq!(b.action(mods("cs"), Keysym::c), None);
314 + }
315 +
316 + #[test]
317 + fn rebinding_vacates_the_old_chord() {
318 + // Otherwise the old key stays consumed and never reaches the shell,
319 + // with nothing in the config explaining why.
320 + let b = Bindings::from_table(&table(r#"copy = "ctrl+shift+y""#));
321 + assert_eq!(b.action(mods("cs"), Keysym::y), Some(Action::Copy));
322 + assert_eq!(b.action(mods("cs"), Keysym::c), None);
323 + }
324 +
325 + #[test]
326 + fn a_chord_can_take_a_key_another_action_had() {
327 + let b = Bindings::from_table(&table("copy = \"none\"\npaste = \"ctrl+shift+c\""));
328 + assert_eq!(b.action(mods("cs"), Keysym::c), Some(Action::Paste));
329 + assert_eq!(b.action(mods("cs"), Keysym::v), None);
330 + }
331 +
332 + #[test]
333 + fn modifier_order_and_spelling_do_not_matter() {
334 + for spec in [
335 + "ctrl+shift+y",
336 + "shift+ctrl+y",
337 + "CTRL+SHIFT+y",
338 + "control+shift+y",
339 + " ctrl + shift + y ",
340 + ] {
341 + let b = Bindings::from_table(&table(&format!("copy = {spec:?}")));
342 + assert_eq!(
343 + b.action(mods("cs"), Keysym::y),
344 + Some(Action::Copy),
345 + "{spec}"
346 + );
347 + }
348 + }
349 +
350 + #[test]
351 + fn named_keys_and_their_aliases_parse() {
352 + for (spec, key) in [
353 + ("shift+page_up", Keysym::Page_Up),
354 + ("shift+pageup", Keysym::Page_Up),
355 + ("shift+prior", Keysym::Page_Up),
356 + ("shift+home", Keysym::Home),
357 + ("shift+f5", Keysym::F5),
358 + ("shift+space", Keysym::space),
359 + ("shift+escape", Keysym::Escape),
360 + ] {
361 + let b = Bindings::from_table(&table(&format!("copy = {spec:?}")));
362 + assert_eq!(b.action(mods("s"), key), Some(Action::Copy), "{spec}");
363 + }
364 + }
365 +
366 + #[test]
367 + fn a_bare_key_with_no_modifiers_is_legal() {
368 + // Ill-advised, and not shop's business to forbid: the user asked.
369 + let b = Bindings::from_table(&table(r#"copy = "f5""#));
370 + assert_eq!(b.action(mods(""), Keysym::F5), Some(Action::Copy));
371 + }
372 +
373 + #[test]
374 + fn an_unparseable_chord_unbinds_rather_than_keeping_the_default() {
375 + // The user clearly meant to move it. Silently leaving the old chord
376 + // live would consume a key they think they freed.
377 + let b = Bindings::from_table(&table(r#"copy = "ctrl+shift+nosuchkey""#));
378 + assert_eq!(b.action(mods("cs"), Keysym::c), None);
379 + }
380 +
381 + #[test]
382 + fn an_unknown_modifier_does_not_silently_become_a_key() {
383 + let b = Bindings::from_table(&table(r#"copy = "hyper+shift+c""#));
384 + assert_eq!(b.action(mods("s"), Keysym::c), None);
385 + assert_eq!(b.action(mods("cs"), Keysym::c), None);
386 + }
387 +
388 + #[test]
389 + fn an_unknown_action_leaves_the_defaults_alone() {
390 + let b = Bindings::from_table(&table(r#"scrollback_search = "ctrl+shift+f""#));
391 + assert_eq!(b.action(mods("cs"), Keysym::c), Some(Action::Copy));
392 + }
393 +
394 + #[test]
395 + fn a_non_string_entry_leaves_its_default_alone() {
396 + let b = Bindings::from_table(&table("copy = 7"));
397 + assert_eq!(b.action(mods("cs"), Keysym::c), Some(Action::Copy));
398 + }
399 +
400 + #[test]
401 + fn every_action_round_trips_through_its_config_name() {
402 + for action in Action::ALL {
403 + assert_eq!(Action::from_name(action.name()), Some(action));
404 + }
405 + }
406 +
407 + #[test]
408 + fn every_action_is_bindable_by_name() {
409 + // A default nobody can reach from the config is a default nobody can
410 + // give back, which is the whole point of this module.
411 + for action in Action::ALL {
412 + let b = Bindings::from_table(&table(&format!("{} = \"none\"", action.name())));
413 + assert!(
414 + !b.map.values().any(|bound| *bound == action),
415 + "{} could not be unbound",
416 + action.name()
417 + );
418 + }
419 + }
420 + }