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