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)]
7
pub 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)]
14
pub 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)]
23
pub struct Editor {
24
    buffer: String,
25
    cursor: usize,
26
    mode: EditMode,
27
    vim: VimMode,
28
}
29

            
30
impl Editor {
31
    #[must_use]
32
1622
    pub fn new(mode: EditMode) -> Self {
33
1622
        Self {
34
1622
            buffer: String::new(),
35
1622
            cursor: 0,
36
1622
            mode,
37
1622
            vim: VimMode::Insert,
38
1622
        }
39
1622
    }
40

            
41
    #[must_use]
42
96
    pub fn with_buffer(mode: EditMode, buffer: impl Into<String>) -> Self {
43
96
        let buf: String = buffer.into();
44
96
        let cursor = buf.chars().count();
45
96
        Self {
46
96
            buffer: buf,
47
96
            cursor,
48
96
            mode,
49
96
            vim: VimMode::Insert,
50
96
        }
51
96
    }
52

            
53
    #[must_use]
54
1202
    pub fn buffer(&self) -> &str {
55
1202
        &self.buffer
56
1202
    }
57

            
58
    #[must_use]
59
18
    pub fn cursor(&self) -> usize {
60
18
        self.cursor
61
18
    }
62

            
63
    #[must_use]
64
1086
    pub fn mode(&self) -> EditMode {
65
1086
        self.mode
66
1086
    }
67

            
68
    #[must_use]
69
9
    pub fn vim_mode(&self) -> VimMode {
70
9
        self.vim
71
9
    }
72

            
73
9
    pub fn set_mode(&mut self, mode: EditMode) {
74
9
        self.mode = mode;
75
9
        self.vim = VimMode::Insert;
76
9
    }
77

            
78
    /// Replace the entire buffer with `new`, placing the cursor at the end.
79
5
    pub fn replace_buffer(&mut self, new: impl Into<String>) {
80
5
        let buf: String = new.into();
81
5
        self.cursor = buf.chars().count();
82
5
        self.buffer = buf;
83
5
    }
84

            
85
6349
    fn chars(&self) -> Vec<char> {
86
6349
        self.buffer.chars().collect()
87
6349
    }
88

            
89
6334
    fn rebuild(&mut self, chars: &[char]) {
90
6334
        self.buffer = chars.iter().collect();
91
6334
        if self.cursor > chars.len() {
92
            self.cursor = chars.len();
93
6334
        }
94
6334
    }
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
6330
    pub fn insert_char(&mut self, c: char) {
99
6330
        if self.mode == EditMode::Vim && self.vim == VimMode::Normal {
100
1
            return;
101
6329
        }
102
6329
        let mut chars = self.chars();
103
6329
        chars.insert(self.cursor, c);
104
6329
        self.cursor += 1;
105
6329
        self.rebuild(&chars);
106
6330
    }
107

            
108
3
    pub fn delete_backward(&mut self) {
109
3
        if self.cursor == 0 {
110
1
            return;
111
2
        }
112
2
        let mut chars = self.chars();
113
2
        chars.remove(self.cursor - 1);
114
2
        self.cursor -= 1;
115
2
        self.rebuild(&chars);
116
3
    }
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
8
    pub fn move_right(&mut self) {
133
8
        let len = self.chars().len();
134
8
        if self.cursor < len {
135
5
            self.cursor += 1;
136
5
        }
137
8
    }
138

            
139
3
    pub fn move_home(&mut self) {
140
3
        self.cursor = 0;
141
3
    }
142

            
143
3
    pub fn move_end(&mut self) {
144
3
        self.cursor = self.chars().len();
145
3
    }
146

            
147
    /// Delete from the cursor to end-of-line (emacs C-k).
148
1
    pub fn kill_to_end(&mut self) {
149
1
        let mut chars = self.chars();
150
1
        chars.truncate(self.cursor);
151
1
        self.rebuild(&chars);
152
1
    }
153

            
154
    /// Delete the word before the cursor (emacs C-w).
155
1
    pub fn kill_word_backward(&mut self) {
156
1
        let mut chars = self.chars();
157
1
        let mut i = self.cursor;
158
1
        while i > 0 && chars[i - 1].is_whitespace() {
159
            i -= 1;
160
        }
161
6
        while i > 0 && !chars[i - 1].is_whitespace() {
162
5
            i -= 1;
163
5
        }
164
1
        chars.drain(i..self.cursor);
165
1
        self.cursor = i;
166
1
        self.rebuild(&chars);
167
1
    }
168

            
169
2
    pub fn enter_insert_mode(&mut self) {
170
2
        self.vim = VimMode::Insert;
171
2
    }
172

            
173
10
    pub fn enter_normal_mode(&mut self) {
174
10
        self.vim = VimMode::Normal;
175
10
    }
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
7
    pub fn vim_action(&mut self, action: VimAction) {
182
7
        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
2
            VimAction::WordForward => self.vim_word_forward(),
188
2
            VimAction::WordBackward => self.vim_word_backward(),
189
            VimAction::DeleteChar => self.delete_forward(),
190
1
            VimAction::DeleteWordForward => self.vim_delete_word_forward(),
191
            VimAction::DeleteWordBackward => self.kill_word_backward(),
192
1
            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
1
            VimAction::InsertAtLineEnd => {
202
1
                self.move_end();
203
1
                self.enter_insert_mode();
204
1
            }
205
        }
206
7
    }
