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
56
    pub fn new(mode: EditMode) -> Self {
33
56
        Self {
34
56
            buffer: String::new(),
35
56
            cursor: 0,
36
56
            mode,
37
56
            vim: VimMode::Insert,
38
56
        }
39
56
    }
40

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

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

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

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

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

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

            
78
81
    fn chars(&self) -> Vec<char> {
79
81
        self.buffer.chars().collect()
80
81
    }
81

            
82
66
    fn rebuild(&mut self, chars: &[char]) {
83
66
        self.buffer = chars.iter().collect();
84
66
        if self.cursor > chars.len() {
85
            self.cursor = chars.len();
86
66
        }
87
66
    }
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
64
    pub fn insert_char(&mut self, c: char) {
92
64
        if self.mode == EditMode::Vim && self.vim == VimMode::Normal {
93
1
            return;
94
63
        }
95
63
        let mut chars = self.chars();
96
63
        chars.insert(self.cursor, c);
97
63
        self.cursor += 1;
98
63
        self.rebuild(&chars);
99
64
    }
100

            
101
1
    pub fn delete_backward(&mut self) {
102
1
        if self.cursor == 0 {
103
1
            return;
104
        }
105
        let mut chars = self.chars();
106
        chars.remove(self.cursor - 1);
107
        self.cursor -= 1;
108
        self.rebuild(&chars);
109
1
    }
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
8
    pub fn move_right(&mut self) {
126
8
        let len = self.chars().len();
127
8
        if self.cursor < len {
128
5
            self.cursor += 1;
129
5
        }
130
8
    }
131

            
132
3
    pub fn move_home(&mut self) {
133
3
        self.cursor = 0;
134
3
    }
135

            
136
3
    pub fn move_end(&mut self) {
137
3
        self.cursor = self.chars().len();
138
3
    }
139

            
140
    /// Delete from the cursor to end-of-line (emacs C-k).
141
1
    pub fn kill_to_end(&mut self) {
142
1
        let mut chars = self.chars();
143
1
        chars.truncate(self.cursor);
144
1
        self.rebuild(&chars);
145
1
    }
146

            
147
    /// Delete the word before the cursor (emacs C-w).
148
1
    pub fn kill_word_backward(&mut self) {
149
1
        let mut chars = self.chars();
150
1
        let mut i = self.cursor;
151
1
        while i > 0 && chars[i - 1].is_whitespace() {
152
            i -= 1;
153
        }
154
6
        while i > 0 && !chars[i - 1].is_whitespace() {
155
5
            i -= 1;
156
5
        }
157
1
        chars.drain(i..self.cursor);
158
1
        self.cursor = i;
159
1
        self.rebuild(&chars);
160
1
    }
161

            
162
2
    pub fn enter_insert_mode(&mut self) {
163
2
        self.vim = VimMode::Insert;
164
2
    }
165

            
166
10
    pub fn enter_normal_mode(&mut self) {
167
10
        self.vim = VimMode::Normal;
168
10
    }
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
7
    pub fn vim_action(&mut self, action: VimAction) {
175
7
        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
2
            VimAction::WordForward => self.vim_word_forward(),
181
2
            VimAction::WordBackward => self.vim_word_backward(),
182
            VimAction::DeleteChar => self.delete_forward(),
183
1
            VimAction::DeleteWordForward => self.vim_delete_word_forward(),
184
            VimAction::DeleteWordBackward => self.kill_word_backward(),
185
1
            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
1
            VimAction::InsertAtLineEnd => {
195
1
                self.move_end();
196
1
                self.enter_insert_mode();
197
1
            }
198
        }
199
7
    }
200

            
201
2
    fn vim_word_forward(&mut self) {
202
2
        let chars = self.chars();
203
2
        let mut i = self.cursor;
204
12
        while i < chars.len() && !chars[i].is_whitespace() {
205
10
            i += 1;
206
10
        }
207
4
        while i < chars.len() && chars[i].is_whitespace() {
208
2
            i += 1;
209
2
        }
210
2
        self.cursor = i;
211
2
    }
212

            
213
2
    fn vim_word_backward(&mut self) {
214
2
        let chars = self.chars();
215
2
        let mut i = self.cursor;
216
3
        while i > 0 && chars[i - 1].is_whitespace() {
217
1
            i -= 1;
218
1
        }
219
10
        while i > 0 && !chars[i - 1].is_whitespace() {
220
8
            i -= 1;
221
8
        }
222
2
        self.cursor = i;
223
2
    }
224

            
225
1
    fn vim_delete_word_forward(&mut self) {
226
1
        let mut chars = self.chars();
227
1
        let start = self.cursor;
228
1
        let mut i = start;
229
6
        while i < chars.len() && !chars[i].is_whitespace() {
230
5
            i += 1;
231
5
        }
232
2
        while i < chars.len() && chars[i].is_whitespace() {
233
1
            i += 1;
234
1
        }
235
1
        chars.drain(start..i);
236
1
        self.rebuild(&chars);
237
1
    }
