tui_dispatch_components/
text_input.rs

1//! Single-line text input component
2
3use crossterm::event::{KeyCode, KeyModifiers};
4use ratatui::{
5    layout::Rect,
6    style::{Color, Style},
7    widgets::{Block, Paragraph},
8    Frame,
9};
10use tui_dispatch_core::{Component, EventKind};
11
12use crate::style::{BaseStyle, ComponentStyle, Padding};
13
14/// Unified styling for TextInput
15#[derive(Debug, Clone)]
16pub struct TextInputStyle {
17    /// Shared base style
18    pub base: BaseStyle,
19    /// Style for placeholder text
20    pub placeholder_style: Option<Style>,
21    /// Style for cursor (when focused)
22    pub cursor_style: Option<Style>,
23}
24
25impl Default for TextInputStyle {
26    fn default() -> Self {
27        Self {
28            base: BaseStyle {
29                fg: None,
30                ..Default::default()
31            },
32            placeholder_style: Some(Style::default().fg(Color::DarkGray)),
33            cursor_style: None,
34        }
35    }
36}
37
38impl TextInputStyle {
39    /// Create a style with no border
40    pub fn borderless() -> Self {
41        let mut style = Self::default();
42        style.base.border = None;
43        style
44    }
45
46    /// Create a minimal style (no border, no padding)
47    pub fn minimal() -> Self {
48        let mut style = Self::default();
49        style.base.border = None;
50        style.base.padding = Padding::default();
51        style
52    }
53}
54
55impl ComponentStyle for TextInputStyle {
56    fn base(&self) -> &BaseStyle {
57        &self.base
58    }
59}
60
61/// Props for TextInput component
62pub struct TextInputProps<'a, A> {
63    /// Current input value
64    pub value: &'a str,
65    /// Placeholder text when empty
66    pub placeholder: &'a str,
67    /// Whether this component has focus
68    pub is_focused: bool,
69    /// Unified styling
70    pub style: TextInputStyle,
71    /// Callback when value changes
72    pub on_change: fn(String) -> A,
73    /// Callback when user submits (Enter)
74    pub on_submit: fn(String) -> A,
75    /// Callback when cursor moves without content change
76    pub on_cursor_move: Option<fn(usize) -> A>,
77}
78
79/// A single-line text input with cursor
80///
81/// Handles typing, backspace, delete, and cursor movement.
82/// Emits on_change for each keystroke, on_submit for Enter,
83/// and on_cursor_move for cursor-only movement if provided.
84#[derive(Default)]
85pub struct TextInput {
86    /// Cursor position (byte index)
87    cursor: usize,
88}
89
90impl TextInput {
91    /// Create a new TextInput
92    pub fn new() -> Self {
93        Self::default()
94    }
95
96    /// Clamp cursor to valid range for the given value
97    fn clamp_cursor(&mut self, value: &str) {
98        self.cursor = self.cursor.min(value.len());
99    }
100
101    /// Move cursor left by one character
102    fn move_cursor_left(&mut self, value: &str) {
103        if self.cursor > 0 {
104            // Find previous char boundary
105            let mut new_pos = self.cursor - 1;
106            while new_pos > 0 && !value.is_char_boundary(new_pos) {
107                new_pos -= 1;
108            }
109            self.cursor = new_pos;
110        }
111    }
112
113    /// Move cursor right by one character
114    fn move_cursor_right(&mut self, value: &str) {
115        if self.cursor < value.len() {
116            // Find next char boundary
117            let mut new_pos = self.cursor + 1;
118            while new_pos < value.len() && !value.is_char_boundary(new_pos) {
119                new_pos += 1;
120            }
121            self.cursor = new_pos;
122        }
123    }
124
125    /// Insert character at cursor position
126    fn insert_char(&mut self, value: &str, c: char) -> String {
127        let mut new_value = String::with_capacity(value.len() + c.len_utf8());
128        new_value.push_str(&value[..self.cursor]);
129        new_value.push(c);
130        new_value.push_str(&value[self.cursor..]);
131        self.cursor += c.len_utf8();
132        new_value
133    }
134
135    /// Delete character before cursor (backspace)
136    fn delete_char_before(&mut self, value: &str) -> Option<String> {
137        if self.cursor == 0 {
138            return None;
139        }
140
141        let mut new_value = String::with_capacity(value.len());
142        let before_cursor = &value[..self.cursor];
143
144        // Find the previous character boundary
145        let char_start = before_cursor
146            .char_indices()
147            .last()
148            .map(|(i, _)| i)
149            .unwrap_or(0);
150
151        new_value.push_str(&value[..char_start]);
152        new_value.push_str(&value[self.cursor..]);
153        self.cursor = char_start;
154        Some(new_value)
155    }
156
157    /// Delete character at cursor (delete key)
158    fn delete_char_at(&self, value: &str) -> Option<String> {
159        if self.cursor >= value.len() {
160            return None;
161        }
162
163        let mut new_value = String::with_capacity(value.len());
164        new_value.push_str(&value[..self.cursor]);
165
166        // Find the next character boundary
167        let after_cursor = &value[self.cursor..];
168        if let Some((_, c)) = after_cursor.char_indices().next() {
169            new_value.push_str(&value[self.cursor + c.len_utf8()..]);
170        }
171
172        Some(new_value)
173    }
174
175    // ========================================================================
176    // Word-based operations (readline/emacs style)
177    // ========================================================================
178
179    /// Find the byte position of the previous word boundary
180    fn prev_word_boundary(&self, value: &str) -> usize {
181        if self.cursor == 0 {
182            return 0;
183        }
184
185        let before = &value[..self.cursor];
186        let mut chars: Vec<(usize, char)> = before.char_indices().collect();
187
188        // Skip trailing non-word, non-whitespace (punctuation)
189        while let Some(&(_, c)) = chars.last() {
190            if c.is_alphanumeric() || c == '_' || c.is_whitespace() {
191                break;
192            }
193            chars.pop();
194        }
195
196        // Skip whitespace
197        while let Some(&(_, c)) = chars.last() {
198            if !c.is_whitespace() {
199                break;
200            }
201            chars.pop();
202        }
203
204        // Skip word characters (alphanumeric or _)
205        while let Some(&(_, c)) = chars.last() {
206            if !c.is_alphanumeric() && c != '_' {
207                break;
208            }
209            chars.pop();
210        }
211
212        chars.last().map(|&(i, c)| i + c.len_utf8()).unwrap_or(0)
213    }
214
215    /// Find the byte position of the next word boundary
216    fn next_word_boundary(&self, value: &str) -> usize {
217        if self.cursor >= value.len() {
218            return value.len();
219        }
220
221        let after = &value[self.cursor..];
222        let mut pos = self.cursor;
223
224        let mut chars = after.chars().peekable();
225
226        // Skip current word characters
227        while let Some(&c) = chars.peek() {
228            if !c.is_alphanumeric() && c != '_' {
229                break;
230            }
231            pos += c.len_utf8();
232            chars.next();
233        }
234
235        // Skip whitespace
236        while let Some(&c) = chars.peek() {
237            if !c.is_whitespace() {
238                break;
239            }
240            pos += c.len_utf8();
241            chars.next();
242        }
243
244        // If we haven't moved (started on whitespace/punctuation), skip to next word
245        if pos == self.cursor {
246            // Skip non-word, non-whitespace
247            while let Some(&c) = chars.peek() {
248                if c.is_alphanumeric() || c == '_' || c.is_whitespace() {
249                    break;
250                }
251                pos += c.len_utf8();
252                chars.next();
253            }
254            // Skip whitespace
255            while let Some(&c) = chars.peek() {
256                if !c.is_whitespace() {
257                    break;
258                }
259                pos += c.len_utf8();
260                chars.next();
261            }
262        }
263
264        pos
265    }
266
267    /// Move cursor backward by one word
268    fn move_word_backward(&mut self, value: &str) {
269        self.cursor = self.prev_word_boundary(value);
270    }
271
272    /// Move cursor forward by one word
273    fn move_word_forward(&mut self, value: &str) {
274        self.cursor = self.next_word_boundary(value);
275    }
276
277    /// Delete from cursor to end of line (Ctrl+K)
278    fn kill_line(&self, value: &str) -> Option<String> {
279        if self.cursor >= value.len() {
280            return None;
281        }
282        Some(value[..self.cursor].to_string())
283    }
284
285    /// Delete word backward (Ctrl+W / Alt+Backspace)
286    fn kill_word_backward(&mut self, value: &str) -> Option<String> {
287        let boundary = self.prev_word_boundary(value);
288        if boundary == self.cursor {
289            return None;
290        }
291
292        let mut new_value = String::with_capacity(value.len());
293        new_value.push_str(&value[..boundary]);
294        new_value.push_str(&value[self.cursor..]);
295        self.cursor = boundary;
296        Some(new_value)
297    }
298
299    /// Delete word forward (Alt+D)
300    fn kill_word_forward(&self, value: &str) -> Option<String> {
301        let boundary = self.next_word_boundary(value);
302        if boundary == self.cursor {
303            return None;
304        }
305
306        let mut new_value = String::with_capacity(value.len());
307        new_value.push_str(&value[..self.cursor]);
308        new_value.push_str(&value[boundary..]);
309        Some(new_value)
310    }
311
312    /// Transpose characters at cursor (Ctrl+T)
313    fn transpose_chars(&mut self, value: &str) -> Option<String> {
314        // Need at least 2 characters and cursor not at start
315        if value.len() < 2 || self.cursor == 0 {
316            return None;
317        }
318
319        // If at end, transpose last two chars
320        let pos = if self.cursor >= value.len() {
321            // Find start of second-to-last char
322            let mut idx = value.len();
323            let mut count = 0;
324            for (i, _) in value.char_indices().rev() {
325                idx = i;
326                count += 1;
327                if count == 2 {
328                    break;
329                }
330            }
331            idx
332        } else {
333            // Find start of char before cursor
334            let before = &value[..self.cursor];
335            before.char_indices().last().map(|(i, _)| i).unwrap_or(0)
336        };
337
338        // Get the two characters to swap
339        let chars: Vec<char> = value[pos..].chars().take(2).collect();
340        if chars.len() < 2 {
341            return None;
342        }
343
344        let mut new_value = String::with_capacity(value.len());
345        new_value.push_str(&value[..pos]);
346        new_value.push(chars[1]);
347        new_value.push(chars[0]);
348        new_value.push_str(&value[pos + chars[0].len_utf8() + chars[1].len_utf8()..]);
349
350        // Move cursor forward if not at end
351        if self.cursor < value.len() {
352            self.cursor += chars[1].len_utf8();
353        }
354
355        Some(new_value)
356    }
357}
358
359impl<A> Component<A> for TextInput {
360    type Props<'a> = TextInputProps<'a, A>;
361
362    fn handle_event(
363        &mut self,
364        event: &EventKind,
365        props: Self::Props<'_>,
366    ) -> impl IntoIterator<Item = A> {
367        if !props.is_focused {
368            return None;
369        }
370
371        // Ensure cursor is valid for current value
372        self.clamp_cursor(props.value);
373
374        match event {
375            EventKind::Key(key) => {
376                let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
377                let alt = key.modifiers.contains(KeyModifiers::ALT);
378                let mut did_move = false;
379
380                let action = match (key.code, ctrl, alt) {
381                    // ============================================================
382                    // Ctrl+key shortcuts (readline/emacs style)
383                    // ============================================================
384
385                    // Movement
386                    (KeyCode::Char('a'), true, false) => {
387                        self.cursor = 0;
388                        did_move = true;
389                        None
390                    }
391                    (KeyCode::Char('e'), true, false) => {
392                        self.cursor = props.value.len();
393                        did_move = true;
394                        None
395                    }
396                    (KeyCode::Char('b'), true, false) => {
397                        self.move_cursor_left(props.value);
398                        did_move = true;
399                        None
400                    }
401                    (KeyCode::Char('f'), true, false) => {
402                        self.move_cursor_right(props.value);
403                        did_move = true;
404                        None
405                    }
406
407                    // Word movement (Ctrl+Arrow - Mac friendly)
408                    (KeyCode::Left, true, false) => {
409                        self.move_word_backward(props.value);
410                        did_move = true;
411                        None
412                    }
413                    (KeyCode::Right, true, false) => {
414                        self.move_word_forward(props.value);
415                        did_move = true;
416                        None
417                    }
418
419                    // Deletion
420                    (KeyCode::Char('u'), true, false) => {
421                        self.cursor = 0;
422                        Some((props.on_change)(String::new()))
423                    }
424                    (KeyCode::Char('k'), true, false) => {
425                        self.kill_line(props.value).map(|v| (props.on_change)(v))
426                    }
427                    (KeyCode::Char('w'), true, false) => self
428                        .kill_word_backward(props.value)
429                        .map(|v| (props.on_change)(v)),
430                    (KeyCode::Char('d'), true, false) => self
431                        .delete_char_at(props.value)
432                        .map(|v| (props.on_change)(v)),
433                    (KeyCode::Char('h'), true, false) => self
434                        .delete_char_before(props.value)
435                        .map(|v| (props.on_change)(v)),
436
437                    // Transpose
438                    (KeyCode::Char('t'), true, false) => self
439                        .transpose_chars(props.value)
440                        .map(|v| (props.on_change)(v)),
441
442                    // ============================================================
443                    // Alt+key shortcuts (when terminal sends escape sequences)
444                    // ============================================================
445
446                    // Word movement
447                    (KeyCode::Char('b'), false, true) => {
448                        self.move_word_backward(props.value);
449                        did_move = true;
450                        None
451                    }
452                    (KeyCode::Char('f'), false, true) => {
453                        self.move_word_forward(props.value);
454                        did_move = true;
455                        None
456                    }
457
458                    // Word deletion
459                    (KeyCode::Char('d'), false, true) => self
460                        .kill_word_forward(props.value)
461                        .map(|v| (props.on_change)(v)),
462                    (KeyCode::Backspace, false, true) => self
463                        .kill_word_backward(props.value)
464                        .map(|v| (props.on_change)(v)),
465
466                    // ============================================================
467                    // Basic keys
468                    // ============================================================
469
470                    // Backspace (no modifiers - Alt+Backspace handled above)
471                    (KeyCode::Backspace, false, false) => self
472                        .delete_char_before(props.value)
473                        .map(|v| (props.on_change)(v)),
474
475                    // Delete
476                    (KeyCode::Delete, _, _) => self
477                        .delete_char_at(props.value)
478                        .map(|v| (props.on_change)(v)),
479
480                    // Cursor movement (no Ctrl - Ctrl+Arrow handled above)
481                    (KeyCode::Left, false, _) => {
482                        self.move_cursor_left(props.value);
483                        did_move = true;
484                        None
485                    }
486                    (KeyCode::Right, false, _) => {
487                        self.move_cursor_right(props.value);
488                        did_move = true;
489                        None
490                    }
491                    (KeyCode::Home, _, _) => {
492                        self.cursor = 0;
493                        did_move = true;
494                        None
495                    }
496                    (KeyCode::End, _, _) => {
497                        self.cursor = props.value.len();
498                        did_move = true;
499                        None
500                    }
501
502                    // Submit
503                    (KeyCode::Enter, _, _) => Some((props.on_submit)(props.value.to_string())),
504
505                    // ============================================================
506                    // Character input - MUST be last to catch all printable chars
507                    // ============================================================
508                    // Any character not handled above gets inserted (space, etc.)
509                    (KeyCode::Char(c), _, _) => {
510                        let new_value = self.insert_char(props.value, c);
511                        Some((props.on_change)(new_value))
512                    }
513
514                    _ => None,
515                };
516
517                if action.is_none() && did_move {
518                    props.on_cursor_move.map(|callback| callback(self.cursor))
519                } else {
520                    action
521                }
522            }
523            _ => None,
524        }
525    }
526
527    fn render(&mut self, frame: &mut Frame, area: Rect, props: Self::Props<'_>) {
528        let style = &props.style;
529
530        // Ensure cursor is valid
531        self.clamp_cursor(props.value);
532
533        // Fill background if color provided
534        if let Some(bg) = style.base.bg {
535            for y in area.y..area.y.saturating_add(area.height) {
536                for x in area.x..area.x.saturating_add(area.width) {
537                    frame.buffer_mut()[(x, y)].set_bg(bg);
538                    frame.buffer_mut()[(x, y)].set_symbol(" ");
539                }
540            }
541        }
542
543        // Apply padding
544        let content_area = Rect {
545            x: area.x + style.base.padding.left,
546            y: area.y + style.base.padding.top,
547            width: area.width.saturating_sub(style.base.padding.horizontal()),
548            height: area.height.saturating_sub(style.base.padding.vertical()),
549        };
550
551        // Determine display text
552        let display_text = if props.value.is_empty() {
553            props.placeholder
554        } else {
555            props.value
556        };
557
558        // Build text style
559        let mut text_style = if props.value.is_empty() {
560            style
561                .placeholder_style
562                .unwrap_or_else(|| Style::default().fg(Color::DarkGray))
563        } else {
564            let mut s = Style::default();
565            if let Some(fg) = style.base.fg {
566                s = s.fg(fg);
567            }
568            s
569        };
570
571        // Preserve background color in text style
572        if let Some(bg) = style.base.bg {
573            text_style = text_style.bg(bg);
574        }
575
576        let mut paragraph = Paragraph::new(display_text).style(text_style);
577
578        if let Some(border) = &style.base.border {
579            paragraph = paragraph.block(
580                Block::default()
581                    .borders(border.borders)
582                    .border_style(border.style_for_focus(props.is_focused)),
583            );
584        }
585
586        frame.render_widget(paragraph, content_area);
587
588        // Show cursor if focused
589        if props.is_focused {
590            // Calculate cursor screen position (account for border and padding)
591            let border_offset = if style.base.border.is_some() { 1 } else { 0 };
592            let cursor_x = content_area.x + border_offset + self.cursor as u16;
593            let cursor_y = content_area.y + border_offset;
594
595            // Only show cursor if within bounds
596            let max_x = if style.base.border.is_some() {
597                content_area.x + content_area.width - 1
598            } else {
599                content_area.x + content_area.width
600            };
601            if cursor_x < max_x {
602                if let Some(cursor_style) = style.cursor_style {
603                    frame.buffer_mut()[(cursor_x, cursor_y)].set_style(cursor_style);
604                }
605                frame.set_cursor_position((cursor_x, cursor_y));
606            }
607        }
608    }
609}
610
611#[cfg(test)]
612mod tests {
613    use super::*;
614    use tui_dispatch_core::testing::{key, RenderHarness};
615
616    #[derive(Debug, Clone, PartialEq)]
617    enum TestAction {
618        Change(String),
619        Submit(String),
620    }
621
622    #[test]
623    fn test_typing() {
624        let mut input = TextInput::new();
625        let props = TextInputProps {
626            value: "",
627            placeholder: "",
628            is_focused: true,
629            style: TextInputStyle::default(),
630            on_change: TestAction::Change,
631            on_submit: TestAction::Submit,
632            on_cursor_move: None,
633        };
634
635        let actions: Vec<_> = input
636            .handle_event(&EventKind::Key(key("a")), props)
637            .into_iter()
638            .collect();
639
640        assert_eq!(actions, vec![TestAction::Change("a".into())]);
641    }
642
643    #[test]
644    fn test_typing_space() {
645        let mut input = TextInput::new();
646        input.cursor = 5; // After "hello"
647
648        let props = TextInputProps {
649            value: "hello",
650            placeholder: "",
651            is_focused: true,
652            style: TextInputStyle::default(),
653            on_change: TestAction::Change,
654            on_submit: TestAction::Submit,
655            on_cursor_move: None,
656        };
657
658        // Space character
659        let space_key = crossterm::event::KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE);
660
661        let actions: Vec<_> = input
662            .handle_event(&EventKind::Key(space_key), props)
663            .into_iter()
664            .collect();
665
666        assert_eq!(actions, vec![TestAction::Change("hello ".into())]);
667    }
668
669    #[test]
670    fn test_typing_appends() {
671        let mut input = TextInput::new();
672        input.cursor = 5; // At end of "hello"
673
674        let props = TextInputProps {
675            value: "hello",
676            placeholder: "",
677            is_focused: true,
678            style: TextInputStyle::default(),
679            on_change: TestAction::Change,
680            on_submit: TestAction::Submit,
681            on_cursor_move: None,
682        };
683
684        let actions: Vec<_> = input
685            .handle_event(&EventKind::Key(key("!")), props)
686            .into_iter()
687            .collect();
688
689        assert_eq!(actions, vec![TestAction::Change("hello!".into())]);
690    }
691
692    #[test]
693    fn test_backspace() {
694        let mut input = TextInput::new();
695        input.cursor = 5;
696
697        let props = TextInputProps {
698            value: "hello",
699            placeholder: "",
700            is_focused: true,
701            style: TextInputStyle::default(),
702            on_change: TestAction::Change,
703            on_submit: TestAction::Submit,
704            on_cursor_move: None,
705        };
706
707        let actions: Vec<_> = input
708            .handle_event(&EventKind::Key(key("backspace")), props)
709            .into_iter()
710            .collect();
711
712        assert_eq!(actions, vec![TestAction::Change("hell".into())]);
713        assert_eq!(input.cursor, 4);
714    }
715
716    #[test]
717    fn test_backspace_at_start() {
718        let mut input = TextInput::new();
719        input.cursor = 0;
720
721        let props = TextInputProps {
722            value: "hello",
723            placeholder: "",
724            is_focused: true,
725            style: TextInputStyle::default(),
726            on_change: TestAction::Change,
727            on_submit: TestAction::Submit,
728            on_cursor_move: None,
729        };
730
731        let actions: Vec<_> = input
732            .handle_event(&EventKind::Key(key("backspace")), props)
733            .into_iter()
734            .collect();
735
736        assert!(actions.is_empty());
737    }
738
739    #[test]
740    fn test_submit() {
741        let mut input = TextInput::new();
742
743        let props = TextInputProps {
744            value: "hello",
745            placeholder: "",
746            is_focused: true,
747            style: TextInputStyle::default(),
748            on_change: TestAction::Change,
749            on_submit: TestAction::Submit,
750            on_cursor_move: None,
751        };
752
753        let actions: Vec<_> = input
754            .handle_event(&EventKind::Key(key("enter")), props)
755            .into_iter()
756            .collect();
757
758        assert_eq!(actions, vec![TestAction::Submit("hello".into())]);
759    }
760
761    #[test]
762    fn test_unfocused_ignores() {
763        let mut input = TextInput::new();
764
765        let props = TextInputProps {
766            value: "",
767            placeholder: "",
768            is_focused: false,
769            style: TextInputStyle::default(),
770            on_change: TestAction::Change,
771            on_submit: TestAction::Submit,
772            on_cursor_move: None,
773        };
774
775        let actions: Vec<_> = input
776            .handle_event(&EventKind::Key(key("a")), props)
777            .into_iter()
778            .collect();
779
780        assert!(actions.is_empty());
781    }
782
783    #[test]
784    fn test_render_with_value() {
785        let mut render = RenderHarness::new(30, 3);
786        let mut input = TextInput::new();
787
788        let output = render.render_to_string_plain(|frame| {
789            let props = TextInputProps {
790                value: "hello",
791                placeholder: "Type here...",
792                is_focused: true,
793                style: TextInputStyle::default(),
794                on_change: |_| (),
795                on_submit: |_| (),
796                on_cursor_move: None,
797            };
798            input.render(frame, frame.area(), props);
799        });
800
801        assert!(output.contains("hello"));
802    }
803
804    #[test]
805    fn test_render_placeholder() {
806        let mut render = RenderHarness::new(30, 3);
807        let mut input = TextInput::new();
808
809        let output = render.render_to_string_plain(|frame| {
810            let props = TextInputProps {
811                value: "",
812                placeholder: "Type here...",
813                is_focused: true,
814                style: TextInputStyle::default(),
815                on_change: |_| (),
816                on_submit: |_| (),
817                on_cursor_move: None,
818            };
819            input.render(frame, frame.area(), props);
820        });
821
822        assert!(output.contains("Type here..."));
823    }
824
825    #[test]
826    fn test_render_with_custom_style() {
827        let mut render = RenderHarness::new(30, 3);
828        let mut input = TextInput::new();
829
830        let output = render.render_to_string_plain(|frame| {
831            let props = TextInputProps {
832                value: "test",
833                placeholder: "",
834                is_focused: true,
835                style: TextInputStyle {
836                    base: BaseStyle {
837                        border: None,
838                        padding: Padding::xy(1, 0),
839                        bg: Some(Color::Blue),
840                        fg: Some(Color::White),
841                    },
842                    placeholder_style: None,
843                    cursor_style: None,
844                },
845                on_change: |_| (),
846                on_submit: |_| (),
847                on_cursor_move: None,
848            };
849            input.render(frame, frame.area(), props);
850        });
851
852        assert!(output.contains("test"));
853    }
854
855    // ========================================================================
856    // Readline/Emacs keybinding tests
857    // ========================================================================
858
859    fn ctrl_key(c: char) -> crossterm::event::KeyEvent {
860        crossterm::event::KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
861    }
862
863    fn alt_key(c: char) -> crossterm::event::KeyEvent {
864        crossterm::event::KeyEvent::new(KeyCode::Char(c), KeyModifiers::ALT)
865    }
866
867    fn ctrl_arrow(code: KeyCode) -> crossterm::event::KeyEvent {
868        crossterm::event::KeyEvent::new(code, KeyModifiers::CONTROL)
869    }
870
871    #[test]
872    fn test_ctrl_k_kill_line() {
873        let mut input = TextInput::new();
874        input.cursor = 5; // After "hello"
875
876        let props = TextInputProps {
877            value: "hello world",
878            placeholder: "",
879            is_focused: true,
880            style: TextInputStyle::default(),
881            on_change: TestAction::Change,
882            on_submit: TestAction::Submit,
883            on_cursor_move: None,
884        };
885
886        let actions: Vec<_> = input
887            .handle_event(&EventKind::Key(ctrl_key('k')), props)
888            .into_iter()
889            .collect();
890
891        assert_eq!(actions, vec![TestAction::Change("hello".into())]);
892    }
893
894    #[test]
895    fn test_ctrl_w_kill_word_backward() {
896        let mut input = TextInput::new();
897        input.cursor = 11; // At end of "hello world"
898
899        let props = TextInputProps {
900            value: "hello world",
901            placeholder: "",
902            is_focused: true,
903            style: TextInputStyle::default(),
904            on_change: TestAction::Change,
905            on_submit: TestAction::Submit,
906            on_cursor_move: None,
907        };
908
909        let actions: Vec<_> = input
910            .handle_event(&EventKind::Key(ctrl_key('w')), props)
911            .into_iter()
912            .collect();
913
914        assert_eq!(actions, vec![TestAction::Change("hello ".into())]);
915        assert_eq!(input.cursor, 6);
916    }
917
918    #[test]
919    fn test_ctrl_left_word_backward() {
920        let mut input = TextInput::new();
921        input.cursor = 11; // At end of "hello world"
922
923        let props = TextInputProps {
924            value: "hello world",
925            placeholder: "",
926            is_focused: true,
927            style: TextInputStyle::default(),
928            on_change: TestAction::Change,
929            on_submit: TestAction::Submit,
930            on_cursor_move: None,
931        };
932
933        let actions: Vec<_> = input
934            .handle_event(&EventKind::Key(ctrl_arrow(KeyCode::Left)), props)
935            .into_iter()
936            .collect();
937
938        assert!(actions.is_empty()); // Movement doesn't emit action
939        assert_eq!(input.cursor, 6); // At start of "world"
940    }
941
942    #[test]
943    fn test_ctrl_right_word_forward() {
944        let mut input = TextInput::new();
945        input.cursor = 0;
946
947        let props = TextInputProps {
948            value: "hello world",
949            placeholder: "",
950            is_focused: true,
951            style: TextInputStyle::default(),
952            on_change: TestAction::Change,
953            on_submit: TestAction::Submit,
954            on_cursor_move: None,
955        };
956
957        let actions: Vec<_> = input
958            .handle_event(&EventKind::Key(ctrl_arrow(KeyCode::Right)), props)
959            .into_iter()
960            .collect();
961
962        assert!(actions.is_empty());
963        assert_eq!(input.cursor, 6); // After "hello "
964    }
965
966    #[test]
967    fn test_alt_d_kill_word_forward() {
968        let mut input = TextInput::new();
969        input.cursor = 0;
970
971        let props = TextInputProps {
972            value: "hello world",
973            placeholder: "",
974            is_focused: true,
975            style: TextInputStyle::default(),
976            on_change: TestAction::Change,
977            on_submit: TestAction::Submit,
978            on_cursor_move: None,
979        };
980
981        let actions: Vec<_> = input
982            .handle_event(&EventKind::Key(alt_key('d')), props)
983            .into_iter()
984            .collect();
985
986        assert_eq!(actions, vec![TestAction::Change("world".into())]);
987    }
988
989    #[test]
990    fn test_ctrl_t_transpose() {
991        let mut input = TextInput::new();
992        input.cursor = 2; // Between 'e' and 'l' in "hello"
993
994        let props = TextInputProps {
995            value: "hello",
996            placeholder: "",
997            is_focused: true,
998            style: TextInputStyle::default(),
999            on_change: TestAction::Change,
1000            on_submit: TestAction::Submit,
1001            on_cursor_move: None,
1002        };
1003
1004        let actions: Vec<_> = input
1005            .handle_event(&EventKind::Key(ctrl_key('t')), props)
1006            .into_iter()
1007            .collect();
1008
1009        assert_eq!(actions, vec![TestAction::Change("hlelo".into())]);
1010    }
1011
1012    #[test]
1013    fn test_ctrl_b_f_movement() {
1014        let mut input = TextInput::new();
1015        input.cursor = 5;
1016
1017        let props = TextInputProps {
1018            value: "hello world",
1019            placeholder: "",
1020            is_focused: true,
1021            style: TextInputStyle::default(),
1022            on_change: TestAction::Change,
1023            on_submit: TestAction::Submit,
1024            on_cursor_move: None,
1025        };
1026
1027        // Ctrl+B moves back
1028        let _: Vec<_> = input
1029            .handle_event(&EventKind::Key(ctrl_key('b')), props)
1030            .into_iter()
1031            .collect();
1032        assert_eq!(input.cursor, 4);
1033
1034        // Ctrl+F moves forward
1035        let props = TextInputProps {
1036            value: "hello world",
1037            placeholder: "",
1038            is_focused: true,
1039            style: TextInputStyle::default(),
1040            on_change: TestAction::Change,
1041            on_submit: TestAction::Submit,
1042            on_cursor_move: None,
1043        };
1044        let _: Vec<_> = input
1045            .handle_event(&EventKind::Key(ctrl_key('f')), props)
1046            .into_iter()
1047            .collect();
1048        assert_eq!(input.cursor, 5);
1049    }
1050
1051    #[test]
1052    fn test_word_boundary_multiple_spaces() {
1053        let mut input = TextInput::new();
1054        input.cursor = 14; // At end of "hello   world" (3 spaces)
1055
1056        // Test backward over multiple spaces
1057        let props = TextInputProps {
1058            value: "hello   world!",
1059            placeholder: "",
1060            is_focused: true,
1061            style: TextInputStyle::default(),
1062            on_change: TestAction::Change,
1063            on_submit: TestAction::Submit,
1064            on_cursor_move: None,
1065        };
1066
1067        let _: Vec<_> = input
1068            .handle_event(&EventKind::Key(ctrl_arrow(KeyCode::Left)), props)
1069            .into_iter()
1070            .collect();
1071
1072        assert_eq!(input.cursor, 8); // At start of "world"
1073    }
1074}