207

            
208
2
    fn vim_word_forward(&mut self) {
209
2
        let chars = self.chars();
210
2
        let mut i = self.cursor;
211
12
        while i < chars.len() && !chars[i].is_whitespace() {
212
10
            i += 1;
213
10
        }
214
4
        while i < chars.len() && chars[i].is_whitespace() {
215
2
            i += 1;
216
2
        }
217
2
        self.cursor = i;
218
2
    }
219

            
220
2
    fn vim_word_backward(&mut self) {
221
2
        let chars = self.chars();
222
2
        let mut i = self.cursor;
223
3
        while i > 0 && chars[i - 1].is_whitespace() {
224
1
            i -= 1;
225
1
        }
226
10
        while i > 0 && !chars[i - 1].is_whitespace() {
227
8
            i -= 1;
228
8
        }
229
2
        self.cursor = i;
230
2
    }
231

            
232
1
    fn vim_delete_word_forward(&mut self) {
233
1
        let mut chars = self.chars();
234
1
        let start = self.cursor;
235
1
        let mut i = start;
236
6
        while i < chars.len() && !chars[i].is_whitespace() {
237
5
            i += 1;
238
5
        }
239
2
        while i < chars.len() && chars[i].is_whitespace() {
240
1
            i += 1;
241
1
        }
242
1
        chars.drain(start..i);
243
1
        self.rebuild(&chars);
244
1
    }
