Skip to main content

taino_edit_core/
keymap.rs

1//! [`Keymap`] — bind canonical key strings to [`Command`]s, with
2//! cross-platform `Mod` handling (Ctrl on Windows/Linux, Cmd/Meta on macOS).
3//!
4//! `core` is headless, so a [`KeyPress`] is a platform-neutral description of
5//! a key event; framework adapters translate their native events into it.
6
7use std::collections::HashMap;
8
9use crate::commands::{
10    caret_left, caret_line_end, caret_line_start, caret_right, chain, delete_backward,
11    delete_forward, delete_selection, join_backward, join_forward, select_all, split_block,
12    Command, Dispatch,
13};
14use crate::state::EditorState;
15
16/// A platform-neutral key event.
17#[derive(Debug, Clone)]
18pub struct KeyPress {
19    /// The key name: a single character (`"b"`) or a named key
20    /// (`"Enter"`, `"Backspace"`, `"ArrowLeft"`, `"Home"`).
21    pub key: String,
22    /// Control held.
23    pub ctrl: bool,
24    /// Alt/Option held.
25    pub alt: bool,
26    /// Shift held.
27    pub shift: bool,
28    /// Meta/Cmd held.
29    pub meta: bool,
30}
31
32impl KeyPress {
33    /// A bare key with no modifiers.
34    pub fn key(name: &str) -> Self {
35        KeyPress {
36            key: name.to_string(),
37            ctrl: false,
38            alt: false,
39            shift: false,
40            meta: false,
41        }
42    }
43
44    /// Builder: set Ctrl.
45    pub fn ctrl(mut self) -> Self {
46        self.ctrl = true;
47        self
48    }
49    /// Builder: set Alt.
50    pub fn alt(mut self) -> Self {
51        self.alt = true;
52        self
53    }
54    /// Builder: set Shift.
55    pub fn shift(mut self) -> Self {
56        self.shift = true;
57        self
58    }
59    /// Builder: set Meta/Cmd.
60    pub fn meta(mut self) -> Self {
61        self.meta = true;
62        self
63    }
64
65    fn canonical(&self) -> String {
66        let mut s = String::new();
67        if self.alt {
68            s.push_str("Alt-");
69        }
70        if self.ctrl {
71            s.push_str("Ctrl-");
72        }
73        if self.meta {
74            s.push_str("Meta-");
75        }
76        if self.shift {
77            s.push_str("Shift-");
78        }
79        s.push_str(&self.key);
80        s
81    }
82}
83
84/// A set of key→command bindings for one platform.
85pub struct Keymap {
86    mac: bool,
87    bindings: HashMap<String, Command>,
88}
89
90impl Keymap {
91    /// Build a keymap. `mac` selects whether `Mod` means Cmd/Meta (macOS) or
92    /// Ctrl. Binding strings use `-`-separated modifiers, e.g.
93    /// `"Mod-b"`, `"Mod-Shift-z"`, `"Enter"`.
94    pub fn new(mac: bool, bindings: Vec<(&str, Command)>) -> Self {
95        let mut map = HashMap::new();
96        let mut km = Keymap {
97            mac,
98            bindings: HashMap::new(),
99        };
100        for (spec, cmd) in bindings {
101            map.insert(km.normalize_spec(spec), cmd);
102        }
103        km.bindings = map;
104        km
105    }
106
107    fn normalize_spec(&self, spec: &str) -> String {
108        let parts: Vec<&str> = spec.split('-').collect();
109        let (mods, key) = parts.split_at(parts.len() - 1);
110        let (mut alt, mut ctrl, mut shift, mut meta) = (false, false, false, false);
111        for m in mods {
112            match *m {
113                "Mod" => {
114                    if self.mac {
115                        meta = true;
116                    } else {
117                        ctrl = true;
118                    }
119                }
120                "Cmd" | "Meta" => meta = true,
121                "Ctrl" | "Control" => ctrl = true,
122                "Alt" | "Option" => alt = true,
123                "Shift" => shift = true,
124                other => panic!("unknown key modifier `{other}`"),
125            }
126        }
127        KeyPress {
128            key: key[0].to_string(),
129            ctrl,
130            alt,
131            shift,
132            meta,
133        }
134        .canonical()
135    }
136
137    /// Handle a key press. Returns whether a binding matched (and ran, if a
138    /// dispatch was given and the command applied).
139    ///
140    /// Lookup is two-pass: first the exact canonical form, then — if shift
141    /// was held and the key isn't a lowercase ASCII letter — the same form
142    /// with shift stripped. That mirrors the browser convention where a
143    /// key like `>` or `?` is always produced with Shift, so a binding
144    /// like `"Mod->"` shouldn't have to spell out `Shift`.
145    pub fn handle(
146        &self,
147        state: &EditorState,
148        press: &KeyPress,
149        mut dispatch: Option<&mut Dispatch<'_>>,
150    ) -> bool {
151        if let Some(cmd) = self.bindings.get(&press.canonical()) {
152            return cmd(state, dispatch.as_deref_mut());
153        }
154        if press.shift && shift_is_implicit(&press.key) {
155            let mut alt = press.clone();
156            alt.shift = false;
157            if let Some(cmd) = self.bindings.get(&alt.canonical()) {
158                return cmd(state, dispatch);
159            }
160        }
161        false
162    }
163
164    /// Add or replace a binding by its `Mod-`-using key spec (e.g.
165    /// `"Mod-b"`). Extensions use this to inject bindings on top of
166    /// [`base_keymap`].
167    pub fn add(&mut self, spec: &str, command: Command) {
168        let canonical = self.normalize_spec(spec);
169        self.bindings.insert(canonical, command);
170    }
171
172    /// Add a binding, **chaining** it in front of any existing binding for
173    /// the same key rather than replacing it: the new command is tried
174    /// first and the previous one becomes its fallback. Because
175    /// well-behaved commands report `false` (and do nothing) when they
176    /// don't apply, this lets independent extensions cooperate on a shared
177    /// key — e.g. `Tab` running cell-navigation inside a table and
178    /// list-indent inside a list, each a no-op in the other's context.
179    pub fn add_chained(&mut self, spec: &str, command: Command) {
180        let canonical = self.normalize_spec(spec);
181        match self.bindings.remove(&canonical) {
182            Some(existing) => {
183                self.bindings
184                    .insert(canonical, chain(vec![command, existing]));
185            }
186            None => {
187                self.bindings.insert(canonical, command);
188            }
189        }
190    }
191
192    /// Number of bindings.
193    pub fn len(&self) -> usize {
194        self.bindings.len()
195    }
196
197    /// Whether the keymap has no bindings.
198    pub fn is_empty(&self) -> bool {
199        self.bindings.is_empty()
200    }
201}
202
203/// Whether shift on `key` is implied (and can be stripped during lookup).
204/// True for single-character keys that are not lowercase ASCII letters —
205/// symbols (`>` `?` `:` …) require shift to produce on most layouts, and
206/// uppercase letters (`Z`) likewise implicitly carry shift.
207fn shift_is_implicit(key: &str) -> bool {
208    let mut chars = key.chars();
209    match (chars.next(), chars.next()) {
210        (Some(c), None) => !c.is_ascii_lowercase(),
211        _ => false,
212    }
213}
214
215/// The baseline keymap every editor wants: Enter (split), Backspace/Delete
216/// (selection → block-join → char), `Mod-a` (select all), and caret motion
217/// (arrows, Home/End).
218pub fn base_keymap(mac: bool) -> Keymap {
219    let bindings: Vec<(&str, Command)> = vec![
220        ("Enter", Box::new(split_block)),
221        (
222            "Backspace",
223            chain(vec![
224                Box::new(delete_selection),
225                Box::new(join_backward),
226                Box::new(delete_backward),
227            ]),
228        ),
229        (
230            "Delete",
231            chain(vec![
232                Box::new(delete_selection),
233                Box::new(join_forward),
234                Box::new(delete_forward),
235            ]),
236        ),
237        ("Mod-a", Box::new(select_all)),
238        ("ArrowLeft", Box::new(caret_left)),
239        ("ArrowRight", Box::new(caret_right)),
240        ("Home", Box::new(caret_line_start)),
241        ("End", Box::new(caret_line_end)),
242    ];
243    Keymap::new(mac, bindings)
244}