Skip to main content

rab/tui/components/
input.rs

1#![allow(clippy::type_complexity)]
2
3use crate::tui::component::Component;
4use crate::tui::focusable::{CURSOR_MARKER, Focusable};
5use crate::tui::keybindings::{
6    ACTION_EDITOR_CURSOR_LEFT, ACTION_EDITOR_CURSOR_LINE_END, ACTION_EDITOR_CURSOR_LINE_START,
7    ACTION_EDITOR_CURSOR_RIGHT, ACTION_EDITOR_CURSOR_WORD_LEFT, ACTION_EDITOR_CURSOR_WORD_RIGHT,
8    ACTION_EDITOR_DELETE_CHAR_BACKWARD, ACTION_EDITOR_DELETE_CHAR_FORWARD,
9    ACTION_EDITOR_DELETE_TO_LINE_END, ACTION_EDITOR_DELETE_TO_LINE_START,
10    ACTION_EDITOR_DELETE_WORD_BACKWARD, ACTION_EDITOR_DELETE_WORD_FORWARD, ACTION_EDITOR_UNDO,
11    ACTION_EDITOR_YANK, ACTION_EDITOR_YANK_POP, ACTION_INPUT_SUBMIT, ACTION_SELECT_CANCEL,
12    get_keybindings,
13};
14use crate::tui::keys::key_event_to_string;
15use crate::tui::kill_ring::KillRing;
16use crate::tui::undo_stack::UndoStack;
17use crate::tui::util::{slice_by_column, visible_width};
18use crate::tui::word_nav::{find_word_backward, find_word_forward};
19use crossterm::event::KeyEvent;
20use unicode_segmentation::UnicodeSegmentation;
21
22/// Single-line text input component.
23///
24/// Supports Emacs-style cursor movement and kill ring operations,
25/// bracketed paste, and undo coalescing (pi fish-style).
26pub struct Input {
27    value: String,
28    cursor: usize,
29    prompt: String,
30    kill_ring: KillRing,
31    undo_stack: UndoStack<String>,
32    focused: bool,
33    on_submit: Option<Box<dyn FnMut(String)>>,
34    on_escape: Option<Box<dyn FnMut()>>,
35    on_change: Option<Box<dyn FnMut(&str)>>,
36
37    // Undo coalescing (pi fish-style)
38    last_action: Option<&'static str>,
39
40    // Bracketed paste buffering (reserved for future Event::Paste wiring)
41    #[allow(dead_code)]
42    paste_buffer: String,
43    #[allow(dead_code)]
44    is_in_paste: bool,
45}
46
47impl Input {
48    pub fn new() -> Self {
49        Self {
50            value: String::new(),
51            cursor: 0,
52            prompt: "> ".to_string(),
53            kill_ring: KillRing::new(),
54            undo_stack: UndoStack::new(),
55            focused: false,
56            on_submit: None,
57            on_escape: None,
58            on_change: None,
59            last_action: None,
60            paste_buffer: String::new(),
61            is_in_paste: false,
62        }
63    }
64
65    pub fn with_prompt(mut self, prompt: impl Into<String>) -> Self {
66        self.prompt = prompt.into();
67        self
68    }
69
70    pub fn get_value(&self) -> &str {
71        &self.value
72    }
73
74    pub fn set_value(&mut self, value: &str) {
75        self.last_action = None;
76        self.save_undo();
77        self.value = value.to_string();
78        self.cursor = self.value.len();
79        if let Some(ref mut cb) = self.on_change {
80            cb(&self.value);
81        }
82    }
83
84    pub fn set_on_submit(&mut self, cb: Box<dyn FnMut(String)>) {
85        self.on_submit = Some(cb);
86    }
87
88    pub fn set_on_escape(&mut self, cb: Box<dyn FnMut()>) {
89        self.on_escape = Some(cb);
90    }
91
92    pub fn set_on_change(&mut self, cb: Box<dyn FnMut(&str)>) {
93        self.on_change = Some(cb);
94    }
95
96    fn save_undo(&mut self) {
97        self.undo_stack.push(&self.value);
98    }
99
100    // ── Undo coalescing (pi fish-style) ──
101
102    fn maybe_push_undo(&mut self, char: &str) {
103        use crate::tui::util::is_whitespace_char;
104        // Consecutive word chars coalesce into one undo unit
105        // Space captures state before itself (so undo removes space + following word together)
106        if is_whitespace_char(char) || self.last_action != Some("type-word") {
107            self.save_undo();
108        }
109        self.last_action = Some("type-word");
110    }
111
112    // ── Text insertion ──
113
114    fn insert_text(&mut self, text: &str) {
115        self.maybe_push_undo(text);
116        self.value.insert_str(self.cursor, text);
117        self.cursor += text.len();
118    }
119
120    // ── Bracketed paste ──
121
122    #[allow(dead_code)]
123    fn handle_paste(&mut self, pasted_text: &str) {
124        self.last_action = None;
125        self.save_undo();
126
127        let clean = pasted_text.replace(['\r', '\n'], "").replace('\t', "    ");
128
129        self.value = format!(
130            "{}{}{}",
131            &self.value[..self.cursor],
132            clean,
133            &self.value[self.cursor..]
134        );
135        self.cursor += clean.len();
136    }
137
138    // ── Deletion ──
139
140    fn delete_before_cursor(&mut self) {
141        if self.cursor == 0 {
142            return;
143        }
144        self.last_action = None;
145        self.save_undo();
146        let graphemes: Vec<(usize, &str)> = self.value.grapheme_indices(true).collect();
147        for &(idx, g) in graphemes.iter().rev() {
148            if idx < self.cursor {
149                let end = idx + g.len();
150                if end <= self.cursor {
151                    self.value.drain(idx..end);
152                    self.cursor = idx;
153                    break;
154                }
155            }
156        }
157    }
158
159    fn delete_after_cursor(&mut self) {
160        if self.cursor >= self.value.len() {
161            return;
162        }
163        self.last_action = None;
164        self.save_undo();
165        let graphemes: Vec<(usize, &str)> = self.value.grapheme_indices(true).collect();
166        for &(idx, g) in &graphemes {
167            if idx >= self.cursor {
168                self.value.drain(idx..idx + g.len());
169                break;
170            }
171        }
172    }
173
174    // ── Cursor movement ──
175
176    fn move_cursor_left(&mut self) {
177        self.last_action = None;
178        if self.cursor == 0 {
179            return;
180        }
181        let graphemes: Vec<(usize, &str)> = self.value.grapheme_indices(true).collect();
182        for &(idx, g) in graphemes.iter().rev() {
183            if idx < self.cursor {
184                let end = idx + g.len();
185                if end <= self.cursor {
186                    self.cursor = idx;
187                    break;
188                }
189            }
190        }
191    }
192
193    fn move_cursor_right(&mut self) {
194        self.last_action = None;
195        if self.cursor >= self.value.len() {
196            return;
197        }
198        if let Some((idx, g)) = self.value[self.cursor..].grapheme_indices(true).next() {
199            self.cursor += idx + g.len();
200        }
201    }
202
203    fn move_to_start(&mut self) {
204        self.last_action = None;
205        self.cursor = 0;
206    }
207
208    fn move_to_end(&mut self) {
209        self.last_action = None;
210        self.cursor = self.value.len();
211    }
212
213    // ── Kill operations ──
214
215    fn kill_word_backward(&mut self) {
216        let new_cursor = find_word_backward(&self.value, self.cursor);
217        if new_cursor < self.cursor {
218            self.save_undo();
219            let killed = self.value[new_cursor..self.cursor].to_string();
220            let accumulate = self.last_action == Some("kill");
221            self.kill_ring.push(&killed, true, accumulate);
222            self.value.drain(new_cursor..self.cursor);
223            self.cursor = new_cursor;
224            self.last_action = Some("kill");
225        }
226    }
227
228    fn kill_word_forward(&mut self) {
229        let new_cursor = find_word_forward(&self.value, self.cursor);
230        if new_cursor > self.cursor {
231            self.save_undo();
232            let killed = self.value[self.cursor..new_cursor].to_string();
233            let accumulate = self.last_action == Some("kill");
234            self.kill_ring.push(&killed, false, accumulate);
235            self.value.drain(self.cursor..new_cursor);
236            self.last_action = Some("kill");
237        }
238    }
239
240    fn kill_to_start(&mut self) {
241        if self.cursor > 0 {
242            self.save_undo();
243            let killed = self.value[..self.cursor].to_string();
244            let accumulate = self.last_action == Some("kill");
245            self.kill_ring.push(&killed, true, accumulate);
246            self.value.drain(..self.cursor);
247            self.cursor = 0;
248            self.last_action = Some("kill");
249        }
250    }
251
252    fn kill_to_end(&mut self) {
253        if self.cursor < self.value.len() {
254            self.save_undo();
255            let killed = self.value[self.cursor..].to_string();
256            let accumulate = self.last_action == Some("kill");
257            self.kill_ring.push(&killed, false, accumulate);
258            self.value.truncate(self.cursor);
259            self.last_action = Some("kill");
260        }
261    }
262
263    // ── Yank ──
264
265    fn yank(&mut self) {
266        let text = self.kill_ring.peek().map(|s| s.to_string());
267        if let Some(text) = text {
268            self.save_undo();
269            self.cursor += text.len();
270            self.value.insert_str(self.cursor - text.len(), &text);
271        }
272        self.last_action = Some("yank");
273    }
274
275    fn yank_pop(&mut self) {
276        // Must follow yank() — save current state, delete previously yanked
277        // text, rotate ring, insert new entry. Matches pi's yankPop().
278        if self.kill_ring.len() <= 1 {
279            return;
280        }
281        let prev = self.kill_ring.peek().map(|s| s.to_string());
282        if let Some(ref prev_text) = prev {
283            self.save_undo();
284            if self.cursor >= prev_text.len() {
285                let before = self.value[..self.cursor - prev_text.len()].to_string();
286                let after = self.value[self.cursor..].to_string();
287                self.value = format!("{}{}", before, after);
288                self.cursor -= prev_text.len();
289            }
290        }
291        self.kill_ring.rotate();
292        let text = self.kill_ring.peek().map(|s| s.to_string());
293        if let Some(ref new_text) = text {
294            self.value.insert_str(self.cursor, new_text);
295            self.cursor += new_text.len();
296        }
297    }
298
299    fn undo(&mut self) {
300        if let Some(prev) = self.undo_stack.pop() {
301            self.value = prev;
302            self.cursor = self.value.len().min(self.cursor);
303            self.last_action = None;
304        }
305    }
306}
307
308impl Component for Input {
309    fn render(&self, width: usize) -> Vec<String> {
310        let prompt_width = visible_width(&self.prompt);
311        let avail = width.saturating_sub(prompt_width);
312
313        if avail == 0 {
314            return vec![self.prompt.clone()];
315        }
316
317        let total_width = visible_width(&self.value);
318        let cursor_text_width = visible_width(&self.value[..self.cursor]);
319
320        // Pi-style smart horizontal scroll: center cursor in half-width window
321        let scroll = if total_width < avail {
322            0
323        } else if self.cursor == self.value.len() {
324            // Cursor at end: show end of text
325            total_width.saturating_sub(avail).saturating_sub(1)
326        } else {
327            // Pi: center cursor in half-width window
328            let half = avail / 2;
329            if cursor_text_width < half {
330                0
331            } else if cursor_text_width > total_width.saturating_sub(half) {
332                total_width.saturating_sub(avail)
333            } else {
334                cursor_text_width.saturating_sub(half)
335            }
336        };
337
338        // Slice visible portion
339        let visible = slice_by_column(&self.value, scroll, avail);
340        let vis_width = visible_width(&visible);
341        let cursor_visible_pos = cursor_text_width.saturating_sub(scroll);
342
343        // Build the line with cursor highlighting
344        let mut line = self.prompt.clone();
345
346        if self.focused && cursor_visible_pos < vis_width {
347            let before = slice_by_column(&visible, 0, cursor_visible_pos);
348            let at_cursor = slice_by_column(&visible, cursor_visible_pos, 1);
349            let after = slice_by_column(&visible, cursor_visible_pos + 1, avail);
350
351            line.push_str(CURSOR_MARKER);
352            line.push_str(&before);
353            line.push_str("\x1b[7m");
354            if at_cursor.is_empty() {
355                line.push(' ');
356            } else {
357                line.push_str(&at_cursor);
358            }
359            line.push_str("\x1b[27m");
360            line.push_str(&after);
361        } else if self.focused && cursor_visible_pos >= vis_width && vis_width < avail {
362            line.push_str(CURSOR_MARKER);
363            line.push_str(&visible);
364            line.push_str("\x1b[7m \x1b[27m");
365        } else {
366            line.push_str(&visible);
367            if self.focused {
368                line.push_str(CURSOR_MARKER);
369            }
370        }
371
372        // Pad to width
373        let line_width = visible_width(&line);
374        if line_width < width {
375            line.push_str(&" ".repeat(width - line_width));
376        }
377
378        vec![line]
379    }
380
381    fn handle_input(&mut self, key: &KeyEvent) -> bool {
382        let kb = get_keybindings();
383
384        // Printable characters
385        if crate::tui::keys::is_printable(key)
386            && let Some(s) = key_event_to_string(key)
387        {
388            self.insert_text(&s);
389            if let Some(ref mut cb) = self.on_change {
390                cb(&self.value);
391            }
392            return true;
393        }
394
395        if kb.matches(key, ACTION_INPUT_SUBMIT) {
396            if let Some(ref mut cb) = self.on_submit {
397                let value = std::mem::take(&mut self.value);
398                self.cursor = 0;
399                self.last_action = None;
400                cb(value);
401            }
402            return true;
403        }
404
405        if kb.matches(key, ACTION_SELECT_CANCEL) {
406            if let Some(ref mut cb) = self.on_escape {
407                cb();
408            }
409            return true;
410        }
411
412        if kb.matches(key, ACTION_EDITOR_DELETE_CHAR_BACKWARD) {
413            self.delete_before_cursor();
414            if let Some(ref mut cb) = self.on_change {
415                cb(&self.value);
416            }
417            return true;
418        }
419
420        if kb.matches(key, ACTION_EDITOR_DELETE_CHAR_FORWARD) {
421            self.delete_after_cursor();
422            if let Some(ref mut cb) = self.on_change {
423                cb(&self.value);
424            }
425            return true;
426        }
427
428        if kb.matches(key, ACTION_EDITOR_CURSOR_LEFT) {
429            self.move_cursor_left();
430            return true;
431        }
432
433        if kb.matches(key, ACTION_EDITOR_CURSOR_RIGHT) {
434            self.move_cursor_right();
435            return true;
436        }
437
438        if kb.matches(key, ACTION_EDITOR_CURSOR_LINE_START) {
439            self.move_to_start();
440            return true;
441        }
442
443        if kb.matches(key, ACTION_EDITOR_CURSOR_LINE_END) {
444            self.move_to_end();
445            return true;
446        }
447
448        if kb.matches(key, ACTION_EDITOR_DELETE_WORD_BACKWARD) {
449            self.kill_word_backward();
450            if let Some(ref mut cb) = self.on_change {
451                cb(&self.value);
452            }
453            return true;
454        }
455
456        if kb.matches(key, ACTION_EDITOR_DELETE_TO_LINE_START) {
457            self.kill_to_start();
458            if let Some(ref mut cb) = self.on_change {
459                cb(&self.value);
460            }
461            return true;
462        }
463
464        if kb.matches(key, ACTION_EDITOR_DELETE_TO_LINE_END) {
465            self.kill_to_end();
466            if let Some(ref mut cb) = self.on_change {
467                cb(&self.value);
468            }
469            return true;
470        }
471
472        if kb.matches(key, ACTION_EDITOR_YANK) {
473            self.yank();
474            if let Some(ref mut cb) = self.on_change {
475                cb(&self.value);
476            }
477            return true;
478        }
479
480        if kb.matches(key, ACTION_EDITOR_YANK_POP) {
481            self.yank_pop();
482            if let Some(ref mut cb) = self.on_change {
483                cb(&self.value);
484            }
485            return true;
486        }
487
488        if kb.matches(key, ACTION_EDITOR_UNDO) {
489            self.undo();
490            if let Some(ref mut cb) = self.on_change {
491                cb(&self.value);
492            }
493            return true;
494        }
495
496        if kb.matches(key, ACTION_EDITOR_DELETE_WORD_FORWARD) {
497            self.kill_word_forward();
498            if let Some(ref mut cb) = self.on_change {
499                cb(&self.value);
500            }
501            return true;
502        }
503
504        if kb.matches(key, ACTION_EDITOR_CURSOR_WORD_LEFT) {
505            self.cursor = find_word_backward(&self.value, self.cursor);
506            return true;
507        }
508
509        if kb.matches(key, ACTION_EDITOR_CURSOR_WORD_RIGHT) {
510            self.cursor = find_word_forward(&self.value, self.cursor);
511            return true;
512        }
513
514        false
515    }
516
517    fn handle_paste(&mut self, text: &str) {
518        self.handle_paste(text);
519    }
520
521    fn is_focusable(&self) -> bool {
522        true
523    }
524}
525
526impl Focusable for Input {
527    fn set_focused(&mut self, focused: bool) {
528        self.focused = focused;
529    }
530
531    fn focused(&self) -> bool {
532        self.focused
533    }
534}
535
536impl Default for Input {
537    fn default() -> Self {
538        Self::new()
539    }
540}
541
542#[cfg(test)]
543mod tests {
544    use super::*;
545
546    #[test]
547    fn test_new_input_is_empty() {
548        let input = Input::new();
549        assert_eq!(input.get_value(), "");
550    }
551
552    #[test]
553    fn test_insert_text() {
554        let mut input = Input::new();
555        input.insert_text("hello");
556        assert_eq!(input.get_value(), "hello");
557        assert_eq!(input.cursor, 5);
558    }
559
560    #[test]
561    fn test_backspace() {
562        let mut input = Input::new();
563        input.insert_text("hello");
564        input.delete_before_cursor();
565        assert_eq!(input.get_value(), "hell");
566        assert_eq!(input.cursor, 4);
567    }
568
569    #[test]
570    fn test_move_cursor() {
571        let mut input = Input::new();
572        input.insert_text("hello");
573        input.move_cursor_left();
574        assert_eq!(input.cursor, 4);
575        input.move_cursor_right();
576        assert_eq!(input.cursor, 5);
577    }
578
579    #[test]
580    fn test_set_value() {
581        let mut input = Input::new();
582        input.set_value("test");
583        assert_eq!(input.get_value(), "test");
584        assert_eq!(input.cursor, 4);
585    }
586
587    #[test]
588    fn test_kill_to_end() {
589        let mut input = Input::new();
590        input.insert_text("hello world");
591        for _ in 0..6 {
592            input.move_cursor_left();
593        }
594        input.kill_to_end();
595        assert_eq!(input.get_value(), "hello");
596    }
597
598    #[test]
599    fn test_undo() {
600        let mut input = Input::new();
601        input.insert_text("hello");
602        input.undo();
603        assert_eq!(input.get_value(), "");
604    }
605
606    #[test]
607    fn test_render_basic() {
608        let mut input = Input::new();
609        input.set_value("test");
610        let lines = input.render(20);
611        assert_eq!(lines.len(), 1);
612        assert!(lines[0].contains("test"));
613    }
614
615    #[test]
616    fn test_undo_coalescing() {
617        let mut input = Input::new();
618        input.insert_text("h");
619        input.insert_text("e");
620        input.insert_text(" ");
621        input.insert_text("w");
622        assert_eq!(input.get_value(), "he w");
623        // Undo once reverts to before space ("he w" → "he").
624        input.undo();
625        assert_eq!(input.get_value(), "he");
626        // Undo again reverts to before everything ("he" → "")
627        input.undo();
628        assert_eq!(input.get_value(), "");
629    }
630
631    #[test]
632    fn test_paste_handling() {
633        let mut input = Input::new();
634        input.handle_paste("hello\nworld");
635        // Newlines should be stripped in paste
636        assert_eq!(input.get_value(), "helloworld");
637        assert_eq!(input.cursor, 10);
638    }
639}