|
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 |
+ |
}
|