Skip to main content

ratatui_interact/components/
input.rs

1//! Input component - Text input field with cursor
2//!
3//! Supports single-line text input with cursor movement, label display,
4//! focus styling, and click-to-focus.
5//!
6//! # Example
7//!
8//! ```rust
9//! use ratatui_interact::components::{Input, InputState, InputStyle};
10//!
11//! let mut state = InputState::new("Hello");
12//!
13//! // Cursor is at end by default
14//! assert_eq!(state.cursor_pos, 5);
15//!
16//! // Insert character
17//! state.insert_char('!');
18//! assert_eq!(state.text, "Hello!");
19//!
20//! // Move cursor
21//! state.move_left();
22//! state.insert_char(' ');
23//! assert_eq!(state.text, "Hello !");
24//! ```
25
26use ratatui::{
27    Frame,
28    layout::Rect,
29    style::{Color, Style},
30    text::{Line, Span},
31    widgets::{Block, Borders, Paragraph},
32};
33
34use crate::traits::{ClickRegion, FocusId};
35
36/// Actions an input can emit.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum InputAction {
39    /// Focus the input.
40    Focus,
41}
42
43/// State for an input field.
44#[derive(Debug, Clone)]
45pub struct InputState {
46    /// The text content.
47    pub text: String,
48    /// Cursor position (character index).
49    pub cursor_pos: usize,
50    /// Whether the input has focus.
51    pub focused: bool,
52    /// Whether the input is enabled.
53    pub enabled: bool,
54    /// Horizontal scroll offset for long text.
55    pub scroll_offset: usize,
56}
57
58impl Default for InputState {
59    fn default() -> Self {
60        Self {
61            text: String::new(),
62            cursor_pos: 0,
63            focused: false,
64            enabled: true,
65            scroll_offset: 0,
66        }
67    }
68}
69
70impl InputState {
71    /// Create a new input state with initial text.
72    ///
73    /// Cursor is positioned at the end of the text.
74    pub fn new(text: impl Into<String>) -> Self {
75        let text = text.into();
76        let cursor_pos = text.chars().count();
77        Self {
78            text,
79            cursor_pos,
80            focused: false,
81            enabled: true,
82            scroll_offset: 0,
83        }
84    }
85
86    /// Create an empty input state.
87    pub fn empty() -> Self {
88        Self::default()
89    }
90
91    /// Insert a character at cursor position.
92    pub fn insert_char(&mut self, c: char) {
93        if !self.enabled {
94            return;
95        }
96        let byte_pos = self.char_to_byte_index(self.cursor_pos);
97        self.text.insert(byte_pos, c);
98        self.cursor_pos += 1;
99    }
100
101    /// Insert a string at cursor position.
102    pub fn insert_str(&mut self, s: &str) {
103        if !self.enabled {
104            return;
105        }
106        let byte_pos = self.char_to_byte_index(self.cursor_pos);
107        self.text.insert_str(byte_pos, s);
108        self.cursor_pos += s.chars().count();
109    }
110
111    /// Delete character before cursor (backspace).
112    ///
113    /// Returns `true` if a character was deleted.
114    pub fn delete_char_backward(&mut self) -> bool {
115        if !self.enabled || self.cursor_pos == 0 {
116            return false;
117        }
118
119        self.cursor_pos -= 1;
120        let byte_pos = self.char_to_byte_index(self.cursor_pos);
121        if let Some(c) = self.text[byte_pos..].chars().next() {
122            self.text
123                .replace_range(byte_pos..byte_pos + c.len_utf8(), "");
124            return true;
125        }
126        false
127    }
128
129    /// Delete character at cursor (delete key).
130    ///
131    /// Returns `true` if a character was deleted.
132    pub fn delete_char_forward(&mut self) -> bool {
133        if !self.enabled {
134            return false;
135        }
136
137        let byte_pos = self.char_to_byte_index(self.cursor_pos);
138        if byte_pos < self.text.len() {
139            if let Some(c) = self.text[byte_pos..].chars().next() {
140                self.text
141                    .replace_range(byte_pos..byte_pos + c.len_utf8(), "");
142                return true;
143            }
144        }
145        false
146    }
147
148    /// Delete word before cursor.
149    ///
150    /// Returns `true` if any characters were deleted.
151    pub fn delete_word_backward(&mut self) -> bool {
152        if !self.enabled || self.cursor_pos == 0 {
153            return false;
154        }
155
156        let start_pos = self.cursor_pos;
157
158        // Skip trailing whitespace
159        while self.cursor_pos > 0 {
160            let prev_char = self.char_at(self.cursor_pos - 1);
161            if prev_char.map(|c| c.is_whitespace()).unwrap_or(false) {
162                self.cursor_pos -= 1;
163            } else {
164                break;
165            }
166        }
167
168        // Delete word characters
169        while self.cursor_pos > 0 {
170            let prev_char = self.char_at(self.cursor_pos - 1);
171            if prev_char.map(|c| !c.is_whitespace()).unwrap_or(false) {
172                self.delete_char_backward();
173            } else {
174                break;
175            }
176        }
177
178        start_pos != self.cursor_pos
179    }
180
181    /// Move cursor left by one character.
182    pub fn move_left(&mut self) {
183        if self.cursor_pos > 0 {
184            self.cursor_pos -= 1;
185        }
186    }
187
188    /// Move cursor right by one character.
189    pub fn move_right(&mut self) {
190        let max = self.text.chars().count();
191        if self.cursor_pos < max {
192            self.cursor_pos += 1;
193        }
194    }
195
196    /// Move cursor to the start of the text.
197    pub fn move_home(&mut self) {
198        self.cursor_pos = 0;
199    }
200
201    /// Move cursor to the end of the text.
202    pub fn move_end(&mut self) {
203        self.cursor_pos = self.text.chars().count();
204    }
205
206    /// Move cursor left by one word.
207    pub fn move_word_left(&mut self) {
208        if self.cursor_pos == 0 {
209            return;
210        }
211
212        // Skip whitespace
213        while self.cursor_pos > 0 {
214            if let Some(c) = self.char_at(self.cursor_pos - 1) {
215                if c.is_whitespace() {
216                    self.cursor_pos -= 1;
217                } else {
218                    break;
219                }
220            } else {
221                break;
222            }
223        }
224
225        // Skip word characters
226        while self.cursor_pos > 0 {
227            if let Some(c) = self.char_at(self.cursor_pos - 1) {
228                if !c.is_whitespace() {
229                    self.cursor_pos -= 1;
230                } else {
231                    break;
232                }
233            } else {
234                break;
235            }
236        }
237    }
238
239    /// Move cursor right by one word.
240    pub fn move_word_right(&mut self) {
241        let max = self.text.chars().count();
242        if self.cursor_pos >= max {
243            return;
244        }
245
246        // Skip current word
247        while self.cursor_pos < max {
248            if let Some(c) = self.char_at(self.cursor_pos) {
249                if !c.is_whitespace() {
250                    self.cursor_pos += 1;
251                } else {
252                    break;
253                }
254            } else {
255                break;
256            }
257        }
258
259        // Skip whitespace
260        while self.cursor_pos < max {
261            if let Some(c) = self.char_at(self.cursor_pos) {
262                if c.is_whitespace() {
263                    self.cursor_pos += 1;
264                } else {
265                    break;
266                }
267            } else {
268                break;
269            }
270        }
271    }
272
273    /// Clear the text and reset cursor.
274    pub fn clear(&mut self) {
275        self.text.clear();
276        self.cursor_pos = 0;
277        self.scroll_offset = 0;
278    }
279
280    /// Set the text content.
281    ///
282    /// Cursor is moved to the end.
283    pub fn set_text(&mut self, text: impl Into<String>) {
284        self.text = text.into();
285        self.cursor_pos = self.text.chars().count();
286        self.scroll_offset = 0;
287    }
288
289    /// Get the character at a given index.
290    fn char_at(&self, index: usize) -> Option<char> {
291        self.text.chars().nth(index)
292    }
293
294    /// Convert character index to byte index.
295    fn char_to_byte_index(&self, char_idx: usize) -> usize {
296        self.text
297            .char_indices()
298            .nth(char_idx)
299            .map(|(i, _)| i)
300            .unwrap_or(self.text.len())
301    }
302
303    /// Get text before cursor.
304    pub fn text_before_cursor(&self) -> &str {
305        let byte_pos = self.char_to_byte_index(self.cursor_pos);
306        &self.text[..byte_pos]
307    }
308
309    /// Get text after cursor.
310    pub fn text_after_cursor(&self) -> &str {
311        let byte_pos = self.char_to_byte_index(self.cursor_pos);
312        &self.text[byte_pos..]
313    }
314
315    /// Check if the input is empty.
316    pub fn is_empty(&self) -> bool {
317        self.text.is_empty()
318    }
319
320    /// Get the length of the text in characters.
321    pub fn len(&self) -> usize {
322        self.text.chars().count()
323    }
324
325    /// Get a reference to the text content.
326    pub fn text(&self) -> &str {
327        &self.text
328    }
329}
330
331/// Configuration for input appearance.
332#[derive(Debug, Clone)]
333pub struct InputStyle {
334    /// Border color when focused.
335    pub focused_border: Color,
336    /// Border color when unfocused.
337    pub unfocused_border: Color,
338    /// Border color when disabled.
339    pub disabled_border: Color,
340    /// Text foreground color.
341    pub text_fg: Color,
342    /// Cursor color.
343    pub cursor_fg: Color,
344    /// Placeholder text color.
345    pub placeholder_fg: Color,
346}
347
348impl Default for InputStyle {
349    fn default() -> Self {
350        Self {
351            focused_border: Color::Yellow,
352            unfocused_border: Color::Gray,
353            disabled_border: Color::DarkGray,
354            text_fg: Color::White,
355            cursor_fg: Color::Yellow,
356            placeholder_fg: Color::DarkGray,
357        }
358    }
359}
360
361impl InputStyle {
362    /// Set the focused border color.
363    pub fn focused_border(mut self, color: Color) -> Self {
364        self.focused_border = color;
365        self
366    }
367
368    /// Set the unfocused border color.
369    pub fn unfocused_border(mut self, color: Color) -> Self {
370        self.unfocused_border = color;
371        self
372    }
373
374    /// Set the text color.
375    pub fn text_fg(mut self, color: Color) -> Self {
376        self.text_fg = color;
377        self
378    }
379
380    /// Set the cursor color.
381    pub fn cursor_fg(mut self, color: Color) -> Self {
382        self.cursor_fg = color;
383        self
384    }
385
386    /// Set the placeholder color.
387    pub fn placeholder_fg(mut self, color: Color) -> Self {
388        self.placeholder_fg = color;
389        self
390    }
391}
392
393/// Input widget.
394///
395/// A text input field with cursor, label, and placeholder support.
396pub struct Input<'a> {
397    label: Option<&'a str>,
398    placeholder: Option<&'a str>,
399    state: &'a InputState,
400    style: InputStyle,
401    focus_id: FocusId,
402    with_border: bool,
403}
404
405impl<'a> Input<'a> {
406    /// Create a new input widget.
407    ///
408    /// # Arguments
409    ///
410    /// * `state` - Reference to the input state
411    pub fn new(state: &'a InputState) -> Self {
412        Self {
413            label: None,
414            placeholder: None,
415            state,
416            style: InputStyle::default(),
417            focus_id: FocusId::default(),
418            with_border: true,
419        }
420    }
421
422    /// Set the label (displayed in the border title).
423    pub fn label(mut self, label: &'a str) -> Self {
424        self.label = Some(label);
425        self
426    }
427
428    /// Set the placeholder text (shown when empty).
429    pub fn placeholder(mut self, placeholder: &'a str) -> Self {
430        self.placeholder = Some(placeholder);
431        self
432    }
433
434    /// Set the input style.
435    pub fn style(mut self, style: InputStyle) -> Self {
436        self.style = style;
437        self
438    }
439
440    /// Set the focus ID.
441    pub fn focus_id(mut self, id: FocusId) -> Self {
442        self.focus_id = id;
443        self
444    }
445
446    /// Enable or disable the border.
447    pub fn with_border(mut self, with_border: bool) -> Self {
448        self.with_border = with_border;
449        self
450    }
451
452    /// Render the input and return the click region.
453    pub fn render_stateful(self, frame: &mut Frame, area: Rect) -> ClickRegion<InputAction> {
454        let border_color = if !self.state.enabled {
455            self.style.disabled_border
456        } else if self.state.focused {
457            self.style.focused_border
458        } else {
459            self.style.unfocused_border
460        };
461
462        let block = if self.with_border {
463            let mut block = Block::default()
464                .borders(Borders::ALL)
465                .border_style(Style::default().fg(border_color));
466            if let Some(label) = self.label {
467                block = block.title(format!(" {} ", label));
468            }
469            Some(block)
470        } else {
471            None
472        };
473
474        let inner_area = if let Some(ref b) = block {
475            b.inner(area)
476        } else {
477            area
478        };
479
480        // Build display text with cursor indicator
481        let display_line = if self.state.text.is_empty() {
482            if let Some(placeholder) = self.placeholder {
483                Line::from(Span::styled(
484                    placeholder,
485                    Style::default().fg(self.style.placeholder_fg),
486                ))
487            } else if self.state.focused {
488                // Show cursor even when empty
489                Line::from(Span::styled("│", Style::default().fg(self.style.cursor_fg)))
490            } else {
491                Line::from("")
492            }
493        } else {
494            let before = self.state.text_before_cursor();
495            let after = self.state.text_after_cursor();
496
497            let mut spans = vec![Span::styled(
498                before.to_string(),
499                Style::default().fg(self.style.text_fg),
500            )];
501
502            if self.state.focused {
503                spans.push(Span::styled("│", Style::default().fg(self.style.cursor_fg)));
504            }
505
506            spans.push(Span::styled(
507                after.to_string(),
508                Style::default().fg(self.style.text_fg),
509            ));
510
511            Line::from(spans)
512        };
513
514        let paragraph = Paragraph::new(display_line);
515
516        if let Some(block) = block {
517            frame.render_widget(block, area);
518        }
519        frame.render_widget(paragraph, inner_area);
520
521        ClickRegion::new(area, InputAction::Focus)
522    }
523}
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528
529    #[test]
530    fn test_state_default() {
531        let state = InputState::default();
532        assert!(state.text.is_empty());
533        assert_eq!(state.cursor_pos, 0);
534        assert!(!state.focused);
535        assert!(state.enabled);
536    }
537
538    #[test]
539    fn test_state_new() {
540        let state = InputState::new("Hello");
541        assert_eq!(state.text, "Hello");
542        assert_eq!(state.cursor_pos, 5); // At end
543    }
544
545    #[test]
546    fn test_insert_char() {
547        let mut state = InputState::new("Hello");
548        state.insert_char('!');
549        assert_eq!(state.text, "Hello!");
550        assert_eq!(state.cursor_pos, 6);
551    }
552
553    #[test]
554    fn test_insert_char_middle() {
555        let mut state = InputState::new("Hllo");
556        state.cursor_pos = 1;
557        state.insert_char('e');
558        assert_eq!(state.text, "Hello");
559        assert_eq!(state.cursor_pos, 2);
560    }
561
562    #[test]
563    fn test_insert_str() {
564        let mut state = InputState::new("Hello");
565        state.insert_str(" World");
566        assert_eq!(state.text, "Hello World");
567    }
568
569    #[test]
570    fn test_delete_char_backward() {
571        let mut state = InputState::new("Hello");
572        assert!(state.delete_char_backward());
573        assert_eq!(state.text, "Hell");
574        assert_eq!(state.cursor_pos, 4);
575    }
576
577    #[test]
578    fn test_delete_char_backward_at_start() {
579        let mut state = InputState::new("Hello");
580        state.cursor_pos = 0;
581        assert!(!state.delete_char_backward());
582        assert_eq!(state.text, "Hello");
583    }
584
585    #[test]
586    fn test_delete_char_forward() {
587        let mut state = InputState::new("Hello");
588        state.cursor_pos = 0;
589        assert!(state.delete_char_forward());
590        assert_eq!(state.text, "ello");
591    }
592
593    #[test]
594    fn test_delete_char_forward_at_end() {
595        let mut state = InputState::new("Hello");
596        assert!(!state.delete_char_forward());
597        assert_eq!(state.text, "Hello");
598    }
599
600    #[test]
601    fn test_move_cursor() {
602        let mut state = InputState::new("Hello");
603        assert_eq!(state.cursor_pos, 5);
604
605        state.move_left();
606        assert_eq!(state.cursor_pos, 4);
607
608        state.move_right();
609        assert_eq!(state.cursor_pos, 5);
610
611        state.move_home();
612        assert_eq!(state.cursor_pos, 0);
613
614        state.move_end();
615        assert_eq!(state.cursor_pos, 5);
616    }
617
618    #[test]
619    fn test_move_cursor_bounds() {
620        let mut state = InputState::new("Hi");
621
622        state.move_home();
623        state.move_left(); // Should not go below 0
624        assert_eq!(state.cursor_pos, 0);
625
626        state.move_end();
627        state.move_right(); // Should not go past end
628        assert_eq!(state.cursor_pos, 2);
629    }
630
631    #[test]
632    fn test_move_word() {
633        let mut state = InputState::new("Hello World Test");
634
635        state.move_home();
636        state.move_word_right();
637        assert_eq!(state.cursor_pos, 6); // After "Hello "
638
639        state.move_word_right();
640        assert_eq!(state.cursor_pos, 12); // After "World "
641
642        state.move_word_left();
643        assert_eq!(state.cursor_pos, 6); // Back to "World"
644    }
645
646    #[test]
647    fn test_clear() {
648        let mut state = InputState::new("Hello");
649        state.clear();
650        assert!(state.text.is_empty());
651        assert_eq!(state.cursor_pos, 0);
652    }
653
654    #[test]
655    fn test_set_text() {
656        let mut state = InputState::new("Hello");
657        state.set_text("World");
658        assert_eq!(state.text, "World");
659        assert_eq!(state.cursor_pos, 5);
660    }
661
662    #[test]
663    fn test_text_before_after_cursor() {
664        let mut state = InputState::new("Hello");
665        state.cursor_pos = 2;
666
667        assert_eq!(state.text_before_cursor(), "He");
668        assert_eq!(state.text_after_cursor(), "llo");
669    }
670
671    #[test]
672    fn test_unicode_handling() {
673        let mut state = InputState::new("你好");
674        assert_eq!(state.cursor_pos, 2); // 2 characters
675
676        state.move_left();
677        assert_eq!(state.cursor_pos, 1);
678
679        state.insert_char('世');
680        assert_eq!(state.text, "你世好");
681    }
682
683    #[test]
684    fn test_emoji_handling() {
685        let mut state = InputState::new("Hi 👋");
686        assert_eq!(state.len(), 4); // "H", "i", " ", "👋"
687
688        state.delete_char_backward();
689        assert_eq!(state.text, "Hi ");
690    }
691
692    #[test]
693    fn test_disabled_input() {
694        let mut state = InputState::new("Hello");
695        state.enabled = false;
696
697        state.insert_char('!');
698        assert_eq!(state.text, "Hello"); // No change
699
700        assert!(!state.delete_char_backward());
701        assert_eq!(state.text, "Hello"); // No change
702    }
703
704    #[test]
705    fn test_is_empty_and_len() {
706        let state = InputState::empty();
707        assert!(state.is_empty());
708        assert_eq!(state.len(), 0);
709
710        let state = InputState::new("Test");
711        assert!(!state.is_empty());
712        assert_eq!(state.len(), 4);
713    }
714
715    #[test]
716    fn test_input_style_builder() {
717        let style = InputStyle::default()
718            .focused_border(Color::Cyan)
719            .text_fg(Color::Green);
720
721        assert_eq!(style.focused_border, Color::Cyan);
722        assert_eq!(style.text_fg, Color::Green);
723    }
724}