Skip to main content

qframe/keymap/
mod.rs

1//! Keymaps: named actions bound to key chords, overridable from files.
2//!
3//! ```toml
4//! [global]
5//! quit = "ctrl+q"
6//!
7//! [app]
8//! save = ["ctrl+s", "f2"]
9//! ```
10//!
11//! A chord is modifiers and a key joined with `+`. Modifier and key names are case-insensitive
12//! (`Ctrl+Enter` is `ctrl+enter`), but a letter's case counts: `"S"` means `shift+s`, the
13//! chord a terminal reports for a typed capital S, while `"s"` is the plain key.
14//!
15//! `[global]` holds framework actions; `[app]` holds the application's own. An application
16//! binding wins over a global one on the same chord. Hint bars take their labels from the
17//! locale key `quvyta.keys.<action>` for global actions and `keys.<action>` for app actions.
18
19mod chord;
20
21use std::collections::BTreeMap;
22use std::ops::Range;
23
24pub use chord::{Key, KeyChord, Modifiers};
25
26use crate::assets;
27use crate::diagnostics::Diagnostic;
28use crate::doc::{Doc, Value};
29
30/// Which table an action belongs to.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
32pub enum Scope {
33    /// Framework actions, in `[global]`.
34    Global,
35    /// Application actions, in `[app]`.
36    App,
37}
38
39impl Scope {
40    fn table(self) -> &'static str {
41        match self {
42            Self::Global => "global",
43            Self::App => "app",
44        }
45    }
46
47    /// The locale key holding the hint label of `action`.
48    #[must_use]
49    pub fn label_key(self, action: &str) -> String {
50        match self {
51            Self::Global => format!("quvyta.keys.{action}"),
52            Self::App => format!("keys.{action}"),
53        }
54    }
55}
56
57/// Actions and the chords that trigger them.
58#[derive(Debug, Clone, Default, PartialEq, Eq)]
59pub struct Keymap {
60    bindings: BTreeMap<(Scope, String), Vec<KeyChord>>,
61}
62
63impl Keymap {
64    /// The built-in keymap.
65    #[must_use]
66    pub fn builtin() -> Self {
67        let mut report = Vec::new();
68        let keymap = Self::parse("default.toml", assets::KEYMAP, &mut report);
69        debug_assert!(report.is_empty(), "built-in keymap must be valid: {report:?}");
70        keymap
71    }
72
73    /// Parses a keymap file. Broken entries are reported and skipped.
74    #[must_use]
75    pub fn parse(file: &str, text: &str, report: &mut Vec<Diagnostic>) -> Self {
76        let doc = Doc::new(file, text);
77        let mut keymap = Self::default();
78        let root = match doc.parse() {
79            Ok(root) => root,
80            Err(diagnostic) => {
81                report.push(diagnostic);
82                return keymap;
83            }
84        };
85        for (section, value) in &root {
86            let scope = match section.get_ref().as_ref() {
87                "global" => Scope::Global,
88                "app" => Scope::App,
89                other => {
90                    report.push(doc.error(&value.span(), format!("unknown section `{other}`; use [global] or [app]")));
91                    continue;
92                }
93            };
94            let table = match doc.table(value, scope.table()) {
95                Ok(table) => table,
96                Err(diagnostic) => {
97                    report.push(diagnostic);
98                    continue;
99                }
100            };
101            for (action, binding) in table {
102                let action = action.get_ref().to_string();
103                if let Some(chords) = parse_binding(&doc, scope, &action, binding, report) {
104                    keymap.bindings.insert((scope, action), chords);
105                }
106            }
107        }
108        keymap
109    }
110
111    /// Replaces every action that `other` defines. An action bound to an empty list in
112    /// `other` becomes unbound.
113    pub fn overlay(&mut self, other: &Self) {
114        for (key, chords) in &other.bindings {
115            self.bindings.insert(key.clone(), chords.clone());
116        }
117    }
118
119    /// Binds `action` in code, replacing any earlier binding.
120    pub fn bind(&mut self, scope: Scope, action: &str, chords: &[KeyChord]) {
121        self.bindings.insert((scope, action.to_owned()), chords.to_vec());
122    }
123
124    /// The action `chord` triggers; application actions win over global ones.
125    #[must_use]
126    pub fn action_for(&self, chord: KeyChord) -> Option<(Scope, &str)> {
127        [Scope::App, Scope::Global].into_iter().find_map(|scope| {
128            self.bindings
129                .iter()
130                .find(|((s, _), chords)| *s == scope && chords.contains(&chord))
131                .map(|((s, action), _)| (*s, action.as_str()))
132        })
133    }
134
135    /// The chords bound to `action`.
136    #[must_use]
137    pub fn chords_for(&self, scope: Scope, action: &str) -> &[KeyChord] {
138        self.bindings.get(&(scope, action.to_owned())).map_or(&[], Vec::as_slice)
139    }
140
141    /// Every binding, sorted by scope and action.
142    pub fn iter(&self) -> impl Iterator<Item = (Scope, &str, &[KeyChord])> {
143        self.bindings.iter().map(|((scope, action), chords)| (*scope, action.as_str(), chords.as_slice()))
144    }
145
146    /// Warnings for chords bound to more than one action within the same scope.
147    #[must_use]
148    pub fn conflicts(&self) -> Vec<Diagnostic> {
149        let mut owners: BTreeMap<(Scope, KeyChord), Vec<&str>> = BTreeMap::new();
150        for ((scope, action), chords) in &self.bindings {
151            for chord in chords {
152                owners.entry((*scope, *chord)).or_default().push(action);
153            }
154        }
155        owners
156            .into_iter()
157            .filter(|(_, actions)| actions.len() > 1)
158            .map(|((scope, chord), actions)| {
159                Diagnostic::warning(
160                    None,
161                    format!("`{chord}` is bound to several [{}] actions: {}", scope.table(), actions.join(", ")),
162                )
163            })
164            .collect()
165    }
166}
167
168/// The chords of `binding`, one key or a list of keys, for `action`. Broken keys are reported and
169/// left out; a binding of another type is reported and gives `None`.
170fn parse_binding(
171    doc: &Doc<'_>,
172    scope: Scope,
173    action: &str,
174    binding: &Value<'_>,
175    report: &mut Vec<Diagnostic>,
176) -> Option<Vec<KeyChord>> {
177    let texts: Vec<(&str, Range<usize>)> = if let Some(text) = binding.get_ref().as_str() {
178        vec![(text, binding.span())]
179    } else if let Some(items) = binding.get_ref().as_array() {
180        items
181            .iter()
182            .filter_map(|item| match doc.string(item, &format!("{}.{action}", scope.table())) {
183                Ok(text) => Some((text, item.span())),
184                Err(diagnostic) => {
185                    report.push(diagnostic);
186                    None
187                }
188            })
189            .collect()
190    } else {
191        report.push(doc.error(&binding.span(), format!("`{action}` must be a key like \"ctrl+s\" or a list of keys")));
192        return None;
193    };
194    let mut chords = Vec::new();
195    for (text, span) in texts {
196        match text.parse::<KeyChord>() {
197            Ok(chord) => chords.push(chord),
198            Err(message) => report.push(doc.error(&span, message)),
199        }
200    }
201    Some(chords)
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    fn chord(text: &str) -> KeyChord {
209        text.parse().expect("valid chord")
210    }
211
212    #[test]
213    fn builtin_binds_quit() {
214        let keymap = Keymap::builtin();
215        assert_eq!(keymap.action_for(chord("ctrl+q")), Some((Scope::Global, "quit")));
216        assert_eq!(keymap.chords_for(Scope::Global, "debug"), &[chord("f12")]);
217    }
218
219    #[test]
220    fn builtin_chords_keep_their_meaning_with_uppercase_letters_as_shift() {
221        let keymap = Keymap::builtin();
222        let shifted: Vec<String> = keymap
223            .iter()
224            .flat_map(|(_, action, chords)| {
225                chords.iter().filter(|c| c.mods.shift).map(move |c| format!("{action} {c}"))
226            })
227            .collect();
228        assert_eq!(shifted, ["focus-prev shift+tab"]);
229        assert_eq!(keymap.action_for(chord("?")), Some((Scope::Global, "help")));
230        let mut report = Vec::new();
231        let user = Keymap::parse("user.toml", "[app]\nsave = \"S\"\nsearch = \"s\"\n", &mut report);
232        assert!(report.is_empty(), "{report:?}");
233        assert_eq!(user.action_for(chord("shift+s")), Some((Scope::App, "save")));
234        assert_eq!(user.action_for(chord("s")), Some((Scope::App, "search")));
235    }
236
237    #[test]
238    fn parses_lists_and_reports_bad_entries() {
239        let mut report = Vec::new();
240        let keymap = Keymap::parse(
241            "app.toml",
242            "[app]\nsave = [\"ctrl+s\", \"f2\"]\nbroken = \"ctrl+banana\"\nweird = 5\n[extra]\n",
243            &mut report,
244        );
245        assert_eq!(keymap.chords_for(Scope::App, "save"), &[chord("ctrl+s"), chord("f2")]);
246        assert_eq!(report.len(), 3, "{report:?}");
247        assert_eq!(report[0].location.as_ref().map(|l| l.line), Some(3));
248    }
249
250    #[test]
251    fn a_list_keeps_its_good_keys() {
252        let mut report = Vec::new();
253        let keymap = Keymap::parse("app.toml", "[app]\nsave = [\"ctrl+s\", 3, \"ctrl+banana\"]\n", &mut report);
254        assert_eq!(keymap.chords_for(Scope::App, "save"), &[chord("ctrl+s")]);
255        let messages: Vec<&str> = report.iter().map(|d| d.message.as_str()).collect();
256        assert_eq!(messages.len(), 2, "{messages:?}");
257        assert_eq!(messages[0], "app.save must be a string, found integer");
258    }
259
260    #[test]
261    fn app_bindings_win_and_overlay_replaces_actions() {
262        let mut keymap = Keymap::builtin();
263        let mut report = Vec::new();
264        let user = Keymap::parse("user.toml", "[global]\nquit = \"ctrl+w\"\n[app]\nclose = \"ctrl+q\"\n", &mut report);
265        keymap.overlay(&user);
266        assert_eq!(keymap.action_for(chord("ctrl+q")), Some((Scope::App, "close")));
267        assert_eq!(keymap.action_for(chord("ctrl+w")), Some((Scope::Global, "quit")));
268        assert_eq!(Scope::App.label_key("close"), "keys.close");
269        assert_eq!(Scope::Global.label_key("quit"), "quvyta.keys.quit");
270    }
271
272    #[test]
273    fn reports_conflicts_within_a_scope() {
274        let mut keymap = Keymap::default();
275        keymap.bind(Scope::App, "save", &[chord("ctrl+s")]);
276        keymap.bind(Scope::App, "search", &[chord("ctrl+s")]);
277        keymap.bind(Scope::Global, "other", &[chord("ctrl+s")]);
278        let conflicts = keymap.conflicts();
279        assert_eq!(conflicts.len(), 1);
280        assert!(conflicts[0].message.contains("save, search"));
281    }
282}