Skip to main content

photon_ui/components/
input.rs

1use std::cell::Cell;
2
3use crossterm::event::KeyCode;
4use unicode_segmentation::UnicodeSegmentation;
5use unicode_width::UnicodeWidthStr;
6
7use crate::{
8    Component,
9    Event,
10    Focusable,
11    InputResult,
12    RenderError,
13    Rendered,
14    kill_ring::KillRing,
15    undo_stack::UndoStack,
16};
17
18/// Snapshot of input state for undo/redo.
19#[derive(Clone)]
20pub struct EditAction {
21    /// The full text buffer at the time of the snapshot.
22    pub text: String,
23    /// Cursor position in grapheme indices.
24    pub cursor: usize,
25}
26
27/// Vim editing mode state for single-line input.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum InputVimMode {
30    /// Normal mode — keys are commands (hl, x, i, etc.).
31    Normal,
32    /// Insert mode — keys insert text.
33    Insert,
34}
35
36/// Single-line text input with horizontal scrolling.
37///
38/// Defaults to **Emacs-style** bindings (Ctrl+A, Ctrl+E, Ctrl+K, etc.).
39/// Call [`set_vim_mode_enabled`](Input::set_vim_mode_enabled) to opt into
40/// vim-style modal editing (Normal/Insert mode with hl, x, i, etc.).
41///
42/// The input scrolls horizontally when the text exceeds the render width so
43/// that the cursor always remains visible. Supports undo, yank, and kill-ring.
44pub struct Input {
45    text: String,
46    cursor: usize,
47    focused: bool,
48    kill_ring: KillRing,
49    undo_stack: UndoStack<EditAction>,
50    scroll: Cell<usize>,
51    vim_mode_enabled: bool,
52    mode: InputVimMode,
53}
54
55impl Input {
56    /// Create a new empty input field with Emacs-style bindings.
57    pub fn new() -> Self {
58        Self {
59            text: String::new(),
60            cursor: 0,
61            focused: false,
62            kill_ring: KillRing::new(),
63            undo_stack: UndoStack::new(),
64            scroll: Cell::new(0),
65            vim_mode_enabled: false,
66            mode: InputVimMode::Normal,
67        }
68    }
69
70    /// Returns `true` if vim modal editing is enabled.
71    pub fn vim_mode_enabled(&self) -> bool {
72        self.vim_mode_enabled
73    }
74
75    /// Enable or disable vim modal editing.
76    ///
77    /// When enabled the input starts in Normal mode; press `i` to insert,
78    /// `Escape` to return to Normal. When disabled, Emacs-style bindings
79    /// are always active.
80    pub fn set_vim_mode_enabled(&mut self, enabled: bool) {
81        self.vim_mode_enabled = enabled;
82        self.mode = InputVimMode::Normal;
83    }
84
85    /// Current vim mode (only meaningful when vim mode is enabled).
86    pub fn mode(&self) -> InputVimMode {
87        self.mode
88    }
89
90    /// Switch to the given vim mode.
91    pub fn set_mode(&mut self, mode: InputVimMode) {
92        self.mode = mode;
93    }
94
95    /// Borrow the current text content.
96    pub fn text(&self) -> &str {
97        &self.text
98    }
99
100    /// Current cursor position in grapheme indices.
101    pub fn cursor(&self) -> usize {
102        self.cursor
103    }
104
105    /// Current horizontal scroll offset in grapheme indices.
106    pub fn scroll(&self) -> usize {
107        self.scroll.get()
108    }
109
110    /// Replace the entire text buffer and move the cursor to the end.
111    pub fn set_text(&mut self, text: impl Into<String>) {
112        self.save_undo();
113        self.text = text.into();
114        self.cursor = self.graphemes().len();
115        self.scroll.set(0);
116    }
117
118    fn save_undo(&mut self) {
119        self.undo_stack.push(EditAction {
120            text: self.text.clone(),
121            cursor: self.cursor,
122        });
123    }
124
125    fn graphemes(&self) -> Vec<&str> {
126        self.text.graphemes(true).collect()
127    }
128
129    fn byte_index(&self, grapheme_idx: usize) -> usize {
130        self.text
131            .grapheme_indices(true)
132            .nth(grapheme_idx)
133            .map(|(i, _)| i)
134            .unwrap_or(self.text.len())
135    }
136
137    fn insert_char(&mut self, c: char) {
138        let idx = self.byte_index(self.cursor);
139        self.text.insert(idx, c);
140        self.cursor += 1;
141    }
142
143    fn delete_backward(&mut self) {
144        if self.cursor > 0 {
145            let start = self.byte_index(self.cursor - 1);
146            let end = self.byte_index(self.cursor);
147            let killed = self.text.drain(start..end).collect::<String>();
148            self.kill_ring.push(killed);
149            self.cursor -= 1;
150        }
151    }
152
153    fn delete_forward(&mut self) {
154        if self.cursor < self.graphemes().len() {
155            let start = self.byte_index(self.cursor);
156            let end = self.byte_index(self.cursor + 1);
157            self.text.drain(start..end);
158        }
159    }
160
161    fn move_cursor_left(&mut self) {
162        if self.cursor > 0 {
163            self.cursor -= 1;
164        }
165    }
166
167    fn move_cursor_right(&mut self) {
168        if self.cursor < self.graphemes().len() {
169            self.cursor += 1;
170        }
171    }
172
173    fn move_cursor_home(&mut self) {
174        self.cursor = 0;
175    }
176
177    fn move_cursor_end(&mut self) {
178        self.cursor = self.graphemes().len();
179    }
180
181    fn yank(&mut self) {
182        if let Some(text) = self.kill_ring.yank() {
183            let idx = self.byte_index(self.cursor);
184            self.text.insert_str(idx, text);
185            self.cursor += text.graphemes(true).count();
186        }
187    }
188
189    fn undo(&mut self) {
190        if let Some(action) = self.undo_stack.undo() {
191            self.text = action.text.clone();
192            self.cursor = action.cursor;
193        }
194    }
195}
196
197impl Default for Input {
198    fn default() -> Self {
199        Self::new()
200    }
201}
202
203impl Focusable for Input {
204    fn focused(&self) -> bool {
205        self.focused
206    }
207
208    fn set_focused(&mut self, focused: bool) {
209        self.focused = focused;
210    }
211}
212
213impl Input {
214    fn handle_insert_mode(&mut self, key: &crossterm::event::KeyEvent) -> InputResult {
215        use crossterm::event::KeyModifiers;
216        self.save_undo();
217        match key.code {
218            | KeyCode::Char(c) => {
219                if key.modifiers.contains(KeyModifiers::CONTROL) {
220                    match c {
221                        | 'a' => self.move_cursor_home(),
222                        | 'e' => self.move_cursor_end(),
223                        | 'b' => self.move_cursor_left(),
224                        | 'f' => self.move_cursor_right(),
225                        | 'd' => self.delete_forward(),
226                        | 'h' => self.delete_backward(),
227                        | 'k' => {
228                            let idx = self.byte_index(self.cursor);
229                            let killed = self.text.split_off(idx);
230                            self.kill_ring.push(killed);
231                        },
232                        | 'y' => self.yank(),
233                        | '-' | '_' => self.undo(),
234                        | _ => return InputResult::Ignored,
235                    }
236                } else {
237                    self.insert_char(c);
238                }
239                InputResult::Handled
240            },
241            | KeyCode::Left => {
242                self.move_cursor_left();
243                InputResult::Handled
244            },
245            | KeyCode::Right => {
246                self.move_cursor_right();
247                InputResult::Handled
248            },
249            | KeyCode::Home => {
250                self.move_cursor_home();
251                InputResult::Handled
252            },
253            | KeyCode::End => {
254                self.move_cursor_end();
255                InputResult::Handled
256            },
257            | KeyCode::Backspace => {
258                self.delete_backward();
259                InputResult::Handled
260            },
261            | KeyCode::Delete => {
262                self.delete_forward();
263                InputResult::Handled
264            },
265            | KeyCode::Esc => {
266                self.mode = InputVimMode::Normal;
267                InputResult::Handled
268            },
269            | _ => InputResult::Ignored,
270        }
271    }
272
273    fn handle_normal_mode(&mut self, key: &crossterm::event::KeyEvent) -> InputResult {
274        match key.code {
275            | KeyCode::Char(c) => {
276                match c {
277                    | 'h' => self.move_cursor_left(),
278                    | 'l' => self.move_cursor_right(),
279                    | 'x' => {
280                        self.save_undo();
281                        self.delete_forward();
282                    },
283                    | '0' => self.move_cursor_home(),
284                    | '$' => self.move_cursor_end(),
285                    | 'i' => self.mode = InputVimMode::Insert,
286                    | 'a' => {
287                        self.move_cursor_right();
288                        self.mode = InputVimMode::Insert;
289                    },
290                    | 'p' => {
291                        self.save_undo();
292                        self.yank();
293                    },
294                    | 'u' => {
295                        self.save_undo();
296                        self.undo();
297                    },
298                    | _ => return InputResult::Ignored,
299                }
300                InputResult::Handled
301            },
302            | KeyCode::Left => {
303                self.move_cursor_left();
304                InputResult::Handled
305            },
306            | KeyCode::Right => {
307                self.move_cursor_right();
308                InputResult::Handled
309            },
310            | KeyCode::Home => {
311                self.move_cursor_home();
312                InputResult::Handled
313            },
314            | KeyCode::End => {
315                self.move_cursor_end();
316                InputResult::Handled
317            },
318            | KeyCode::Backspace => {
319                self.move_cursor_left();
320                InputResult::Handled
321            },
322            | _ => InputResult::Ignored,
323        }
324    }
325}
326
327impl Component for Input {
328    fn render(&self, width: u16) -> Result<Rendered, RenderError> {
329        let w = width as usize;
330        let graphemes = self.graphemes();
331
332        // Compute cumulative visible widths for each grapheme boundary.
333        let mut cum_vw = vec![0usize; graphemes.len() + 1];
334        for (i, g) in graphemes.iter().enumerate() {
335            cum_vw[i + 1] = cum_vw[i] + g.width();
336        }
337
338        let cursor_vw = cum_vw[self.cursor.min(graphemes.len())];
339        let mut scroll = self.scroll.get().min(graphemes.len());
340
341        // Adjust scroll so the cursor remains visible.
342        let cursor_screen_vw = cursor_vw.saturating_sub(cum_vw[scroll]);
343        if cursor_screen_vw > w.saturating_sub(1) {
344            let target = cursor_vw.saturating_sub(w.saturating_sub(1));
345            scroll = cum_vw.partition_point(|&v| v < target);
346            scroll = scroll.min(graphemes.len());
347        } else if self.cursor < scroll {
348            scroll = self.cursor;
349        }
350
351        self.scroll.set(scroll);
352
353        // Build the visible line by accumulating graphemes until width is reached.
354        let mut line = String::new();
355        let mut display_vw = 0;
356        for g in graphemes.iter().skip(scroll) {
357            let gw = g.width();
358            if display_vw + gw > w {
359                break;
360            }
361            line.push_str(g);
362            display_vw += gw;
363        }
364        if display_vw < w {
365            line.push_str(&" ".repeat(w - display_vw));
366        }
367
368        let cursor_col = cursor_vw.saturating_sub(cum_vw[scroll]);
369        Ok(Rendered {
370            lines: vec![line],
371            cursor: if self.focused {
372                Some((0, cursor_col))
373            } else {
374                None
375            },
376            images: Vec::new(),
377        })
378    }
379
380    fn handle_input(&mut self, event: &Event) -> InputResult {
381        if let Event::Key(key) = event {
382            if self.vim_mode_enabled {
383                match self.mode {
384                    | InputVimMode::Insert => self.handle_insert_mode(key),
385                    | InputVimMode::Normal => self.handle_normal_mode(key),
386                }
387            } else {
388                self.handle_insert_mode(key)
389            }
390        } else {
391            InputResult::Ignored
392        }
393    }
394
395    fn as_focusable(&self) -> Option<&dyn Focusable> {
396        Some(self)
397    }
398
399    fn as_focusable_mut(&mut self) -> Option<&mut dyn Focusable> {
400        Some(self)
401    }
402}
403
404#[cfg(test)]
405mod tests {
406    use crossterm::event::{
407        KeyCode,
408        KeyEvent,
409        KeyModifiers,
410    };
411
412    use super::*;
413
414    fn key_event(code: KeyCode) -> Event {
415        Event::Key(code.into())
416    }
417
418    fn ctrl_event(c: char) -> Event {
419        Event::Key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL))
420    }
421
422    #[test]
423    fn input_delete_forward_at_end() {
424        let mut input = Input::new();
425        input.insert_char('a');
426        input.move_cursor_end();
427        input.delete_forward();
428        assert_eq!(input.text(), "a");
429    }
430
431    #[test]
432    fn input_scrolls_when_long() {
433        let mut input = Input::new();
434        input.set_focused(true);
435        input.set_mode(InputVimMode::Insert);
436        for _ in 0..20 {
437            input.handle_input(&key_event(KeyCode::Char('x')));
438        }
439        let r = input.render(10).unwrap();
440        assert_eq!(r.lines[0].len(), 10);
441        assert!(r.cursor.is_some());
442        assert_eq!(input.scroll(), 11);
443        assert_eq!(r.cursor, Some((0, 9)));
444    }
445
446    #[test]
447    fn input_scrolls_back_left() {
448        let mut input = Input::new();
449        input.set_focused(true);
450        input.set_mode(InputVimMode::Insert);
451        for _ in 0..20 {
452            input.handle_input(&key_event(KeyCode::Char('x')));
453        }
454        input.render(10).unwrap();
455        assert_eq!(input.scroll(), 11);
456        // Move cursor to the start (Ctrl+A in insert mode)
457        input.handle_input(&ctrl_event('a'));
458        let r = input.render(10).unwrap();
459        assert_eq!(input.scroll(), 0);
460        assert_eq!(r.cursor, Some((0, 0)));
461    }
462
463    #[test]
464    fn input_render_unfocused() {
465        let mut input = Input::new();
466        input.set_focused(false);
467        input.insert_char('a');
468        let r = input.render(10).unwrap();
469        assert!(r.cursor.is_none());
470    }
471
472    #[test]
473    fn input_yank() {
474        let mut input = Input::new();
475        input.set_mode(InputVimMode::Insert);
476        input.insert_char('a');
477        input.insert_char('b');
478        input.move_cursor_home();
479        input.handle_input(&ctrl_event('k'));
480        assert_eq!(input.text(), "");
481        input.yank();
482        assert_eq!(input.text(), "ab");
483    }
484
485    #[test]
486    fn input_ctrl_d_delete() {
487        let mut input = Input::new();
488        input.set_mode(InputVimMode::Insert);
489        input.insert_char('a');
490        input.insert_char('b');
491        input.move_cursor_home();
492        input.handle_input(&ctrl_event('d'));
493        assert_eq!(input.text(), "b");
494    }
495
496    #[test]
497    fn input_ignored_ctrl_key() {
498        let mut input = Input::new();
499        let result = input.handle_input(&ctrl_event('z'));
500        assert!(matches!(result, InputResult::Ignored));
501    }
502
503    #[test]
504    fn input_resize_ignored() {
505        let mut input = Input::new();
506        let result = input.handle_input(&Event::Resize(80, 24));
507        assert!(matches!(result, InputResult::Ignored));
508    }
509
510    #[test]
511    fn input_delete_backward_at_start() {
512        let mut input = Input::new();
513        input.delete_backward();
514        assert_eq!(input.text(), "");
515    }
516
517    #[test]
518    fn input_move_past_bounds() {
519        let mut input = Input::new();
520        input.move_cursor_left();
521        assert_eq!(input.cursor(), 0);
522        input.insert_char('a');
523        input.move_cursor_right();
524        input.move_cursor_right();
525        assert_eq!(input.cursor(), 1);
526    }
527
528    #[test]
529    fn input_enter_ignored() {
530        let mut input = Input::new();
531        let result = input.handle_input(&key_event(KeyCode::Enter));
532        assert!(matches!(result, InputResult::Ignored));
533    }
534
535    #[test]
536    fn input_tab_ignored() {
537        let mut input = Input::new();
538        let result = input.handle_input(&key_event(KeyCode::Tab));
539        assert!(matches!(result, InputResult::Ignored));
540    }
541
542    #[test]
543    fn input_render_pads_with_spaces() {
544        let mut input = Input::new();
545        input.set_focused(true);
546        input.insert_char('a');
547        let r = input.render(10).unwrap();
548        assert_eq!(r.lines[0].len(), 10);
549    }
550
551    // -- Vim mode tests --
552
553    #[test]
554    fn vim_input_starts_in_normal_mode() {
555        let mut input = Input::new();
556        input.set_vim_mode_enabled(true);
557        assert_eq!(input.mode(), InputVimMode::Normal);
558    }
559
560    #[test]
561    fn vim_input_hl_navigation() {
562        let mut input = Input::new();
563        input.set_vim_mode_enabled(true);
564        input.set_mode(InputVimMode::Insert);
565        input.insert_char('a');
566        input.insert_char('b');
567        input.set_mode(InputVimMode::Normal);
568        input.handle_input(&Event::Key(KeyEvent::new(
569            KeyCode::Char('h'),
570            KeyModifiers::empty(),
571        )));
572        assert_eq!(input.cursor(), 1);
573        input.handle_input(&Event::Key(KeyEvent::new(
574            KeyCode::Char('l'),
575            KeyModifiers::empty(),
576        )));
577        assert_eq!(input.cursor(), 2);
578    }
579
580    #[test]
581    fn vim_input_i_enters_insert() {
582        let mut input = Input::new();
583        input.set_vim_mode_enabled(true);
584        input.handle_input(&Event::Key(KeyEvent::new(
585            KeyCode::Char('i'),
586            KeyModifiers::empty(),
587        )));
588        assert_eq!(input.mode(), InputVimMode::Insert);
589        input.handle_input(&key_event(KeyCode::Char('x')));
590        assert_eq!(input.text(), "x");
591    }
592
593    #[test]
594    fn vim_input_esc_returns_to_normal() {
595        let mut input = Input::new();
596        input.set_vim_mode_enabled(true);
597        input.set_mode(InputVimMode::Insert);
598        input.handle_input(&key_event(KeyCode::Esc));
599        assert_eq!(input.mode(), InputVimMode::Normal);
600    }
601
602    #[test]
603    fn vim_input_x_deletes() {
604        let mut input = Input::new();
605        input.set_vim_mode_enabled(true);
606        input.set_mode(InputVimMode::Insert);
607        input.insert_char('a');
608        input.insert_char('b');
609        input.set_mode(InputVimMode::Normal);
610        input.move_cursor_home();
611        input.handle_input(&Event::Key(KeyEvent::new(
612            KeyCode::Char('x'),
613            KeyModifiers::empty(),
614        )));
615        assert_eq!(input.text(), "b");
616    }
617
618    #[test]
619    fn vim_input_a_appends() {
620        let mut input = Input::new();
621        input.set_vim_mode_enabled(true);
622        input.set_mode(InputVimMode::Insert);
623        input.insert_char('a');
624        input.set_mode(InputVimMode::Normal);
625        input.move_cursor_home();
626        input.handle_input(&Event::Key(KeyEvent::new(
627            KeyCode::Char('a'),
628            KeyModifiers::empty(),
629        )));
630        assert_eq!(input.mode(), InputVimMode::Insert);
631        input.handle_input(&key_event(KeyCode::Char('b')));
632        assert_eq!(input.text(), "ab");
633    }
634
635    #[test]
636    fn vim_input_0_and_dollar() {
637        let mut input = Input::new();
638        input.set_vim_mode_enabled(true);
639        input.set_mode(InputVimMode::Insert);
640        input.insert_char('a');
641        input.insert_char('b');
642        input.set_mode(InputVimMode::Normal);
643        input.move_cursor_end();
644        input.handle_input(&Event::Key(KeyEvent::new(
645            KeyCode::Char('0'),
646            KeyModifiers::empty(),
647        )));
648        assert_eq!(input.cursor(), 0);
649        input.handle_input(&Event::Key(KeyEvent::new(
650            KeyCode::Char('$'),
651            KeyModifiers::empty(),
652        )));
653        assert_eq!(input.cursor(), 2);
654    }
655
656    #[test]
657    fn vim_input_p_paste() {
658        let mut input = Input::new();
659        input.set_vim_mode_enabled(true);
660        input.set_mode(InputVimMode::Insert);
661        input.insert_char('a');
662        input.insert_char('b');
663        input.move_cursor_home();
664        input.handle_input(&ctrl_event('k'));
665        assert_eq!(input.text(), "");
666        input.set_mode(InputVimMode::Normal);
667        input.handle_input(&Event::Key(KeyEvent::new(
668            KeyCode::Char('p'),
669            KeyModifiers::empty(),
670        )));
671        assert_eq!(input.text(), "ab");
672    }
673
674    #[test]
675    fn vim_input_u_undo() {
676        let mut input = Input::new();
677        input.set_vim_mode_enabled(true);
678        input.set_mode(InputVimMode::Insert);
679        input.handle_input(&key_event(KeyCode::Char('a')));
680        input.handle_input(&key_event(KeyCode::Char('b')));
681        input.set_mode(InputVimMode::Normal);
682        input.handle_input(&Event::Key(KeyEvent::new(
683            KeyCode::Char('u'),
684            KeyModifiers::empty(),
685        )));
686        assert_eq!(input.text(), "a");
687    }
688
689    /// Regression: Input must not exceed its allocated width when the text
690    /// contains wide characters (e.g. CJK). Taking `w` graphemes can produce
691    /// a visible width up to `2*w` if each grapheme is 2 columns wide.
692    #[test]
693    fn input_respects_width_with_wide_chars() {
694        let mut input = Input::new();
695        input.set_text("中文测试");
696        let rendered = input.render(4).unwrap();
697        let vw = crate::utils::visible_width(&rendered.lines[0]);
698        assert!(
699            vw <= 4,
700            "input line exceeds width 4 (actual {}): {:?}",
701            vw,
702            rendered.lines[0]
703        );
704    }
705}