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
393impl From<&crate::theme::Theme> for InputStyle {
394    fn from(theme: &crate::theme::Theme) -> Self {
395        let p = &theme.palette;
396        Self {
397            focused_border: p.border_focused,
398            unfocused_border: p.border,
399            disabled_border: p.border_disabled,
400            text_fg: p.text,
401            cursor_fg: p.primary,
402            placeholder_fg: p.text_placeholder,
403        }
404    }
405}
406
407/// Input widget.
408///
409/// A text input field with cursor, label, and placeholder support.
410pub struct Input<'a> {
411    label: Option<&'a str>,
412    placeholder: Option<&'a str>,
413    state: &'a InputState,
414    style: InputStyle,
415    focus_id: FocusId,
416    with_border: bool,
417}
418
419impl<'a> Input<'a> {
420    /// Create a new input widget.
421    ///
422    /// # Arguments
423    ///
424    /// * `state` - Reference to the input state
425    pub fn new(state: &'a InputState) -> Self {
426        Self {
427            label: None,
428            placeholder: None,
429            state,
430            style: InputStyle::default(),
431            focus_id: FocusId::default(),
432            with_border: true,
433        }
434    }
435
436    /// Set the label (displayed in the border title).
437    pub fn label(mut self, label: &'a str) -> Self {
438        self.label = Some(label);
439        self
440    }
441
442    /// Set the placeholder text (shown when empty).
443    pub fn placeholder(mut self, placeholder: &'a str) -> Self {
444        self.placeholder = Some(placeholder);
445        self
446    }
447
448    /// Set the input style.
449    pub fn style(mut self, style: InputStyle) -> Self {
450        self.style = style;
451        self
452    }
453
454    /// Apply a theme to this input.
455    pub fn theme(self, theme: &crate::theme::Theme) -> Self {
456        self.style(InputStyle::from(theme))
457    }
458
459    /// Set the focus ID.
460    pub fn focus_id(mut self, id: FocusId) -> Self {
461        self.focus_id = id;
462        self
463    }
464
465    /// Enable or disable the border.
466    pub fn with_border(mut self, with_border: bool) -> Self {
467        self.with_border = with_border;
468        self
469    }
470
471    /// Render the input and return the click region.
472    pub fn render_stateful(self, frame: &mut Frame, area: Rect) -> ClickRegion<InputAction> {
473        let border_color = if !self.state.enabled {
474            self.style.disabled_border
475        } else if self.state.focused {
476            self.style.focused_border
477        } else {
478            self.style.unfocused_border
479        };
480
481        let block = if self.with_border {
482            let mut block = Block::default()
483                .borders(Borders::ALL)
484                .border_style(Style::default().fg(border_color));
485            if let Some(label) = self.label {
486                block = block.title(format!(" {} ", label));
487            }
488            Some(block)
489        } else {
490            None
491        };
492
493        let inner_area = if let Some(ref b) = block {
494            b.inner(area)
495        } else {
496            area
497        };
498
499        // Build display text with cursor indicator
500        let display_line = if self.state.text.is_empty() {
501            if let Some(placeholder) = self.placeholder {
502                Line::from(Span::styled(
503                    placeholder,
504                    Style::default().fg(self.style.placeholder_fg),
505                ))
506            } else if self.state.focused {
507                // Show cursor even when empty
508                Line::from(Span::styled("│", Style::default().fg(self.style.cursor_fg)))
509            } else {
510                Line::from("")
511            }
512        } else {
513            let before = self.state.text_before_cursor();
514            let after = self.state.text_after_cursor();
515
516            let mut spans = vec![Span::styled(
517                before.to_string(),
518                Style::default().fg(self.style.text_fg),
519            )];
520
521            if self.state.focused {
522                spans.push(Span::styled("│", Style::default().fg(self.style.cursor_fg)));
523            }
524
525            spans.push(Span::styled(
526                after.to_string(),
527                Style::default().fg(self.style.text_fg),
528            ));
529
530            Line::from(spans)
531        };
532
533        let paragraph = Paragraph::new(display_line);
534
535        if let Some(block) = block {
536            frame.render_widget(block, area);
537        }
538        frame.render_widget(paragraph, inner_area);
539
540        ClickRegion::new(area, InputAction::Focus)
541    }
542}
543
544#[cfg(test)]
545mod tests {
546    use super::*;
547
548    #[test]
549    fn test_state_default() {
550        let state = InputState::default();
551        assert!(state.text.is_empty());
552        assert_eq!(state.cursor_pos, 0);
553        assert!(!state.focused);
554        assert!(state.enabled);
555    }
556
557    #[test]
558    fn test_state_new() {
559        let state = InputState::new("Hello");
560        assert_eq!(state.text, "Hello");
561        assert_eq!(state.cursor_pos, 5); // At end
562    }
563
564    #[test]
565    fn test_insert_char() {
566        let mut state = InputState::new("Hello");
567        state.insert_char('!');
568        assert_eq!(state.text, "Hello!");
569        assert_eq!(state.cursor_pos, 6);
570    }
571
572    #[test]
573    fn test_insert_char_middle() {
574        let mut state = InputState::new("Hllo");
575        state.cursor_pos = 1;
576        state.insert_char('e');
577        assert_eq!(state.text, "Hello");
578        assert_eq!(state.cursor_pos, 2);
579    }
580
581    #[test]
582    fn test_insert_str() {
583        let mut state = InputState::new("Hello");
584        state.insert_str(" World");
585        assert_eq!(state.text, "Hello World");
586    }
587
588    #[test]
589    fn test_delete_char_backward() {
590        let mut state = InputState::new("Hello");
591        assert!(state.delete_char_backward());
592        assert_eq!(state.text, "Hell");
593        assert_eq!(state.cursor_pos, 4);
594    }
595
596    #[test]
597    fn test_delete_char_backward_at_start() {
598        let mut state = InputState::new("Hello");
599        state.cursor_pos = 0;
600        assert!(!state.delete_char_backward());
601        assert_eq!(state.text, "Hello");
602    }
603
604    #[test]
605    fn test_delete_char_forward() {
606        let mut state = InputState::new("Hello");
607        state.cursor_pos = 0;
608        assert!(state.delete_char_forward());
609        assert_eq!(state.text, "ello");
610    }
611
612    #[test]
613    fn test_delete_char_forward_at_end() {
614        let mut state = InputState::new("Hello");
615        assert!(!state.delete_char_forward());
616        assert_eq!(state.text, "Hello");
617    }
618
619    #[test]
620    fn test_move_cursor() {
621        let mut state = InputState::new("Hello");
622        assert_eq!(state.cursor_pos, 5);
623
624        state.move_left();
625        assert_eq!(state.cursor_pos, 4);
626
627        state.move_right();
628        assert_eq!(state.cursor_pos, 5);
629
630        state.move_home();
631        assert_eq!(state.cursor_pos, 0);
632
633        state.move_end();
634        assert_eq!(state.cursor_pos, 5);
635    }
636
637    #[test]
638    fn test_move_cursor_bounds() {
639        let mut state = InputState::new("Hi");
640
641        state.move_home();
642        state.move_left(); // Should not go below 0
643        assert_eq!(state.cursor_pos, 0);
644
645        state.move_end();
646        state.move_right(); // Should not go past end
647        assert_eq!(state.cursor_pos, 2);
648    }
649
650    #[test]
651    fn test_move_word() {
652        let mut state = InputState::new("Hello World Test");
653
654        state.move_home();
655        state.move_word_right();
656        assert_eq!(state.cursor_pos, 6); // After "Hello "
657
658        state.move_word_right();
659        assert_eq!(state.cursor_pos, 12); // After "World "
660
661        state.move_word_left();
662        assert_eq!(state.cursor_pos, 6); // Back to "World"
663    }
664
665    #[test]
666    fn test_clear() {
667        let mut state = InputState::new("Hello");
668        state.clear();
669        assert!(state.text.is_empty());
670        assert_eq!(state.cursor_pos, 0);
671    }
672
673    #[test]
674    fn test_set_text() {
675        let mut state = InputState::new("Hello");
676        state.set_text("World");
677        assert_eq!(state.text, "World");
678        assert_eq!(state.cursor_pos, 5);
679    }
680
681    #[test]
682    fn test_text_before_after_cursor() {
683        let mut state = InputState::new("Hello");
684        state.cursor_pos = 2;
685
686        assert_eq!(state.text_before_cursor(), "He");
687        assert_eq!(state.text_after_cursor(), "llo");
688    }
689
690    #[test]
691    fn test_unicode_handling() {
692        let mut state = InputState::new("你好");
693        assert_eq!(state.cursor_pos, 2); // 2 characters
694
695        state.move_left();
696        assert_eq!(state.cursor_pos, 1);
697
698        state.insert_char('世');
699        assert_eq!(state.text, "你世好");
700    }
701
702    #[test]
703    fn test_emoji_handling() {
704        let mut state = InputState::new("Hi 👋");
705        assert_eq!(state.len(), 4); // "H", "i", " ", "👋"
706
707        state.delete_char_backward();
708        assert_eq!(state.text, "Hi ");
709    }
710
711    #[test]
712    fn test_disabled_input() {
713        let mut state = InputState::new("Hello");
714        state.enabled = false;
715
716        state.insert_char('!');
717        assert_eq!(state.text, "Hello"); // No change
718
719        assert!(!state.delete_char_backward());
720        assert_eq!(state.text, "Hello"); // No change
721    }
722
723    #[test]
724    fn test_is_empty_and_len() {
725        let state = InputState::empty();
726        assert!(state.is_empty());
727        assert_eq!(state.len(), 0);
728
729        let state = InputState::new("Test");
730        assert!(!state.is_empty());
731        assert_eq!(state.len(), 4);
732    }
733
734    #[test]
735    fn test_input_style_builder() {
736        let style = InputStyle::default()
737            .focused_border(Color::Cyan)
738            .text_fg(Color::Green);
739
740        assert_eq!(style.focused_border, Color::Cyan);
741        assert_eq!(style.text_fg, Color::Green);
742    }
743}