Skip to main content

kimun_notes/components/text_editor/
plain_keys.rs

1//! What a key means to the **plain** backend.
2//!
3//! The same shape the vim engine uses — keys are *reified* into an
4//! [`Operation`] first and applied second — for the same reason: the mapping can
5//! then be asserted without a buffer. "Ctrl+Left means a word back, without
6//! extending" is a fact about the table, and until this existed the only way to
7//! ask was to press the key at a buffer and look at where the cursor went.
8//!
9//! This is also where the last of the old widget's surprises ends. It
10//! bound Ctrl+U and Ctrl+R to undo and redo inside its own input handler, so a key
11//! could step history behind the caller's back. Nothing is bound here that is not
12//! written here.
13//!
14//! What is deliberately *not* here: anything needing more than the buffer. The
15//! clipboard chords reach the OS, `Tab` indents whole rows, `Enter` may continue a
16//! markdown list, and an opening bracket typed over a selection wraps it — all of
17//! which the component owns and handles before a key reaches this table.
18
19use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
20
21use super::rope_buffer::{CursorMove, RopeBuffer};
22
23/// One editing action, named independently of the key that asked for it.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum Operation {
26    /// Move the cursor. `extend` is Shift: it grows the selection rather than
27    /// dropping it.
28    Move {
29        to: CursorMove,
30        extend: bool,
31    },
32    SelectAll,
33    /// Ctrl/Alt+Backspace.
34    DeleteWordBack,
35    /// Ctrl/Alt+Delete.
36    DeleteWordForward,
37    Insert(char),
38    InsertNewline,
39    /// Backspace.
40    DeleteBack,
41    /// Delete.
42    DeleteForward,
43}
44
45/// What `key` means, or `None` when it means nothing to this backend.
46///
47/// `None` is not "do nothing loudly": function keys, modifier-only releases and
48/// IME composition events all land here, and the caller leaves the buffer alone
49/// rather than marking the note dirty for a key that did not edit it.
50pub fn operation(key: KeyEvent) -> Option<Operation> {
51    let extend = key.modifiers.contains(KeyModifiers::SHIFT);
52    let chord = key.modifiers & !KeyModifiers::SHIFT;
53
54    let motion = |to| Some(Operation::Move { to, extend });
55    match (chord, key.code) {
56        (KeyModifiers::NONE, KeyCode::Left) => motion(CursorMove::Back),
57        (KeyModifiers::NONE, KeyCode::Right) => motion(CursorMove::Forward),
58        (KeyModifiers::NONE, KeyCode::Up) => motion(CursorMove::Up),
59        (KeyModifiers::NONE, KeyCode::Down) => motion(CursorMove::Down),
60        (KeyModifiers::NONE, KeyCode::Home) => motion(CursorMove::Head),
61        (KeyModifiers::NONE, KeyCode::End) => motion(CursorMove::End),
62        (KeyModifiers::NONE, KeyCode::PageUp) => motion(CursorMove::ParagraphBack),
63        (KeyModifiers::NONE, KeyCode::PageDown) => motion(CursorMove::ParagraphForward),
64
65        (KeyModifiers::CONTROL, KeyCode::Left) => motion(CursorMove::WordBack),
66        (KeyModifiers::CONTROL, KeyCode::Right) => motion(CursorMove::WordForward),
67        (KeyModifiers::CONTROL, KeyCode::Home) => motion(CursorMove::Top),
68        (KeyModifiers::CONTROL, KeyCode::End) => motion(CursorMove::Bottom),
69
70        // macOS conventions. Terminals translate Option+arrow into `Esc b`/`Esc f`
71        // by default, which crossterm reports as Alt+b / Alt+f; the shifted
72        // variants arrive as the uppercase character with SHIFT still set, which
73        // is why `extend` reads the modifier rather than the letter's case.
74        (KeyModifiers::ALT, KeyCode::Left) => motion(CursorMove::WordBack),
75        (KeyModifiers::ALT, KeyCode::Right) => motion(CursorMove::WordForward),
76        (KeyModifiers::ALT, KeyCode::Char('b' | 'B')) => motion(CursorMove::WordBack),
77        (KeyModifiers::ALT, KeyCode::Char('f' | 'F')) => motion(CursorMove::WordForward),
78        (KeyModifiers::SUPER, KeyCode::Left) => motion(CursorMove::Head),
79        (KeyModifiers::SUPER, KeyCode::Right) => motion(CursorMove::End),
80        (KeyModifiers::SUPER, KeyCode::Up) => motion(CursorMove::Top),
81        (KeyModifiers::SUPER, KeyCode::Down) => motion(CursorMove::Bottom),
82
83        (KeyModifiers::CONTROL, KeyCode::Char('a')) => Some(Operation::SelectAll),
84        (KeyModifiers::CONTROL, KeyCode::Backspace) | (KeyModifiers::ALT, KeyCode::Backspace) => {
85            Some(Operation::DeleteWordBack)
86        }
87        (KeyModifiers::CONTROL, KeyCode::Delete) | (KeyModifiers::ALT, KeyCode::Delete) => {
88            Some(Operation::DeleteWordForward)
89        }
90
91        // Text entry. Only an unmodified key types: a chord that reached here is
92        // one nothing above claimed, and inserting its character would be worse
93        // than ignoring it.
94        (KeyModifiers::NONE, KeyCode::Char(c)) => Some(Operation::Insert(c)),
95        (KeyModifiers::NONE, KeyCode::Enter) => Some(Operation::InsertNewline),
96        // No Tab: the component intercepts it ahead of this table and routes it
97        // to `indent_lines`, which indents whole lines by the **indent step**.
98        // The operation that used to sit here inserted spaces to the next tab
99        // stop computed from the cursor's *char* column — a third statement of
100        // the tab-stop rule, on a basis neither the wrap nor the renderer uses.
101        (KeyModifiers::NONE, KeyCode::Backspace) => Some(Operation::DeleteBack),
102        (KeyModifiers::NONE, KeyCode::Delete) => Some(Operation::DeleteForward),
103
104        _ => None,
105    }
106}
107
108/// Carry `op` out. Reports whether the buffer's text changed — a cursor move
109/// never does, and a delete with nothing to delete does not either.
110pub fn apply(op: Operation, buf: &mut RopeBuffer) -> bool {
111    match op {
112        Operation::Move { to, extend } => {
113            // Extending starts an anchor if there is not one already; not
114            // extending drops it. Saying which, per key, is what keeps a
115            // selection from outliving the gesture that made it.
116            if extend {
117                if buf.selection_range().is_none() {
118                    buf.start_selection();
119                }
120            } else {
121                buf.cancel_selection();
122            }
123            buf.move_cursor(to);
124            false
125        }
126        Operation::SelectAll => {
127            buf.select_all();
128            false
129        }
130        Operation::DeleteWordBack => buf.delete_word(),
131        Operation::DeleteWordForward => buf.delete_next_word(),
132        Operation::Insert(c) => {
133            buf.insert_char(c);
134            true
135        }
136        Operation::InsertNewline => {
137            buf.insert_newline();
138            true
139        }
140        Operation::DeleteBack => buf.delete_char(),
141        Operation::DeleteForward => buf.delete_next_char(),
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use crate::ropetext::Text;
149
150    fn key(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent {
151        KeyEvent::new(code, modifiers)
152    }
153
154    fn plain(code: KeyCode) -> Option<Operation> {
155        operation(key(code, KeyModifiers::NONE))
156    }
157
158    fn with(code: KeyCode, modifiers: KeyModifiers) -> Option<Operation> {
159        operation(key(code, modifiers))
160    }
161
162    // -- the mapping, asserted without a buffer -----------------------------
163
164    #[test]
165    fn arrows_move_without_extending() {
166        assert_eq!(
167            plain(KeyCode::Left),
168            Some(Operation::Move {
169                to: CursorMove::Back,
170                extend: false
171            })
172        );
173        assert_eq!(
174            plain(KeyCode::Down),
175            Some(Operation::Move {
176                to: CursorMove::Down,
177                extend: false
178            })
179        );
180    }
181
182    #[test]
183    fn shift_extends_whatever_it_is_held_with() {
184        for (code, to) in [
185            (KeyCode::Left, CursorMove::Back),
186            (KeyCode::Home, CursorMove::Head),
187            (KeyCode::PageDown, CursorMove::ParagraphForward),
188        ] {
189            assert_eq!(
190                with(code, KeyModifiers::SHIFT),
191                Some(Operation::Move { to, extend: true }),
192                "{code:?}"
193            );
194        }
195        assert_eq!(
196            with(KeyCode::Left, KeyModifiers::CONTROL | KeyModifiers::SHIFT),
197            Some(Operation::Move {
198                to: CursorMove::WordBack,
199                extend: true
200            }),
201            "shift composes with a chord rather than replacing it"
202        );
203    }
204
205    #[test]
206    fn control_moves_by_word_and_to_the_ends() {
207        assert_eq!(
208            with(KeyCode::Right, KeyModifiers::CONTROL),
209            Some(Operation::Move {
210                to: CursorMove::WordForward,
211                extend: false
212            })
213        );
214        assert_eq!(
215            with(KeyCode::End, KeyModifiers::CONTROL),
216            Some(Operation::Move {
217                to: CursorMove::Bottom,
218                extend: false
219            })
220        );
221    }
222
223    #[test]
224    fn either_modifier_deletes_a_word() {
225        for modifier in [KeyModifiers::CONTROL, KeyModifiers::ALT] {
226            assert_eq!(
227                with(KeyCode::Backspace, modifier),
228                Some(Operation::DeleteWordBack)
229            );
230            assert_eq!(
231                with(KeyCode::Delete, modifier),
232                Some(Operation::DeleteWordForward)
233            );
234        }
235    }
236
237    #[test]
238    fn unmodified_keys_type() {
239        assert_eq!(plain(KeyCode::Char('x')), Some(Operation::Insert('x')));
240        assert_eq!(plain(KeyCode::Enter), Some(Operation::InsertNewline));
241        // Tab is claimed by the component ahead of this table, so it is not a
242        // typing key here — see the note beside `InsertNewline` in `operation`.
243        assert_eq!(plain(KeyCode::Tab), None);
244        assert_eq!(plain(KeyCode::Backspace), Some(Operation::DeleteBack));
245        assert_eq!(plain(KeyCode::Delete), Some(Operation::DeleteForward));
246        assert_eq!(
247            with(KeyCode::Char('X'), KeyModifiers::SHIFT),
248            Some(Operation::Insert('X')),
249            "a shifted character is still a character"
250        );
251    }
252
253    #[test]
254    fn the_macos_conventions_are_here_too() {
255        // These used to be a second table further up the handler. One table means
256        // one place to look for what a key does — and one place for a collision to
257        // be visible rather than decided by which match ran first.
258        assert_eq!(
259            with(KeyCode::Left, KeyModifiers::ALT),
260            Some(Operation::Move {
261                to: CursorMove::WordBack,
262                extend: false
263            })
264        );
265        assert_eq!(
266            with(KeyCode::Char('f'), KeyModifiers::ALT),
267            Some(Operation::Move {
268                to: CursorMove::WordForward,
269                extend: false
270            })
271        );
272        assert_eq!(
273            with(KeyCode::Char('B'), KeyModifiers::ALT | KeyModifiers::SHIFT),
274            Some(Operation::Move {
275                to: CursorMove::WordBack,
276                extend: true
277            }),
278            "the uppercase letter arrives with SHIFT set; the modifier decides"
279        );
280        assert_eq!(
281            with(KeyCode::Up, KeyModifiers::SUPER),
282            Some(Operation::Move {
283                to: CursorMove::Top,
284                extend: false
285            })
286        );
287    }
288
289    #[test]
290    fn undo_and_redo_are_not_bound_here() {
291        // The widget this replaced bound these inside
292        // its own input handler, so a key stepped history behind the caller's
293        // back. They belong to the component's shortcut layer, which sees the key
294        // first — and if it ever stopped, this table must not quietly pick it up.
295        assert_eq!(with(KeyCode::Char('u'), KeyModifiers::CONTROL), None);
296        assert_eq!(with(KeyCode::Char('r'), KeyModifiers::CONTROL), None);
297    }
298
299    #[test]
300    fn keys_that_mean_nothing_here_mean_nothing() {
301        assert_eq!(plain(KeyCode::F(1)), None);
302        assert_eq!(plain(KeyCode::Null), None);
303        assert_eq!(plain(KeyCode::Esc), None);
304        assert_eq!(with(KeyCode::Char('v'), KeyModifiers::CONTROL), None);
305        assert_eq!(
306            with(KeyCode::Char('q'), KeyModifiers::ALT),
307            None,
308            "an unclaimed chord must not type its character"
309        );
310    }
311
312    // -- application ---------------------------------------------------------
313
314    fn buffer(text: &str) -> RopeBuffer {
315        RopeBuffer::new(Text::from(text))
316    }
317
318    #[test]
319    fn a_move_reports_no_text_change() {
320        let mut buf = buffer("hello");
321        let changed = apply(
322            Operation::Move {
323                to: CursorMove::Forward,
324                extend: false,
325            },
326            &mut buf,
327        );
328        assert!(!changed);
329        assert_eq!(buf.cursor(), (0, 1));
330    }
331
332    #[test]
333    fn extending_grows_a_selection_and_moving_drops_it() {
334        let mut buf = buffer("hello");
335        for _ in 0..2 {
336            apply(
337                Operation::Move {
338                    to: CursorMove::Forward,
339                    extend: true,
340                },
341                &mut buf,
342            );
343        }
344        assert_eq!(buf.selection_range(), Some(((0, 0), (0, 2))));
345        apply(
346            Operation::Move {
347                to: CursorMove::Forward,
348                extend: false,
349            },
350            &mut buf,
351        );
352        assert!(buf.selection_range().is_none());
353    }
354
355    #[test]
356    fn a_delete_with_nothing_to_delete_reports_no_change() {
357        let mut buf = buffer("");
358        assert!(!apply(Operation::DeleteBack, &mut buf));
359        assert!(!apply(Operation::DeleteForward, &mut buf));
360        assert!(!apply(Operation::DeleteWordBack, &mut buf));
361    }
362
363    #[test]
364    fn typing_reports_a_change() {
365        let mut buf = buffer("");
366        assert!(apply(Operation::Insert('a'), &mut buf));
367        assert!(apply(Operation::InsertNewline, &mut buf));
368        assert_eq!(buf.text().to_string(), "a\n");
369    }
370}