Skip to main content

tui/widgets/
editor.rs

1//! Text-input editor supporting both emacs-style readline bindings and
2//! a minimal vim-motion mode. The editor is pure state — it does not
3//! own any terminal or ratatui types, so it can be tested without I/O.
4
5/// Edit mode for text-input widgets.
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum EditMode {
8    Emacs,
9    Vim,
10}
11
12/// Vim sub-mode. Only relevant when [`EditMode::Vim`] is active.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum VimMode {
15    Normal,
16    Insert,
17}
18
19/// A single-line text buffer with a cursor. Multi-line editing is not
20/// needed for any current form field (transaction notes are one-line
21/// memos today) but could grow in-place later.
22#[derive(Debug, Clone)]
23pub struct Editor {
24    buffer: String,
25    cursor: usize,
26    mode: EditMode,
27    vim: VimMode,
28}
29
30impl Editor {
31    #[must_use]
32    pub fn new(mode: EditMode) -> Self {
33        Self {
34            buffer: String::new(),
35            cursor: 0,
36            mode,
37            vim: VimMode::Insert,
38        }
39    }
40
41    #[must_use]
42    pub fn with_buffer(mode: EditMode, buffer: impl Into<String>) -> Self {
43        let buf: String = buffer.into();
44        let cursor = buf.chars().count();
45        Self {
46            buffer: buf,
47            cursor,
48            mode,
49            vim: VimMode::Insert,
50        }
51    }
52
53    #[must_use]
54    pub fn buffer(&self) -> &str {
55        &self.buffer
56    }
57
58    #[must_use]
59    pub fn cursor(&self) -> usize {
60        self.cursor
61    }
62
63    #[must_use]
64    pub fn mode(&self) -> EditMode {
65        self.mode
66    }
67
68    #[must_use]
69    pub fn vim_mode(&self) -> VimMode {
70        self.vim
71    }
72
73    pub fn set_mode(&mut self, mode: EditMode) {
74        self.mode = mode;
75        self.vim = VimMode::Insert;
76    }
77
78    fn chars(&self) -> Vec<char> {
79        self.buffer.chars().collect()
80    }
81
82    fn rebuild(&mut self, chars: &[char]) {
83        self.buffer = chars.iter().collect();
84        if self.cursor > chars.len() {
85            self.cursor = chars.len();
86        }
87    }
88
89    /// Insert a single character at the cursor. In vim mode, insertion
90    /// only happens in `Insert` sub-mode; in emacs mode always.
91    pub fn insert_char(&mut self, c: char) {
92        if self.mode == EditMode::Vim && self.vim == VimMode::Normal {
93            return;
94        }
95        let mut chars = self.chars();
96        chars.insert(self.cursor, c);
97        self.cursor += 1;
98        self.rebuild(&chars);
99    }
100
101    pub fn delete_backward(&mut self) {
102        if self.cursor == 0 {
103            return;
104        }
105        let mut chars = self.chars();
106        chars.remove(self.cursor - 1);
107        self.cursor -= 1;
108        self.rebuild(&chars);
109    }
110
111    pub fn delete_forward(&mut self) {
112        let mut chars = self.chars();
113        if self.cursor < chars.len() {
114            chars.remove(self.cursor);
115            self.rebuild(&chars);
116        }
117    }
118
119    pub fn move_left(&mut self) {
120        if self.cursor > 0 {
121            self.cursor -= 1;
122        }
123    }
124
125    pub fn move_right(&mut self) {
126        let len = self.chars().len();
127        if self.cursor < len {
128            self.cursor += 1;
129        }
130    }
131
132    pub fn move_home(&mut self) {
133        self.cursor = 0;
134    }
135
136    pub fn move_end(&mut self) {
137        self.cursor = self.chars().len();
138    }
139
140    /// Delete from the cursor to end-of-line (emacs C-k).
141    pub fn kill_to_end(&mut self) {
142        let mut chars = self.chars();
143        chars.truncate(self.cursor);
144        self.rebuild(&chars);
145    }
146
147    /// Delete the word before the cursor (emacs C-w).
148    pub fn kill_word_backward(&mut self) {
149        let mut chars = self.chars();
150        let mut i = self.cursor;
151        while i > 0 && chars[i - 1].is_whitespace() {
152            i -= 1;
153        }
154        while i > 0 && !chars[i - 1].is_whitespace() {
155            i -= 1;
156        }
157        chars.drain(i..self.cursor);
158        self.cursor = i;
159        self.rebuild(&chars);
160    }
161
162    pub fn enter_insert_mode(&mut self) {
163        self.vim = VimMode::Insert;
164    }
165
166    pub fn enter_normal_mode(&mut self) {
167        self.vim = VimMode::Normal;
168    }
169
170    /// Execute a vim normal-mode motion or edit by name. Keeping this
171    /// symbolic rather than key-driven means the event layer can
172    /// translate key events into these names, and the pure engine is
173    /// trivial to unit-test.
174    pub fn vim_action(&mut self, action: VimAction) {
175        match action {
176            VimAction::MoveLeft => self.move_left(),
177            VimAction::MoveRight => self.move_right(),
178            VimAction::MoveHome => self.move_home(),
179            VimAction::MoveEnd => self.move_end(),
180            VimAction::WordForward => self.vim_word_forward(),
181            VimAction::WordBackward => self.vim_word_backward(),
182            VimAction::DeleteChar => self.delete_forward(),
183            VimAction::DeleteWordForward => self.vim_delete_word_forward(),
184            VimAction::DeleteWordBackward => self.kill_word_backward(),
185            VimAction::InsertAtCursor => self.enter_insert_mode(),
186            VimAction::InsertAfterCursor => {
187                self.move_right();
188                self.enter_insert_mode();
189            }
190            VimAction::InsertAtLineStart => {
191                self.move_home();
192                self.enter_insert_mode();
193            }
194            VimAction::InsertAtLineEnd => {
195                self.move_end();
196                self.enter_insert_mode();
197            }
198        }
199    }
200
201    fn vim_word_forward(&mut self) {
202        let chars = self.chars();
203        let mut i = self.cursor;
204        while i < chars.len() && !chars[i].is_whitespace() {
205            i += 1;
206        }
207        while i < chars.len() && chars[i].is_whitespace() {
208            i += 1;
209        }
210        self.cursor = i;
211    }
212
213    fn vim_word_backward(&mut self) {
214        let chars = self.chars();
215        let mut i = self.cursor;
216        while i > 0 && chars[i - 1].is_whitespace() {
217            i -= 1;
218        }
219        while i > 0 && !chars[i - 1].is_whitespace() {
220            i -= 1;
221        }
222        self.cursor = i;
223    }
224
225    fn vim_delete_word_forward(&mut self) {
226        let mut chars = self.chars();
227        let start = self.cursor;
228        let mut i = start;
229        while i < chars.len() && !chars[i].is_whitespace() {
230            i += 1;
231        }
232        while i < chars.len() && chars[i].is_whitespace() {
233            i += 1;
234        }
235        chars.drain(start..i);
236        self.rebuild(&chars);
237    }
238}
239
240/// Named vim-mode actions the event layer can invoke.
241#[derive(Debug, Clone, Copy, PartialEq, Eq)]
242pub enum VimAction {
243    MoveLeft,
244    MoveRight,
245    MoveHome,
246    MoveEnd,
247    WordForward,
248    WordBackward,
249    DeleteChar,
250    DeleteWordForward,
251    DeleteWordBackward,
252    InsertAtCursor,
253    InsertAfterCursor,
254    InsertAtLineStart,
255    InsertAtLineEnd,
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    #[test]
263    fn emacs_insert_appends_at_cursor() {
264        let mut e = Editor::new(EditMode::Emacs);
265        e.insert_char('a');
266        e.insert_char('b');
267        e.insert_char('c');
268        assert_eq!(e.buffer(), "abc");
269        assert_eq!(e.cursor(), 3);
270    }
271
272    #[test]
273    fn emacs_kill_to_end_truncates() {
274        let mut e = Editor::with_buffer(EditMode::Emacs, "hello world");
275        e.move_home();
276        for _ in 0..5 {
277            e.move_right();
278        }
279        e.kill_to_end();
280        assert_eq!(e.buffer(), "hello");
281        assert_eq!(e.cursor(), 5);
282    }
283
284    #[test]
285    fn emacs_kill_word_backward_drops_word_and_spaces() {
286        let mut e = Editor::with_buffer(EditMode::Emacs, "hello world");
287        e.move_end();
288        e.kill_word_backward();
289        assert_eq!(e.buffer(), "hello ");
290    }
291
292    #[test]
293    fn delete_backward_at_start_is_noop() {
294        let mut e = Editor::new(EditMode::Emacs);
295        e.delete_backward();
296        assert_eq!(e.buffer(), "");
297        assert_eq!(e.cursor(), 0);
298    }
299
300    #[test]
301    fn move_right_saturates_at_end() {
302        let mut e = Editor::with_buffer(EditMode::Emacs, "ab");
303        e.move_right();
304        e.move_right();
305        e.move_right();
306        assert_eq!(e.cursor(), 2);
307    }
308
309    #[test]
310    fn vim_normal_mode_ignores_insert_char() {
311        let mut e = Editor::new(EditMode::Vim);
312        e.enter_normal_mode();
313        e.insert_char('a');
314        assert_eq!(e.buffer(), "");
315    }
316
317    #[test]
318    fn vim_i_enters_insert_mode() {
319        let mut e = Editor::new(EditMode::Vim);
320        e.enter_normal_mode();
321        e.vim_action(VimAction::InsertAtCursor);
322        assert_eq!(e.vim_mode(), VimMode::Insert);
323        e.insert_char('x');
324        assert_eq!(e.buffer(), "x");
325    }
326
327    #[test]
328    fn vim_word_forward_skips_to_next_token() {
329        let mut e = Editor::with_buffer(EditMode::Vim, "hello world foo");
330        e.enter_normal_mode();
331        e.move_home();
332        e.vim_action(VimAction::WordForward);
333        assert_eq!(e.cursor(), 6);
334        e.vim_action(VimAction::WordForward);
335        assert_eq!(e.cursor(), 12);
336    }
337
338    #[test]
339    fn vim_word_backward_reverses_word_forward() {
340        let mut e = Editor::with_buffer(EditMode::Vim, "hello world foo");
341        e.enter_normal_mode();
342        e.move_end();
343        e.vim_action(VimAction::WordBackward);
344        assert_eq!(e.cursor(), 12);
345        e.vim_action(VimAction::WordBackward);
346        assert_eq!(e.cursor(), 6);
347    }
348
349    #[test]
350    fn vim_dw_deletes_word_forward() {
351        let mut e = Editor::with_buffer(EditMode::Vim, "hello world foo");
352        e.enter_normal_mode();
353        e.move_home();
354        e.vim_action(VimAction::DeleteWordForward);
355        assert_eq!(e.buffer(), "world foo");
356    }
357
358    #[test]
359    fn vim_capital_a_appends_at_eol_in_insert_mode() {
360        let mut e = Editor::with_buffer(EditMode::Vim, "ab");
361        e.enter_normal_mode();
362        e.vim_action(VimAction::InsertAtLineEnd);
363        assert_eq!(e.cursor(), 2);
364        assert_eq!(e.vim_mode(), VimMode::Insert);
365        e.insert_char('c');
366        assert_eq!(e.buffer(), "abc");
367    }
368
369    #[test]
370    fn switching_mode_resets_to_insert() {
371        let mut e = Editor::new(EditMode::Vim);
372        e.enter_normal_mode();
373        e.set_mode(EditMode::Emacs);
374        assert_eq!(e.vim_mode(), VimMode::Insert);
375    }
376}