245
}
246

            
247
/// Named vim-mode actions the event layer can invoke.
248
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
249
pub 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)]
266
mod tests {
267
    use super::*;
268

            
269
    #[test]
270
1
    fn emacs_insert_appends_at_cursor() {
271
1
        let mut e = Editor::new(EditMode::Emacs);
272
1
        e.insert_char('a');
273
1
        e.insert_char('b');
274
1
        e.insert_char('c');
275
1
        assert_eq!(e.buffer(), "abc");
276
1
        assert_eq!(e.cursor(), 3);
277
1
    }
278

            
279
    #[test]
280
1
    fn emacs_kill_to_end_truncates() {
281
1
        let mut e = Editor::with_buffer(EditMode::Emacs, "hello world");
282
1
        e.move_home();
283
5
        for _ in 0..5 {
284
5
            e.move_right();
285
5
        }
286
1
        e.kill_to_end();
287
1
        assert_eq!(e.buffer(), "hello");
288
1
        assert_eq!(e.cursor(), 5);
289
1
    }
290

            
291
    #[test]
292
1
    fn emacs_kill_word_backward_drops_word_and_spaces() {
293
1
        let mut e = Editor::with_buffer(EditMode::Emacs, "hello world");
294
1
        e.move_end();
295
1
        e.kill_word_backward();
296
1
        assert_eq!(e.buffer(), "hello ");
297
1
    }
298

            
299
    #[test]
300
1
    fn delete_backward_at_start_is_noop() {
301
1
        let mut e = Editor::new(EditMode::Emacs);
302
1
        e.delete_backward();
303
1
        assert_eq!(e.buffer(), "");
304
1
        assert_eq!(e.cursor(), 0);
305
1
    }
306

            
307
    #[test]
308
1
    fn move_right_saturates_at_end() {
309
1
        let mut e = Editor::with_buffer(EditMode::Emacs, "ab");
310
1
        e.move_right();
311
1
        e.move_right();
312
1
        e.move_right();
313
1
        assert_eq!(e.cursor(), 2);
314
1
    }
315

            
316
    #[test]
317
1
    fn vim_normal_mode_ignores_insert_char() {
318
1
        let mut e = Editor::new(EditMode::Vim);
319
1
        e.enter_normal_mode();
320
1
        e.insert_char('a');
321
1
        assert_eq!(e.buffer(), "");
322
1
    }
323

            
324
    #[test]
325
1
    fn vim_i_enters_insert_mode() {
326
1
        let mut e = Editor::new(EditMode::Vim);
327
1
        e.enter_normal_mode();
328
1
        e.vim_action(VimAction::InsertAtCursor);
329
1
        assert_eq!(e.vim_mode(), VimMode::Insert);
330
1
        e.insert_char('x');
331
1
        assert_eq!(e.buffer(), "x");
332
1
    }
333

            
334
    #[test]
335
1
    fn vim_word_forward_skips_to_next_token() {
336
1
        let mut e = Editor::with_buffer(EditMode::Vim, "hello world foo");
337
1
        e.enter_normal_mode();
338
1
        e.move_home();
339
1
        e.vim_action(VimAction::WordForward);
340
1
        assert_eq!(e.cursor(), 6);
341
1
        e.vim_action(VimAction::WordForward);
342
1
        assert_eq!(e.cursor(), 12);
343
1
    }
344

            
345
    #[test]
346
1
    fn vim_word_backward_reverses_word_forward() {
347
1
        let mut e = Editor::with_buffer(EditMode::Vim, "hello world foo");
348
1
        e.enter_normal_mode();
349
1
        e.move_end();
350
1
        e.vim_action(VimAction::WordBackward);
351
1
        assert_eq!(e.cursor(), 12);
352
1
        e.vim_action(VimAction::WordBackward);
353
1
        assert_eq!(e.cursor(), 6);
354
1
    }
355

            
356
    #[test]
357
1
    fn vim_dw_deletes_word_forward() {
358
1
        let mut e = Editor::with_buffer(EditMode::Vim, "hello world foo");
359
1
        e.enter_normal_mode();
360
1
        e.move_home();
361
1
        e.vim_action(VimAction::DeleteWordForward);
362
1
        assert_eq!(e.buffer(), "world foo");
363
1
    }
364

            
365
    #[test]
366
1
    fn vim_capital_a_appends_at_eol_in_insert_mode() {
367
1
        let mut e = Editor::with_buffer(EditMode::Vim, "ab");
368
1
        e.enter_normal_mode();
369
1
        e.vim_action(VimAction::InsertAtLineEnd);
370
1
        assert_eq!(e.cursor(), 2);
371
1
        assert_eq!(e.vim_mode(), VimMode::Insert);
372
1
        e.insert_char('c');
373
1
        assert_eq!(e.buffer(), "abc");
374
1
    }
375

            
376
    #[test]
377
1
    fn switching_mode_resets_to_insert() {
378
1
        let mut e = Editor::new(EditMode::Vim);
379
1
        e.enter_normal_mode();
380
1
        e.set_mode(EditMode::Emacs);
381
1
        assert_eq!(e.vim_mode(), VimMode::Insert);
382
1
    }
383
}