238
}
239

            
240
/// Named vim-mode actions the event layer can invoke.
241
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
242
pub 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)]
259
mod tests {
260
    use super::*;
261

            
262
    #[test]
263
1
    fn emacs_insert_appends_at_cursor() {
264
1
        let mut e = Editor::new(EditMode::Emacs);
265
1
        e.insert_char('a');
266
1
        e.insert_char('b');
267
1
        e.insert_char('c');
268
1
        assert_eq!(e.buffer(), "abc");
269
1
        assert_eq!(e.cursor(), 3);
270
1
    }
271

            
272
    #[test]
273
1
    fn emacs_kill_to_end_truncates() {
274
1
        let mut e = Editor::with_buffer(EditMode::Emacs, "hello world");
275
1
        e.move_home();
276
5
        for _ in 0..5 {
277
5
            e.move_right();
278
5
        }
279
1
        e.kill_to_end();
280
1
        assert_eq!(e.buffer(), "hello");
281
1
        assert_eq!(e.cursor(), 5);
282
1
    }
283

            
284
    #[test]
285
1
    fn emacs_kill_word_backward_drops_word_and_spaces() {
286
1
        let mut e = Editor::with_buffer(EditMode::Emacs, "hello world");
287
1
        e.move_end();
288
1
        e.kill_word_backward();
289
1
        assert_eq!(e.buffer(), "hello ");
290
1
    }
291

            
292
    #[test]
293
1
    fn delete_backward_at_start_is_noop() {
294
1
        let mut e = Editor::new(EditMode::Emacs);
295
1
        e.delete_backward();
296
1
        assert_eq!(e.buffer(), "");
297
1
        assert_eq!(e.cursor(), 0);
298
1
    }
299

            
300
    #[test]
301
1
    fn move_right_saturates_at_end() {
302
1
        let mut e = Editor::with_buffer(EditMode::Emacs, "ab");
303
1
        e.move_right();
304
1
        e.move_right();
305
1
        e.move_right();
306
1
        assert_eq!(e.cursor(), 2);
307
1
    }
308

            
309
    #[test]
310
1
    fn vim_normal_mode_ignores_insert_char() {
311
1
        let mut e = Editor::new(EditMode::Vim);
312
1
        e.enter_normal_mode();
313
1
        e.insert_char('a');
314
1
        assert_eq!(e.buffer(), "");
315
1
    }
316

            
317
    #[test]
318
1
    fn vim_i_enters_insert_mode() {
319
1
        let mut e = Editor::new(EditMode::Vim);
320
1
        e.enter_normal_mode();
321
1
        e.vim_action(VimAction::InsertAtCursor);
322
1
        assert_eq!(e.vim_mode(), VimMode::Insert);
323
1
        e.insert_char('x');
324
1
        assert_eq!(e.buffer(), "x");
325
1
    }
326

            
327
    #[test]
328
1
    fn vim_word_forward_skips_to_next_token() {
329
1
        let mut e = Editor::with_buffer(EditMode::Vim, "hello world foo");
330
1
        e.enter_normal_mode();
331
1
        e.move_home();
332
1
        e.vim_action(VimAction::WordForward);
333
1
        assert_eq!(e.cursor(), 6);
334
1
        e.vim_action(VimAction::WordForward);
335
1
        assert_eq!(e.cursor(), 12);
336
1
    }
337

            
338
    #[test]
339
1
    fn vim_word_backward_reverses_word_forward() {
340
1
        let mut e = Editor::with_buffer(EditMode::Vim, "hello world foo");
341
1
        e.enter_normal_mode();
342
1
        e.move_end();
343
1
        e.vim_action(VimAction::WordBackward);
344
1
        assert_eq!(e.cursor(), 12);
345
1
        e.vim_action(VimAction::WordBackward);
346
1
        assert_eq!(e.cursor(), 6);
347
1
    }
348

            
349
    #[test]
350
1
    fn vim_dw_deletes_word_forward() {
351
1
        let mut e = Editor::with_buffer(EditMode::Vim, "hello world foo");
352
1
        e.enter_normal_mode();
353
1
        e.move_home();
354
1
        e.vim_action(VimAction::DeleteWordForward);
355
1
        assert_eq!(e.buffer(), "world foo");
356
1
    }
357

            
358
    #[test]
359
1
    fn vim_capital_a_appends_at_eol_in_insert_mode() {
360
1
        let mut e = Editor::with_buffer(EditMode::Vim, "ab");
361
1
        e.enter_normal_mode();
362
1
        e.vim_action(VimAction::InsertAtLineEnd);
363
1
        assert_eq!(e.cursor(), 2);
364
1
        assert_eq!(e.vim_mode(), VimMode::Insert);
365
1
        e.insert_char('c');
366
1
        assert_eq!(e.buffer(), "abc");
367
1
    }
368

            
369
    #[test]
370
1
    fn switching_mode_resets_to_insert() {
371
1
        let mut e = Editor::new(EditMode::Vim);
372
1
        e.enter_normal_mode();
373
1
        e.set_mode(EditMode::Emacs);
374
1
        assert_eq!(e.vim_mode(), VimMode::Insert);
375
1
    }
376
}