Skip to main content

limit_cli/tui/input/
editor.rs

1//! Input text editor for TUI
2//!
3//! Manages input text buffer with cursor position and editing operations.
4
5use crate::tui::MAX_PASTE_SIZE;
6
7/// Input text editor with cursor management
8pub struct InputEditor {
9    /// Text buffer
10    text: String,
11    /// Cursor position (byte offset)
12    cursor: usize,
13}
14
15impl InputEditor {
16    /// Create a new empty editor
17    pub fn new() -> Self {
18        Self {
19            text: String::with_capacity(256),
20            cursor: 0,
21        }
22    }
23
24    /// Get the current text
25    #[inline]
26    pub fn text(&self) -> &str {
27        &self.text
28    }
29
30    /// Get a mutable reference to the text
31    #[inline]
32    pub fn text_mut(&mut self) -> &mut String {
33        &mut self.text
34    }
35
36    /// Get cursor position
37    #[inline]
38    pub fn cursor(&self) -> usize {
39        self.cursor
40    }
41
42    /// Set cursor position (clamped to valid range)
43    pub fn set_cursor(&mut self, pos: usize) {
44        self.cursor = pos.min(self.text.len());
45        // Ensure cursor is at char boundary
46        while self.cursor > 0 && !self.text.is_char_boundary(self.cursor) {
47            self.cursor -= 1;
48        }
49    }
50
51    /// Check if text is empty
52    #[inline]
53    pub fn is_empty(&self) -> bool {
54        self.text.is_empty()
55    }
56
57    /// Check if cursor is at start
58    #[inline]
59    pub fn is_cursor_at_start(&self) -> bool {
60        self.cursor == 0
61    }
62
63    /// Check if cursor is at end
64    #[inline]
65    pub fn is_cursor_at_end(&self) -> bool {
66        self.cursor == self.text.len()
67    }
68
69    /// Insert a character at cursor position
70    #[inline]
71    pub fn insert_char(&mut self, c: char) {
72        self.text.insert(self.cursor, c);
73        self.cursor += c.len_utf8();
74    }
75
76    /// Insert a string at cursor position
77    #[inline]
78    pub fn insert_str(&mut self, s: &str) {
79        self.text.insert_str(self.cursor, s);
80        self.cursor += s.len();
81    }
82
83    /// Insert paste with size limit
84    /// Returns true if truncated
85    pub fn insert_paste(&mut self, text: &str) -> bool {
86        let (text, truncated) = truncate_paste(text);
87
88        // Normalize newlines with pre-allocated capacity
89        let normalized = if text.contains('\r') {
90            let mut normalized = String::with_capacity(text.len());
91            for c in text.chars() {
92                normalized.push(if c == '\r' { '\n' } else { c });
93            }
94            normalized
95        } else {
96            return {
97                self.insert_str(text);
98                truncated
99            };
100        };
101
102        self.insert_str(&normalized);
103        truncated
104    }
105
106    /// Delete character before cursor (backspace)
107    pub fn delete_char_before(&mut self) -> bool {
108        if self.cursor == 0 {
109            return false;
110        }
111
112        let prev_pos = self.prev_char_pos();
113        self.text.drain(prev_pos..self.cursor);
114        self.cursor = prev_pos;
115        true
116    }
117
118    /// Delete character at cursor (delete key)
119    pub fn delete_char_at(&mut self) -> bool {
120        if self.cursor >= self.text.len() {
121            return false;
122        }
123
124        let next_pos = self.next_char_pos();
125        self.text.drain(self.cursor..next_pos);
126        true
127    }
128
129    /// Move cursor left one character
130    #[inline]
131    pub fn move_left(&mut self) {
132        if self.cursor > 0 {
133            self.cursor = self.prev_char_pos();
134        }
135    }
136
137    /// Move cursor right one character
138    #[inline]
139    pub fn move_right(&mut self) {
140        if self.cursor < self.text.len() {
141            self.cursor = self.next_char_pos();
142        }
143    }
144
145    /// Move cursor to start
146    #[inline]
147    pub fn move_to_start(&mut self) {
148        self.cursor = 0;
149    }
150
151    /// Move cursor to end
152    #[inline]
153    pub fn move_to_end(&mut self) {
154        self.cursor = self.text.len();
155    }
156
157    /// Clear all text
158    #[inline]
159    pub fn clear(&mut self) {
160        self.text.clear();
161        self.cursor = 0;
162    }
163
164    /// Get trimmed text and clear
165    pub fn take_trimmed(&mut self) -> String {
166        let trimmed = self.text.trim();
167        let result = String::from(trimmed);
168        self.clear();
169        result
170    }
171
172    /// Replace text in a range (used for autocomplete)
173    pub fn replace_range(&mut self, start: usize, end: usize, replacement: &str) {
174        self.text.drain(start..end);
175        self.text.insert_str(start, replacement);
176        self.cursor = start + replacement.len();
177    }
178
179    /// Delete from start to cursor
180    #[inline]
181    pub fn delete_range_to_cursor(&mut self, start: usize) {
182        if start < self.cursor {
183            self.text.drain(start..self.cursor);
184            self.cursor = start;
185        }
186    }
187
188    /// Get text before cursor
189    #[inline]
190    pub fn text_before_cursor(&self) -> &str {
191        &self.text[..self.cursor]
192    }
193
194    /// Get text after cursor
195    #[inline]
196    pub fn text_after_cursor(&self) -> &str {
197        &self.text[self.cursor..]
198    }
199
200    /// Get character at cursor (if any)
201    #[inline]
202    pub fn char_at_cursor(&self) -> Option<char> {
203        self.text[self.cursor..].chars().next()
204    }
205
206    /// Get character before cursor (if any)
207    pub fn char_before_cursor(&self) -> Option<char> {
208        if self.cursor == 0 {
209            return None;
210        }
211        let prev_pos = self.prev_char_pos();
212        self.text[prev_pos..self.cursor].chars().next()
213    }
214
215    /// Find previous char boundary
216    #[inline]
217    fn prev_char_pos(&self) -> usize {
218        if self.cursor == 0 {
219            return 0;
220        }
221        let mut pos = self.cursor - 1;
222        while pos > 0 && !self.text.is_char_boundary(pos) {
223            pos -= 1;
224        }
225        pos
226    }
227
228    /// Find next char boundary
229    #[inline]
230    fn next_char_pos(&self) -> usize {
231        if self.cursor >= self.text.len() {
232            return self.text.len();
233        }
234        let mut pos = self.cursor + 1;
235        while pos < self.text.len() && !self.text.is_char_boundary(pos) {
236            pos += 1;
237        }
238        pos
239    }
240}
241
242/// Truncate paste to max size (freestanding function for reuse)
243#[inline]
244fn truncate_paste(text: &str) -> (&str, bool) {
245    if text.len() <= MAX_PASTE_SIZE {
246        return (text, false);
247    }
248    
249    let truncated = &text[..text
250        .char_indices()
251        .nth(MAX_PASTE_SIZE)
252        .map(|(i, _)| i)
253        .unwrap_or(text.len())];
254    (truncated, true)
255}
256
257impl Default for InputEditor {
258    fn default() -> Self {
259        Self::new()
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    #[test]
268    fn test_editor_creation() {
269        let editor = InputEditor::new();
270        assert!(editor.is_empty());
271        assert_eq!(editor.cursor(), 0);
272    }
273
274    #[test]
275    fn test_insert_char() {
276        let mut editor = InputEditor::new();
277        editor.insert_char('h');
278        editor.insert_char('i');
279        assert_eq!(editor.text(), "hi");
280        assert_eq!(editor.cursor(), 2);
281    }
282
283    #[test]
284    fn test_delete_char_before() {
285        let mut editor = InputEditor::new();
286        editor.insert_str("hello");
287        editor.set_cursor(3);
288
289        assert!(editor.delete_char_before());
290        assert_eq!(editor.text(), "helo");
291        assert_eq!(editor.cursor(), 2);
292    }
293
294    #[test]
295    fn test_navigation() {
296        let mut editor = InputEditor::new();
297        editor.insert_str("hello");
298
299        editor.move_left();
300        assert_eq!(editor.cursor(), 4);
301
302        editor.move_to_start();
303        assert_eq!(editor.cursor(), 0);
304
305        editor.move_to_end();
306        assert_eq!(editor.cursor(), 5);
307    }
308
309    #[test]
310    fn test_utf8() {
311        let mut editor = InputEditor::new();
312        editor.insert_str("hΓ©llo");
313
314        let pos = editor.text().char_indices().nth(2).map(|(i, _)| i).unwrap();
315        editor.set_cursor(pos);
316
317        assert_eq!(editor.cursor(), pos);
318        assert_eq!(editor.char_before_cursor(), Some('Γ©'));
319    }
320
321    #[test]
322    fn test_take_trimmed() {
323        let mut editor = InputEditor::new();
324        editor.insert_str("  hello  ");
325        let text = editor.take_trimmed();
326        assert_eq!(text, "hello");
327        assert!(editor.is_empty());
328    }
329
330    #[test]
331    fn test_replace_range() {
332        let mut editor = InputEditor::new();
333        editor.insert_str("hello world");
334        editor.replace_range(6, 11, "universe");
335        assert_eq!(editor.text(), "hello universe");
336    }
337
338    #[test]
339    fn test_utf8_emojis() {
340        let mut editor = InputEditor::new();
341
342        editor.insert_str("Hello πŸ‘‹ World 🌍");
343        assert_eq!(editor.text(), "Hello πŸ‘‹ World 🌍");
344
345        editor.move_to_start();
346        editor.move_right();
347        editor.move_right();
348
349        editor.insert_char('πŸš€');
350        assert_eq!(editor.text(), "HeπŸš€llo πŸ‘‹ World 🌍");
351    }
352
353    #[test]
354    fn test_utf8_multibyte_chars() {
355        let mut editor = InputEditor::new();
356
357        editor.insert_str("ζ—₯本θͺž");
358        assert_eq!(editor.text(), "ζ—₯本θͺž");
359        assert_eq!(editor.cursor(), 9);
360
361        editor.set_cursor(6);
362        assert!(editor.delete_char_before());
363        assert_eq!(editor.text(), "ζ—₯θͺž");
364        assert_eq!(editor.cursor(), 3);
365    }
366
367    #[test]
368    fn test_paste_size_limit() {
369        let mut editor = InputEditor::new();
370
371        let large_text = "x".repeat(150 * 1024);
372        let truncated = editor.insert_paste(&large_text);
373
374        assert!(truncated, "Should indicate paste was truncated");
375        assert!(editor.text().len() <= MAX_PASTE_SIZE);
376    }
377
378    #[test]
379    fn test_paste_normal_size() {
380        let mut editor = InputEditor::new();
381
382        let text = "normal text";
383        let truncated = editor.insert_paste(text);
384
385        assert!(!truncated, "Should not truncate normal-sized paste");
386        assert_eq!(editor.text(), text);
387    }
388
389    #[test]
390    fn test_paste_newline_normalization() {
391        let mut editor = InputEditor::new();
392
393        editor.insert_paste("line1\r\nline2\r\n");
394        assert_eq!(editor.text(), "line1\n\nline2\n\n");
395    }
396
397    #[test]
398    fn test_navigation_empty_text() {
399        let mut editor = InputEditor::new();
400
401        editor.move_left();
402        assert_eq!(editor.cursor(), 0);
403
404        editor.move_right();
405        assert_eq!(editor.cursor(), 0);
406
407        editor.move_to_start();
408        assert_eq!(editor.cursor(), 0);
409
410        editor.move_to_end();
411        assert_eq!(editor.cursor(), 0);
412
413        assert!(!editor.delete_char_before());
414        assert!(!editor.delete_char_at());
415    }
416
417    #[test]
418    fn test_replace_range_invalid() {
419        let mut editor = InputEditor::new();
420        editor.insert_str("hello");
421
422        editor.replace_range(5, 5, " world");
423        assert_eq!(editor.text(), "hello world");
424
425        editor.replace_range(6, 11, "universe");
426        assert_eq!(editor.text(), "hello universe");
427    }
428
429    #[test]
430    fn test_replace_range_multibyte() {
431        let mut editor = InputEditor::new();
432        editor.insert_str("hello δΈ–η•Œ");
433
434        let world_start = editor.text().char_indices().nth(6).map(|(i, _)| i).unwrap();
435        editor.replace_range(world_start, editor.text().len(), "🌍");
436        assert_eq!(editor.text(), "hello 🌍");
437    }
438
439    #[test]
440    fn test_cursor_boundary_safety() {
441        let mut editor = InputEditor::new();
442        editor.insert_str("hΓ©llo");
443
444        editor.set_cursor(2);
445        assert_ne!(editor.cursor(), 2, "Cursor should not be in middle of char");
446        assert!(editor.text().is_char_boundary(editor.cursor()));
447    }
448
449    #[test]
450    fn test_char_at_cursor() {
451        let mut editor = InputEditor::new();
452        editor.insert_str("hello");
453
454        editor.set_cursor(0);
455        assert_eq!(editor.char_at_cursor(), Some('h'));
456
457        editor.set_cursor(5);
458        assert_eq!(editor.char_at_cursor(), None);
459
460        editor.clear();
461        assert_eq!(editor.char_at_cursor(), None);
462    }
463
464    #[test]
465    fn test_text_before_after_cursor() {
466        let mut editor = InputEditor::new();
467        editor.insert_str("hello world");
468        editor.set_cursor(5);
469
470        assert_eq!(editor.text_before_cursor(), "hello");
471        assert_eq!(editor.text_after_cursor(), " world");
472    }
473
474    #[test]
475    fn test_delete_range_to_cursor() {
476        let mut editor = InputEditor::new();
477        editor.insert_str("hello world");
478        editor.set_cursor(11);
479
480        editor.delete_range_to_cursor(6);
481        assert_eq!(editor.text(), "hello ");
482        assert_eq!(editor.cursor(), 6);
483    }
484}