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    /// Replace the entire buffer with `new`, placing the cursor at the end.
79    pub fn replace_buffer(&mut self, new: impl Into<String>) {
80        let buf: String = new.into();
81        self.cursor = buf.chars().count();
82        self.buffer = buf;
83    }
84
85    fn chars(&self) -> Vec<char> {
86        self.buffer.chars().collect()
87    }
88
89    fn rebuild(&mut self, chars: &[char]) {
90        self.buffer = chars.iter().collect();
91        if self.cursor > chars.len() {
92            self.cursor = chars.len();
93        }
94    }
95
96    /// Insert a single character at the cursor. In vim mode, insertion
97    /// only happens in `Insert` sub-mode; in emacs mode always.
98    pub fn insert_char(&mut self, c: char) {
99        if self.mode == EditMode::Vim && self.vim == VimMode::Normal {
100            return;
101        }
102        let mut chars = self.chars();
103        chars.insert(self.cursor, c);
104        self.cursor += 1;
105        self.rebuild(&chars);
106    }
107
108    pub fn delete_backward(&mut self) {
109        if self.cursor == 0 {
110            return;
111        }
112        let mut chars = self.chars();
113        chars.remove(self.cursor - 1);
114        self.cursor -= 1;
115        self.rebuild(&chars);
116    }
117
118    pub fn delete_forward(&mut self) {
119        let mut chars = self.chars();
120        if self.cursor < chars.len() {
121            chars.remove(self.cursor);
122            self.rebuild(&chars);
123        }
124    }
125
126    pub fn move_left(&mut self) {
127        if self.cursor > 0 {
128            self.cursor -= 1;
129        }
130    }
131
132    pub fn move_right(&mut self) {
133        let len = self.chars().len();
134        if self.cursor < len {
135            self.cursor += 1;
136        }
137    }
138
139    pub fn move_home(&mut self) {
140        self.cursor = 0;
141    }
142
143    pub fn move_end(&mut self) {
144        self.cursor = self.chars().len();
145    }
146
147    /// Delete from the cursor to end-of-line (emacs C-k).
148    pub fn kill_to_end(&mut self) {
149        let mut chars = self.chars();
150        chars.truncate(self.cursor);
151        self.rebuild(&chars);
152    }
153
154    /// Delete the word before the cursor (emacs C-w).
155    pub fn kill_word_backward(&mut self) {
156        let mut chars = self.chars();
157        let mut i = self.cursor;
158        while i > 0 && chars[i - 1].is_whitespace() {
159            i -= 1;
160        }
161        while i > 0 && !chars[i - 1].is_whitespace() {
162            i -= 1;
163        }
164        chars.drain(i..self.cursor);
165        self.cursor = i;
166        self.rebuild(&chars);
167    }
168
169    pub fn enter_insert_mode(&mut self) {
170        self.vim = VimMode::Insert;
171    }
172
173    pub fn enter_normal_mode(&mut self) {
174        self.vim = VimMode::Normal;
175    }
176
177    /// Execute a vim normal-mode motion or edit by name. Keeping this
178    /// symbolic rather than key-driven means the event layer can
179    /// translate key events into these names, and the pure engine is
180    /// trivial to unit-test.
181    pub fn vim_action(&mut self, action: VimAction) {
182        match action {
183            VimAction::MoveLeft => self.move_left(),
184            VimAction::MoveRight => self.move_right(),
185            VimAction::MoveHome => self.move_home(),
186            VimAction::MoveEnd => self.move_end(),
187            VimAction::WordForward => self.vim_word_forward(),
188            VimAction::WordBackward => self.vim_word_backward(),
189            VimAction::DeleteChar => self.delete_forward(),
190            VimAction::DeleteWordForward => self.vim_delete_word_forward(),
191            VimAction::DeleteWordBackward => self.kill_word_backward(),
192            VimAction::InsertAtCursor => self.enter_insert_mode(),
193            VimAction::InsertAfterCursor => {
194                self.move_right();
195                self.enter_insert_mode();
196            }
197            VimAction::InsertAtLineStart => {
198                self.move_home();
199                self.enter_insert_mode();
200            }
201            VimAction::InsertAtLineEnd => {
202                self.move_end();
203                self.enter_insert_mode();
204            }
205        }
206    }
207
208    fn vim_word_forward(&mut self) {
209        let chars = self.chars();
210        let mut i = self.cursor;
211        while i < chars.len() && !chars[i].is_whitespace() {
212            i += 1;
213        }
214        while i < chars.len() && chars[i].is_whitespace() {
215            i += 1;
216        }
217        self.cursor = i;
218    }
219
220    fn vim_word_backward(&mut self) {
221        let chars = self.chars();
222        let mut i = self.cursor;
223        while i > 0 && chars[i - 1].is_whitespace() {
224            i -= 1;
225        }
226        while i > 0 && !chars[i - 1].is_whitespace() {
227            i -= 1;
228        }
229        self.cursor = i;
230    }
231
232    fn vim_delete_word_forward(&mut self) {
233        let mut chars = self.chars();
234        let start = self.cursor;
235        let mut i = start;
236        while i < chars.len() && !chars[i].is_whitespace() {
237            i += 1;
238        }
239        while i < chars.len() && chars[i].is_whitespace() {
240            i += 1;
241        }
242        chars.drain(start..i);
243        self.rebuild(&chars);
244    }
245}
246
247/// Named vim-mode actions the event layer can invoke.
248#[derive(Debug, Clone, Copy, PartialEq, Eq)]
249pub enum VimAction {
250    MoveLeft,
251    MoveRight,
252    MoveHome,
253    MoveEnd,
254    WordForward,
255    WordBackward,
256    DeleteChar,
257    DeleteWordForward,
258    DeleteWordBackward,
259    InsertAtCursor,
260    InsertAfterCursor,
261    InsertAtLineStart,
262    InsertAtLineEnd,
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    #[test]
270    fn emacs_insert_appends_at_cursor() {
271        let mut e = Editor::new(EditMode::Emacs);
272        e.insert_char('a');
273        e.insert_char('b');
274        e.insert_char('c');
275        assert_eq!(e.buffer(), "abc");
276        assert_eq!(e.cursor(), 3);
277    }
278
279    #[test]
280    fn emacs_kill_to_end_truncates() {
281        let mut e = Editor::with_buffer(EditMode::Emacs, "hello world");
282        e.move_home();
283        for _ in 0..5 {
284            e.move_right();
285        }
286        e.kill_to_end();
287        assert_eq!(e.buffer(), "hello");
288        assert_eq!(e.cursor(), 5);
289    }
290
291    #[test]
292    fn emacs_kill_word_backward_drops_word_and_spaces() {
293        let mut e = Editor::with_buffer(EditMode::Emacs, "hello world");
294        e.move_end();
295        e.kill_word_backward();
296        assert_eq!(e.buffer(), "hello ");
297    }
298
299    #[test]
300    fn delete_backward_at_start_is_noop() {
301        let mut e = Editor::new(EditMode::Emacs);
302        e.delete_backward();
303        assert_eq!(e.buffer(), "");
304        assert_eq!(e.cursor(), 0);
305    }
306
307    #[test]
308    fn move_right_saturates_at_end() {
309        let mut e = Editor::with_buffer(EditMode::Emacs, "ab");
310        e.move_right();
311        e.move_right();
312        e.move_right();
313        assert_eq!(e.cursor(), 2);
314    }
315
316    #[test]
317    fn vim_normal_mode_ignores_insert_char() {
318        let mut e = Editor::new(EditMode::Vim);
319        e.enter_normal_mode();
320        e.insert_char('a');
321        assert_eq!(e.buffer(), "");
322    }
323
324    #[test]
325    fn vim_i_enters_insert_mode() {
326        let mut e = Editor::new(EditMode::Vim);
327        e.enter_normal_mode();
328        e.vim_action(VimAction::InsertAtCursor);
329        assert_eq!(e.vim_mode(), VimMode::Insert);
330        e.insert_char('x');
331        assert_eq!(e.buffer(), "x");
332    }
333
334    #[test]
335    fn vim_word_forward_skips_to_next_token() {
336        let mut e = Editor::with_buffer(EditMode::Vim, "hello world foo");
337        e.enter_normal_mode();
338        e.move_home();
339        e.vim_action(VimAction::WordForward);
340        assert_eq!(e.cursor(), 6);
341        e.vim_action(VimAction::WordForward);
342        assert_eq!(e.cursor(), 12);
343    }
344
345    #[test]
346    fn vim_word_backward_reverses_word_forward() {
347        let mut e = Editor::with_buffer(EditMode::Vim, "hello world foo");
348        e.enter_normal_mode();
349        e.move_end();
350        e.vim_action(VimAction::WordBackward);
351        assert_eq!(e.cursor(), 12);
352        e.vim_action(VimAction::WordBackward);
353        assert_eq!(e.cursor(), 6);
354    }
355
356    #[test]
357    fn vim_dw_deletes_word_forward() {
358        let mut e = Editor::with_buffer(EditMode::Vim, "hello world foo");
359        e.enter_normal_mode();
360        e.move_home();
361        e.vim_action(VimAction::DeleteWordForward);
362        assert_eq!(e.buffer(), "world foo");
363    }
364
365    #[test]
366    fn vim_capital_a_appends_at_eol_in_insert_mode() {
367        let mut e = Editor::with_buffer(EditMode::Vim, "ab");
368        e.enter_normal_mode();
369        e.vim_action(VimAction::InsertAtLineEnd);
370        assert_eq!(e.cursor(), 2);
371        assert_eq!(e.vim_mode(), VimMode::Insert);
372        e.insert_char('c');
373        assert_eq!(e.buffer(), "abc");
374    }
375
376    #[test]
377    fn switching_mode_resets_to_insert() {
378        let mut e = Editor::new(EditMode::Vim);
379        e.enter_normal_mode();
380        e.set_mode(EditMode::Emacs);
381        assert_eq!(e.vim_mode(), VimMode::Insert);
382    }
383}