Skip to main content

tui_dispatch_components/
text_input.rs

1//! Single-line text input component
2
3use std::rc::Rc;
4
5use crossterm::event::{KeyCode, KeyModifiers};
6use ratatui::{
7    layout::Rect,
8    style::{Color, Style},
9    widgets::{Block, Paragraph},
10    Frame,
11};
12use tui_dispatch_core::{Component, EventKind, HandlerResponse};
13
14use crate::commands;
15use crate::style::{BaseStyle, ComponentStyle, Padding};
16use crate::{ComponentDebugEntry, ComponentDebugState, ComponentInput, InteractiveComponent};
17
18/// Unified styling for TextInput
19#[derive(Debug, Clone)]
20pub struct TextInputStyle {
21    /// Shared base style
22    pub base: BaseStyle,
23    /// Style for placeholder text
24    pub placeholder_style: Option<Style>,
25    /// Style for cursor (when focused)
26    pub cursor_style: Option<Style>,
27}
28
29impl Default for TextInputStyle {
30    fn default() -> Self {
31        Self {
32            base: BaseStyle {
33                fg: None,
34                ..Default::default()
35            },
36            placeholder_style: Some(Style::default().fg(Color::DarkGray)),
37            cursor_style: None,
38        }
39    }
40}
41
42impl TextInputStyle {
43    /// Create a style with no border
44    pub fn borderless() -> Self {
45        let mut style = Self::default();
46        style.base.border = None;
47        style
48    }
49
50    /// Create a minimal style (no border, no padding)
51    pub fn minimal() -> Self {
52        let mut style = Self::default();
53        style.base.border = None;
54        style.base.padding = Padding::default();
55        style
56    }
57}
58
59impl ComponentStyle for TextInputStyle {
60    fn base(&self) -> &BaseStyle {
61        &self.base
62    }
63}
64
65/// Callback to create an action when text changes or is submitted.
66pub type TextInputCallback<A> = Rc<dyn Fn(String) -> A>;
67
68/// Callback to create an action when the cursor moves.
69pub type TextInputCursorCallback<A> = Rc<dyn Fn(usize) -> A>;
70
71/// Props for TextInput component
72#[derive(Clone)]
73pub struct TextInputProps<'a, A> {
74    /// Current input value
75    pub value: &'a str,
76    /// Placeholder text when empty
77    pub placeholder: &'a str,
78    /// Whether this component has focus
79    pub is_focused: bool,
80    /// Unified styling
81    pub style: TextInputStyle,
82    /// Callback when value changes
83    pub on_change: TextInputCallback<A>,
84    /// Callback when user submits (Enter or `submit` command)
85    pub on_submit: TextInputCallback<A>,
86    /// Callback when cursor moves without content change
87    pub on_cursor_move: Option<TextInputCursorCallback<A>>,
88    /// Callback when the user runs the `cancel` command
89    pub on_cancel: Option<TextInputCallback<A>>,
90}
91
92/// Render-only props for TextInput
93pub struct TextInputRenderProps<'a> {
94    /// Current input value
95    pub value: &'a str,
96    /// Placeholder text when empty
97    pub placeholder: &'a str,
98    /// Whether this component has focus
99    pub is_focused: bool,
100    /// Unified styling
101    pub style: TextInputStyle,
102}
103
104/// A single-line text input with cursor
105///
106/// Handles typing, backspace, delete, and cursor movement.
107/// Emits on_change for each keystroke, on_submit for Enter,
108/// and on_cursor_move for cursor-only movement if provided.
109#[derive(Default)]
110pub struct TextInput {
111    /// Cursor position (byte index)
112    cursor: usize,
113}
114
115impl TextInput {
116    /// Create a new TextInput
117    pub fn new() -> Self {
118        Self::default()
119    }
120
121    /// Render the widget without requiring update callbacks.
122    pub fn render_widget(
123        &mut self,
124        frame: &mut Frame,
125        area: Rect,
126        props: TextInputRenderProps<'_>,
127    ) {
128        self.render_with(
129            frame,
130            area,
131            props.value,
132            props.placeholder,
133            props.is_focused,
134            props.style,
135        );
136    }
137
138    /// Clamp cursor to valid range for the given value
139    fn clamp_cursor(&mut self, value: &str) {
140        self.cursor = self.cursor.min(value.len());
141    }
142
143    /// Move cursor left by one character
144    fn move_cursor_left(&mut self, value: &str) {
145        if self.cursor > 0 {
146            // Find previous char boundary
147            let mut new_pos = self.cursor - 1;
148            while new_pos > 0 && !value.is_char_boundary(new_pos) {
149                new_pos -= 1;
150            }
151            self.cursor = new_pos;
152        }
153    }
154
155    /// Move cursor right by one character
156    fn move_cursor_right(&mut self, value: &str) {
157        if self.cursor < value.len() {
158            // Find next char boundary
159            let mut new_pos = self.cursor + 1;
160            while new_pos < value.len() && !value.is_char_boundary(new_pos) {
161                new_pos += 1;
162            }
163            self.cursor = new_pos;
164        }
165    }
166
167    /// Insert character at cursor position
168    fn insert_char(&mut self, value: &str, c: char) -> String {
169        let mut new_value = String::with_capacity(value.len() + c.len_utf8());
170        new_value.push_str(&value[..self.cursor]);
171        new_value.push(c);
172        new_value.push_str(&value[self.cursor..]);
173        self.cursor += c.len_utf8();
174        new_value
175    }
176
177    /// Delete character before cursor (backspace)
178    fn delete_char_before(&mut self, value: &str) -> Option<String> {
179        if self.cursor == 0 {
180            return None;
181        }
182
183        let mut new_value = String::with_capacity(value.len());
184        let before_cursor = &value[..self.cursor];
185
186        // Find the previous character boundary
187        let char_start = before_cursor
188            .char_indices()
189            .last()
190            .map(|(i, _)| i)
191            .unwrap_or(0);
192
193        new_value.push_str(&value[..char_start]);
194        new_value.push_str(&value[self.cursor..]);
195        self.cursor = char_start;
196        Some(new_value)
197    }
198
199    /// Delete character at cursor (delete key)
200    fn delete_char_at(&self, value: &str) -> Option<String> {
201        if self.cursor >= value.len() {
202            return None;
203        }
204
205        let mut new_value = String::with_capacity(value.len());
206        new_value.push_str(&value[..self.cursor]);
207
208        // Find the next character boundary
209        let after_cursor = &value[self.cursor..];
210        if let Some((_, c)) = after_cursor.char_indices().next() {
211            new_value.push_str(&value[self.cursor + c.len_utf8()..]);
212        }
213
214        Some(new_value)
215    }
216
217    // ========================================================================
218    // Word-based operations (readline/emacs style)
219    // ========================================================================
220
221    /// Find the byte position of the previous word boundary
222    fn prev_word_boundary(&self, value: &str) -> usize {
223        if self.cursor == 0 {
224            return 0;
225        }
226
227        let before = &value[..self.cursor];
228        let mut chars: Vec<(usize, char)> = before.char_indices().collect();
229
230        // Skip trailing non-word, non-whitespace (punctuation)
231        while let Some(&(_, c)) = chars.last() {
232            if c.is_alphanumeric() || c == '_' || c.is_whitespace() {
233                break;
234            }
235            chars.pop();
236        }
237
238        // Skip whitespace
239        while let Some(&(_, c)) = chars.last() {
240            if !c.is_whitespace() {
241                break;
242            }
243            chars.pop();
244        }
245
246        // Skip word characters (alphanumeric or _)
247        while let Some(&(_, c)) = chars.last() {
248            if !c.is_alphanumeric() && c != '_' {
249                break;
250            }
251            chars.pop();
252        }
253
254        chars.last().map(|&(i, c)| i + c.len_utf8()).unwrap_or(0)
255    }
256
257    /// Find the byte position of the next word boundary
258    fn next_word_boundary(&self, value: &str) -> usize {
259        if self.cursor >= value.len() {
260            return value.len();
261        }
262
263        let after = &value[self.cursor..];
264        let mut pos = self.cursor;
265
266        let mut chars = after.chars().peekable();
267
268        // Skip current word characters
269        while let Some(&c) = chars.peek() {
270            if !c.is_alphanumeric() && c != '_' {
271                break;
272            }
273            pos += c.len_utf8();
274            chars.next();
275        }
276
277        // Skip whitespace
278        while let Some(&c) = chars.peek() {
279            if !c.is_whitespace() {
280                break;
281            }
282            pos += c.len_utf8();
283            chars.next();
284        }
285
286        // If we haven't moved (started on whitespace/punctuation), skip to next word
287        if pos == self.cursor {
288            // Skip non-word, non-whitespace
289            while let Some(&c) = chars.peek() {
290                if c.is_alphanumeric() || c == '_' || c.is_whitespace() {
291                    break;
292                }
293                pos += c.len_utf8();
294                chars.next();
295            }
296            // Skip whitespace
297            while let Some(&c) = chars.peek() {
298                if !c.is_whitespace() {
299                    break;
300                }
301                pos += c.len_utf8();
302                chars.next();
303            }
304        }
305
306        pos
307    }
308
309    /// Move cursor backward by one word
310    fn move_word_backward(&mut self, value: &str) {
311        self.cursor = self.prev_word_boundary(value);
312    }
313
314    /// Move cursor forward by one word
315    fn move_word_forward(&mut self, value: &str) {
316        self.cursor = self.next_word_boundary(value);
317    }
318
319    /// Delete from cursor to end of line (Ctrl+K)
320    fn kill_line(&self, value: &str) -> Option<String> {
321        if self.cursor >= value.len() {
322            return None;
323        }
324        Some(value[..self.cursor].to_string())
325    }
326
327    /// Delete word backward (Ctrl+W / Alt+Backspace)
328    fn kill_word_backward(&mut self, value: &str) -> Option<String> {
329        let boundary = self.prev_word_boundary(value);
330        if boundary == self.cursor {
331            return None;
332        }
333
334        let mut new_value = String::with_capacity(value.len());
335        new_value.push_str(&value[..boundary]);
336        new_value.push_str(&value[self.cursor..]);
337        self.cursor = boundary;
338        Some(new_value)
339    }
340
341    /// Delete word forward (Alt+D)
342    fn kill_word_forward(&self, value: &str) -> Option<String> {
343        let boundary = self.next_word_boundary(value);
344        if boundary == self.cursor {
345            return None;
346        }
347
348        let mut new_value = String::with_capacity(value.len());
349        new_value.push_str(&value[..self.cursor]);
350        new_value.push_str(&value[boundary..]);
351        Some(new_value)
352    }
353
354    /// Transpose characters at cursor (Ctrl+T)
355    fn transpose_chars(&mut self, value: &str) -> Option<String> {
356        // Need at least 2 characters and cursor not at start
357        if value.len() < 2 || self.cursor == 0 {
358            return None;
359        }
360
361        // If at end, transpose last two chars
362        let pos = if self.cursor >= value.len() {
363            // Find start of second-to-last char
364            let mut idx = value.len();
365            let mut count = 0;
366            for (i, _) in value.char_indices().rev() {
367                idx = i;
368                count += 1;
369                if count == 2 {
370                    break;
371                }
372            }
373            idx
374        } else {
375            // Find start of char before cursor
376            let before = &value[..self.cursor];
377            before.char_indices().last().map(|(i, _)| i).unwrap_or(0)
378        };
379
380        // Get the two characters to swap
381        let chars: Vec<char> = value[pos..].chars().take(2).collect();
382        if chars.len() < 2 {
383            return None;
384        }
385
386        let mut new_value = String::with_capacity(value.len());
387        new_value.push_str(&value[..pos]);
388        new_value.push(chars[1]);
389        new_value.push(chars[0]);
390        new_value.push_str(&value[pos + chars[0].len_utf8() + chars[1].len_utf8()..]);
391
392        // Move cursor forward if not at end
393        if self.cursor < value.len() {
394            self.cursor += chars[1].len_utf8();
395        }
396
397        Some(new_value)
398    }
399
400    fn handle_key<A>(
401        &mut self,
402        key: crossterm::event::KeyEvent,
403        props: &TextInputProps<'_, A>,
404    ) -> (Option<A>, bool) {
405        // Ensure cursor is valid for current value
406        self.clamp_cursor(props.value);
407        let cursor_before = self.cursor;
408
409        let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
410        let alt = key.modifiers.contains(KeyModifiers::ALT);
411        let mut did_move = false;
412
413        let action = match (key.code, ctrl, alt) {
414            // ============================================================
415            // Ctrl+key shortcuts (readline/emacs style)
416            // ============================================================
417
418            // Movement
419            (KeyCode::Char('a'), true, false) => {
420                self.cursor = 0;
421                did_move = true;
422                None
423            }
424            (KeyCode::Char('e'), true, false) => {
425                self.cursor = props.value.len();
426                did_move = true;
427                None
428            }
429            (KeyCode::Char('b'), true, false) => {
430                self.move_cursor_left(props.value);
431                did_move = true;
432                None
433            }
434            (KeyCode::Char('f'), true, false) => {
435                self.move_cursor_right(props.value);
436                did_move = true;
437                None
438            }
439
440            // Word movement (Ctrl+Arrow - Mac friendly)
441            (KeyCode::Left, true, false) => {
442                self.move_word_backward(props.value);
443                did_move = true;
444                None
445            }
446            (KeyCode::Right, true, false) => {
447                self.move_word_forward(props.value);
448                did_move = true;
449                None
450            }
451
452            // Deletion
453            (KeyCode::Char('u'), true, false) => {
454                self.cursor = 0;
455                Some((props.on_change.as_ref())(String::new()))
456            }
457            (KeyCode::Char('k'), true, false) => self
458                .kill_line(props.value)
459                .map(|value| (props.on_change.as_ref())(value)),
460            (KeyCode::Char('w'), true, false) => self
461                .kill_word_backward(props.value)
462                .map(|value| (props.on_change.as_ref())(value)),
463            (KeyCode::Char('d'), true, false) => self
464                .delete_char_at(props.value)
465                .map(|value| (props.on_change.as_ref())(value)),
466            (KeyCode::Char('h'), true, false) => self
467                .delete_char_before(props.value)
468                .map(|value| (props.on_change.as_ref())(value)),
469
470            // Transpose
471            (KeyCode::Char('t'), true, false) => self
472                .transpose_chars(props.value)
473                .map(|value| (props.on_change.as_ref())(value)),
474
475            // ============================================================
476            // Alt+key shortcuts (when terminal sends escape sequences)
477            // ============================================================
478
479            // Word movement
480            (KeyCode::Char('b'), false, true) => {
481                self.move_word_backward(props.value);
482                did_move = true;
483                None
484            }
485            (KeyCode::Char('f'), false, true) => {
486                self.move_word_forward(props.value);
487                did_move = true;
488                None
489            }
490
491            // Word deletion
492            (KeyCode::Char('d'), false, true) => self
493                .kill_word_forward(props.value)
494                .map(|value| (props.on_change.as_ref())(value)),
495            (KeyCode::Backspace, false, true) => self
496                .kill_word_backward(props.value)
497                .map(|value| (props.on_change.as_ref())(value)),
498
499            // ============================================================
500            // Basic keys
501            // ============================================================
502
503            // Backspace (no modifiers - Alt+Backspace handled above)
504            (KeyCode::Backspace, false, false) => self
505                .delete_char_before(props.value)
506                .map(|value| (props.on_change.as_ref())(value)),
507
508            // Delete
509            (KeyCode::Delete, _, _) => self
510                .delete_char_at(props.value)
511                .map(|value| (props.on_change.as_ref())(value)),
512
513            // Cursor movement (no Ctrl - Ctrl+Arrow handled above)
514            (KeyCode::Left, false, _) => {
515                self.move_cursor_left(props.value);
516                did_move = true;
517                None
518            }
519            (KeyCode::Right, false, _) => {
520                self.move_cursor_right(props.value);
521                did_move = true;
522                None
523            }
524            (KeyCode::Home, _, _) => {
525                self.cursor = 0;
526                did_move = true;
527                None
528            }
529            (KeyCode::End, _, _) => {
530                self.cursor = props.value.len();
531                did_move = true;
532                None
533            }
534
535            // Submit
536            (KeyCode::Enter, _, _) => Some((props.on_submit.as_ref())(props.value.to_string())),
537
538            // ============================================================
539            // Character input - MUST be last to catch all printable chars
540            // ============================================================
541            // Any character not handled above gets inserted (space, etc.)
542            (KeyCode::Char(c), _, _) => {
543                let new_value = self.insert_char(props.value, c);
544                Some((props.on_change.as_ref())(new_value))
545            }
546
547            _ => None,
548        };
549
550        let cursor_moved = self.cursor != cursor_before;
551        let action = if action.is_none() && did_move && cursor_moved {
552            props
553                .on_cursor_move
554                .as_ref()
555                .map(|callback| callback(self.cursor))
556        } else {
557            action
558        };
559
560        (action, cursor_moved)
561    }
562
563    /// Handle a `ComponentInput::Command`.
564    ///
565    /// Recognised commands are defined in [`commands::text_input`]. Unknown
566    /// commands are ignored.
567    fn handle_command<A>(
568        &mut self,
569        name: &str,
570        props: &TextInputProps<'_, A>,
571    ) -> (Option<A>, bool) {
572        self.clamp_cursor(props.value);
573        let cursor_before = self.cursor;
574        let mut did_move = false;
575
576        use commands::text_input as cmd;
577
578        let action = match name {
579            cmd::MOVE_BACKWARD | cmd::MOVE_LEFT => {
580                self.move_cursor_left(props.value);
581                did_move = true;
582                None
583            }
584            cmd::MOVE_FORWARD | cmd::MOVE_RIGHT => {
585                self.move_cursor_right(props.value);
586                did_move = true;
587                None
588            }
589            cmd::MOVE_WORD_BACKWARD | cmd::MOVE_WORD_LEFT => {
590                self.move_word_backward(props.value);
591                did_move = true;
592                None
593            }
594            cmd::MOVE_WORD_FORWARD | cmd::MOVE_WORD_RIGHT => {
595                self.move_word_forward(props.value);
596                did_move = true;
597                None
598            }
599            cmd::MOVE_HOME => {
600                self.cursor = 0;
601                did_move = true;
602                None
603            }
604            cmd::MOVE_END => {
605                self.cursor = props.value.len();
606                did_move = true;
607                None
608            }
609            cmd::DELETE_BACKWARD | cmd::DELETE_LEFT => self
610                .delete_char_before(props.value)
611                .map(|value| (props.on_change.as_ref())(value)),
612            cmd::DELETE_FORWARD | cmd::DELETE_RIGHT => self
613                .delete_char_at(props.value)
614                .map(|value| (props.on_change.as_ref())(value)),
615            cmd::DELETE_WORD_BACKWARD | cmd::DELETE_WORD_LEFT => self
616                .kill_word_backward(props.value)
617                .map(|value| (props.on_change.as_ref())(value)),
618            cmd::DELETE_WORD_FORWARD | cmd::DELETE_WORD_RIGHT => self
619                .kill_word_forward(props.value)
620                .map(|value| (props.on_change.as_ref())(value)),
621            cmd::SUBMIT => Some((props.on_submit.as_ref())(props.value.to_string())),
622            cmd::CANCEL => props
623                .on_cancel
624                .as_ref()
625                .map(|cb| cb(props.value.to_string())),
626            _ => None,
627        };
628
629        let cursor_moved = self.cursor != cursor_before;
630        let action = if action.is_none() && did_move && cursor_moved {
631            props
632                .on_cursor_move
633                .as_ref()
634                .map(|callback| callback(self.cursor))
635        } else {
636            action
637        };
638
639        (action, cursor_moved)
640    }
641
642    fn render_with(
643        &mut self,
644        frame: &mut Frame,
645        area: Rect,
646        value: &str,
647        placeholder: &str,
648        is_focused: bool,
649        style: TextInputStyle,
650    ) {
651        let style = &style;
652
653        // Ensure cursor is valid
654        self.clamp_cursor(value);
655
656        // Fill background if color provided
657        if let Some(bg) = style.base.bg {
658            for y in area.y..area.y.saturating_add(area.height) {
659                for x in area.x..area.x.saturating_add(area.width) {
660                    frame.buffer_mut()[(x, y)].set_bg(bg);
661                    frame.buffer_mut()[(x, y)].set_symbol(" ");
662                }
663            }
664        }
665
666        // Apply padding
667        let content_area = Rect {
668            x: area.x + style.base.padding.left,
669            y: area.y + style.base.padding.top,
670            width: area.width.saturating_sub(style.base.padding.horizontal()),
671            height: area.height.saturating_sub(style.base.padding.vertical()),
672        };
673
674        // Determine display text
675        let display_text = if value.is_empty() { placeholder } else { value };
676
677        // Build text style
678        let mut text_style = if value.is_empty() {
679            style
680                .placeholder_style
681                .unwrap_or_else(|| Style::default().fg(Color::DarkGray))
682        } else {
683            let mut s = Style::default();
684            if let Some(fg) = style.base.fg {
685                s = s.fg(fg);
686            }
687            s
688        };
689
690        // Preserve background color in text style
691        if let Some(bg) = style.base.bg {
692            text_style = text_style.bg(bg);
693        }
694
695        let mut paragraph = Paragraph::new(display_text).style(text_style);
696
697        if let Some(border) = &style.base.border {
698            paragraph = paragraph.block(
699                Block::default()
700                    .borders(border.borders)
701                    .border_style(border.style_for_focus(is_focused)),
702            );
703        }
704
705        frame.render_widget(paragraph, content_area);
706
707        // Show cursor if focused
708        if is_focused {
709            // Calculate cursor screen position (account for border and padding)
710            let border_offset = if style.base.border.is_some() { 1 } else { 0 };
711            let cursor_x = content_area.x + border_offset + self.cursor as u16;
712            let cursor_y = content_area.y + border_offset;
713
714            // Only show cursor if within bounds
715            let max_x = if style.base.border.is_some() {
716                content_area.x + content_area.width - 1
717            } else {
718                content_area.x + content_area.width
719            };
720            if cursor_x < max_x {
721                if let Some(cursor_style) = style.cursor_style {
722                    frame.buffer_mut()[(cursor_x, cursor_y)].set_style(cursor_style);
723                }
724                frame.set_cursor_position((cursor_x, cursor_y));
725            }
726        }
727    }
728}
729
730impl<A> Component<A> for TextInput {
731    type Props<'a> = TextInputProps<'a, A>;
732
733    fn handle_event(
734        &mut self,
735        event: &EventKind,
736        props: Self::Props<'_>,
737    ) -> impl IntoIterator<Item = A> {
738        if !props.is_focused {
739            return None;
740        }
741
742        match event {
743            EventKind::Key(key) => self.handle_key(*key, &props).0,
744            _ => None,
745        }
746    }
747
748    fn render(&mut self, frame: &mut Frame, area: Rect, props: Self::Props<'_>) {
749        self.render_with(
750            frame,
751            area,
752            props.value,
753            props.placeholder,
754            props.is_focused,
755            props.style,
756        );
757    }
758}
759
760impl ComponentDebugState for TextInput {
761    fn debug_state(&self) -> Vec<ComponentDebugEntry> {
762        vec![ComponentDebugEntry::new("cursor", self.cursor.to_string())]
763    }
764}
765
766impl<A, Ctx> InteractiveComponent<A, Ctx> for TextInput {
767    type Props<'a> = TextInputProps<'a, A>;
768
769    fn update(
770        &mut self,
771        input: ComponentInput<'_, Ctx>,
772        props: Self::Props<'_>,
773    ) -> HandlerResponse<A> {
774        if !props.is_focused {
775            return HandlerResponse::ignored();
776        }
777
778        let (action, local_changed) = match input {
779            ComponentInput::Command { name, .. } => self.handle_command(name, &props),
780            ComponentInput::Key(key) => self.handle_key(key, &props),
781            _ => return HandlerResponse::ignored(),
782        };
783
784        let mut response = match action {
785            Some(action) => HandlerResponse::action(action),
786            None if local_changed => HandlerResponse::ignored().with_consumed(true),
787            None => HandlerResponse::ignored(),
788        };
789
790        if local_changed {
791            response = response.with_render();
792        }
793
794        response
795    }
796
797    fn render(&mut self, frame: &mut Frame, area: Rect, props: Self::Props<'_>) {
798        <Self as Component<A>>::render(self, frame, area, props);
799    }
800}
801
802#[cfg(test)]
803mod tests {
804    use super::*;
805    use tui_dispatch_core::testing::{key, RenderHarness};
806
807    #[derive(Debug, Clone, PartialEq)]
808    enum TestAction {
809        Change(String),
810        Submit(String),
811    }
812
813    #[test]
814    fn test_typing() {
815        let mut input = TextInput::new();
816        let props = TextInputProps {
817            value: "",
818            placeholder: "",
819            is_focused: true,
820            style: TextInputStyle::default(),
821            on_change: Rc::new(TestAction::Change),
822            on_submit: Rc::new(TestAction::Submit),
823            on_cursor_move: None,
824            on_cancel: None,
825        };
826
827        let actions: Vec<_> = input
828            .handle_event(&EventKind::Key(key("a")), props)
829            .into_iter()
830            .collect();
831
832        assert_eq!(actions, vec![TestAction::Change("a".into())]);
833    }
834
835    #[test]
836    fn test_typing_space() {
837        let mut input = TextInput::new();
838        input.cursor = 5; // After "hello"
839
840        let props = TextInputProps {
841            value: "hello",
842            placeholder: "",
843            is_focused: true,
844            style: TextInputStyle::default(),
845            on_change: Rc::new(TestAction::Change),
846            on_submit: Rc::new(TestAction::Submit),
847            on_cursor_move: None,
848            on_cancel: None,
849        };
850
851        // Space character
852        let space_key = crossterm::event::KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE);
853
854        let actions: Vec<_> = input
855            .handle_event(&EventKind::Key(space_key), props)
856            .into_iter()
857            .collect();
858
859        assert_eq!(actions, vec![TestAction::Change("hello ".into())]);
860    }
861
862    #[test]
863    fn test_typing_appends() {
864        let mut input = TextInput::new();
865        input.cursor = 5; // At end of "hello"
866
867        let props = TextInputProps {
868            value: "hello",
869            placeholder: "",
870            is_focused: true,
871            style: TextInputStyle::default(),
872            on_change: Rc::new(TestAction::Change),
873            on_submit: Rc::new(TestAction::Submit),
874            on_cursor_move: None,
875            on_cancel: None,
876        };
877
878        let actions: Vec<_> = input
879            .handle_event(&EventKind::Key(key("!")), props)
880            .into_iter()
881            .collect();
882
883        assert_eq!(actions, vec![TestAction::Change("hello!".into())]);
884    }
885
886    #[test]
887    fn test_backspace() {
888        let mut input = TextInput::new();
889        input.cursor = 5;
890
891        let props = TextInputProps {
892            value: "hello",
893            placeholder: "",
894            is_focused: true,
895            style: TextInputStyle::default(),
896            on_change: Rc::new(TestAction::Change),
897            on_submit: Rc::new(TestAction::Submit),
898            on_cursor_move: None,
899            on_cancel: None,
900        };
901
902        let actions: Vec<_> = input
903            .handle_event(&EventKind::Key(key("backspace")), props)
904            .into_iter()
905            .collect();
906
907        assert_eq!(actions, vec![TestAction::Change("hell".into())]);
908        assert_eq!(input.cursor, 4);
909    }
910
911    #[test]
912    fn test_backspace_at_start() {
913        let mut input = TextInput::new();
914        input.cursor = 0;
915
916        let props = TextInputProps {
917            value: "hello",
918            placeholder: "",
919            is_focused: true,
920            style: TextInputStyle::default(),
921            on_change: Rc::new(TestAction::Change),
922            on_submit: Rc::new(TestAction::Submit),
923            on_cursor_move: None,
924            on_cancel: None,
925        };
926
927        let actions: Vec<_> = input
928            .handle_event(&EventKind::Key(key("backspace")), props)
929            .into_iter()
930            .collect();
931
932        assert!(actions.is_empty());
933    }
934
935    #[test]
936    fn test_submit() {
937        let mut input = TextInput::new();
938
939        let props = TextInputProps {
940            value: "hello",
941            placeholder: "",
942            is_focused: true,
943            style: TextInputStyle::default(),
944            on_change: Rc::new(TestAction::Change),
945            on_submit: Rc::new(TestAction::Submit),
946            on_cursor_move: None,
947            on_cancel: None,
948        };
949
950        let actions: Vec<_> = input
951            .handle_event(&EventKind::Key(key("enter")), props)
952            .into_iter()
953            .collect();
954
955        assert_eq!(actions, vec![TestAction::Submit("hello".into())]);
956    }
957
958    #[test]
959    fn test_unfocused_ignores() {
960        let mut input = TextInput::new();
961
962        let props = TextInputProps {
963            value: "",
964            placeholder: "",
965            is_focused: false,
966            style: TextInputStyle::default(),
967            on_change: Rc::new(TestAction::Change),
968            on_submit: Rc::new(TestAction::Submit),
969            on_cursor_move: None,
970            on_cancel: None,
971        };
972
973        let actions: Vec<_> = input
974            .handle_event(&EventKind::Key(key("a")), props)
975            .into_iter()
976            .collect();
977
978        assert!(actions.is_empty());
979    }
980
981    #[test]
982    fn test_render_with_value() {
983        let mut render = RenderHarness::new(30, 3);
984        let mut input = TextInput::new();
985
986        let output = render.render_to_string_plain(|frame| {
987            let props = TextInputProps {
988                value: "hello",
989                placeholder: "Type here...",
990                is_focused: true,
991                style: TextInputStyle::default(),
992                on_change: Rc::new(|_| ()),
993                on_submit: Rc::new(|_| ()),
994                on_cursor_move: None,
995                on_cancel: None,
996            };
997            <TextInput as Component<()>>::render(&mut input, frame, frame.area(), props);
998        });
999
1000        assert!(output.contains("hello"));
1001    }
1002
1003    #[test]
1004    fn test_render_placeholder() {
1005        let mut render = RenderHarness::new(30, 3);
1006        let mut input = TextInput::new();
1007
1008        let output = render.render_to_string_plain(|frame| {
1009            let props = TextInputProps {
1010                value: "",
1011                placeholder: "Type here...",
1012                is_focused: true,
1013                style: TextInputStyle::default(),
1014                on_change: Rc::new(|_| ()),
1015                on_submit: Rc::new(|_| ()),
1016                on_cursor_move: None,
1017                on_cancel: None,
1018            };
1019            <TextInput as Component<()>>::render(&mut input, frame, frame.area(), props);
1020        });
1021
1022        assert!(output.contains("Type here..."));
1023    }
1024
1025    #[test]
1026    fn test_render_with_custom_style() {
1027        let mut render = RenderHarness::new(30, 3);
1028        let mut input = TextInput::new();
1029
1030        let output = render.render_to_string_plain(|frame| {
1031            let props = TextInputProps {
1032                value: "test",
1033                placeholder: "",
1034                is_focused: true,
1035                style: TextInputStyle {
1036                    base: BaseStyle {
1037                        border: None,
1038                        padding: Padding::xy(1, 0),
1039                        bg: Some(Color::Blue),
1040                        fg: Some(Color::White),
1041                    },
1042                    placeholder_style: None,
1043                    cursor_style: None,
1044                },
1045                on_change: Rc::new(|_| ()),
1046                on_submit: Rc::new(|_| ()),
1047                on_cursor_move: None,
1048                on_cancel: None,
1049            };
1050            <TextInput as Component<()>>::render(&mut input, frame, frame.area(), props);
1051        });
1052
1053        assert!(output.contains("test"));
1054    }
1055
1056    // ========================================================================
1057    // Readline/Emacs keybinding tests
1058    // ========================================================================
1059
1060    fn ctrl_key(c: char) -> crossterm::event::KeyEvent {
1061        crossterm::event::KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
1062    }
1063
1064    fn alt_key(c: char) -> crossterm::event::KeyEvent {
1065        crossterm::event::KeyEvent::new(KeyCode::Char(c), KeyModifiers::ALT)
1066    }
1067
1068    fn ctrl_arrow(code: KeyCode) -> crossterm::event::KeyEvent {
1069        crossterm::event::KeyEvent::new(code, KeyModifiers::CONTROL)
1070    }
1071
1072    #[test]
1073    fn test_ctrl_k_kill_line() {
1074        let mut input = TextInput::new();
1075        input.cursor = 5; // After "hello"
1076
1077        let props = TextInputProps {
1078            value: "hello world",
1079            placeholder: "",
1080            is_focused: true,
1081            style: TextInputStyle::default(),
1082            on_change: Rc::new(TestAction::Change),
1083            on_submit: Rc::new(TestAction::Submit),
1084            on_cursor_move: None,
1085            on_cancel: None,
1086        };
1087
1088        let actions: Vec<_> = input
1089            .handle_event(&EventKind::Key(ctrl_key('k')), props)
1090            .into_iter()
1091            .collect();
1092
1093        assert_eq!(actions, vec![TestAction::Change("hello".into())]);
1094    }
1095
1096    #[test]
1097    fn test_ctrl_w_kill_word_backward() {
1098        let mut input = TextInput::new();
1099        input.cursor = 11; // At end of "hello world"
1100
1101        let props = TextInputProps {
1102            value: "hello world",
1103            placeholder: "",
1104            is_focused: true,
1105            style: TextInputStyle::default(),
1106            on_change: Rc::new(TestAction::Change),
1107            on_submit: Rc::new(TestAction::Submit),
1108            on_cursor_move: None,
1109            on_cancel: None,
1110        };
1111
1112        let actions: Vec<_> = input
1113            .handle_event(&EventKind::Key(ctrl_key('w')), props)
1114            .into_iter()
1115            .collect();
1116
1117        assert_eq!(actions, vec![TestAction::Change("hello ".into())]);
1118        assert_eq!(input.cursor, 6);
1119    }
1120
1121    #[test]
1122    fn test_ctrl_left_word_backward() {
1123        let mut input = TextInput::new();
1124        input.cursor = 11; // At end of "hello world"
1125
1126        let props = TextInputProps {
1127            value: "hello world",
1128            placeholder: "",
1129            is_focused: true,
1130            style: TextInputStyle::default(),
1131            on_change: Rc::new(TestAction::Change),
1132            on_submit: Rc::new(TestAction::Submit),
1133            on_cursor_move: None,
1134            on_cancel: None,
1135        };
1136
1137        let actions: Vec<_> = input
1138            .handle_event(&EventKind::Key(ctrl_arrow(KeyCode::Left)), props)
1139            .into_iter()
1140            .collect();
1141
1142        assert!(actions.is_empty()); // Movement doesn't emit action
1143        assert_eq!(input.cursor, 6); // At start of "world"
1144    }
1145
1146    #[test]
1147    fn test_ctrl_right_word_forward() {
1148        let mut input = TextInput::new();
1149        input.cursor = 0;
1150
1151        let props = TextInputProps {
1152            value: "hello world",
1153            placeholder: "",
1154            is_focused: true,
1155            style: TextInputStyle::default(),
1156            on_change: Rc::new(TestAction::Change),
1157            on_submit: Rc::new(TestAction::Submit),
1158            on_cursor_move: None,
1159            on_cancel: None,
1160        };
1161
1162        let actions: Vec<_> = input
1163            .handle_event(&EventKind::Key(ctrl_arrow(KeyCode::Right)), props)
1164            .into_iter()
1165            .collect();
1166
1167        assert!(actions.is_empty());
1168        assert_eq!(input.cursor, 6); // After "hello "
1169    }
1170
1171    #[test]
1172    fn test_alt_d_kill_word_forward() {
1173        let mut input = TextInput::new();
1174        input.cursor = 0;
1175
1176        let props = TextInputProps {
1177            value: "hello world",
1178            placeholder: "",
1179            is_focused: true,
1180            style: TextInputStyle::default(),
1181            on_change: Rc::new(TestAction::Change),
1182            on_submit: Rc::new(TestAction::Submit),
1183            on_cursor_move: None,
1184            on_cancel: None,
1185        };
1186
1187        let actions: Vec<_> = input
1188            .handle_event(&EventKind::Key(alt_key('d')), props)
1189            .into_iter()
1190            .collect();
1191
1192        assert_eq!(actions, vec![TestAction::Change("world".into())]);
1193    }
1194
1195    #[test]
1196    fn test_ctrl_t_transpose() {
1197        let mut input = TextInput::new();
1198        input.cursor = 2; // Between 'e' and 'l' in "hello"
1199
1200        let props = TextInputProps {
1201            value: "hello",
1202            placeholder: "",
1203            is_focused: true,
1204            style: TextInputStyle::default(),
1205            on_change: Rc::new(TestAction::Change),
1206            on_submit: Rc::new(TestAction::Submit),
1207            on_cursor_move: None,
1208            on_cancel: None,
1209        };
1210
1211        let actions: Vec<_> = input
1212            .handle_event(&EventKind::Key(ctrl_key('t')), props)
1213            .into_iter()
1214            .collect();
1215
1216        assert_eq!(actions, vec![TestAction::Change("hlelo".into())]);
1217    }
1218
1219    #[test]
1220    fn test_ctrl_b_f_movement() {
1221        let mut input = TextInput::new();
1222        input.cursor = 5;
1223
1224        let props = TextInputProps {
1225            value: "hello world",
1226            placeholder: "",
1227            is_focused: true,
1228            style: TextInputStyle::default(),
1229            on_change: Rc::new(TestAction::Change),
1230            on_submit: Rc::new(TestAction::Submit),
1231            on_cursor_move: None,
1232            on_cancel: None,
1233        };
1234
1235        // Ctrl+B moves back
1236        let _: Vec<_> = input
1237            .handle_event(&EventKind::Key(ctrl_key('b')), props)
1238            .into_iter()
1239            .collect();
1240        assert_eq!(input.cursor, 4);
1241
1242        // Ctrl+F moves forward
1243        let props = TextInputProps {
1244            value: "hello world",
1245            placeholder: "",
1246            is_focused: true,
1247            style: TextInputStyle::default(),
1248            on_change: Rc::new(TestAction::Change),
1249            on_submit: Rc::new(TestAction::Submit),
1250            on_cursor_move: None,
1251            on_cancel: None,
1252        };
1253        let _: Vec<_> = input
1254            .handle_event(&EventKind::Key(ctrl_key('f')), props)
1255            .into_iter()
1256            .collect();
1257        assert_eq!(input.cursor, 5);
1258    }
1259
1260    #[test]
1261    fn test_word_boundary_multiple_spaces() {
1262        let mut input = TextInput::new();
1263        input.cursor = 14; // At end of "hello   world" (3 spaces)
1264
1265        // Test backward over multiple spaces
1266        let props = TextInputProps {
1267            value: "hello   world!",
1268            placeholder: "",
1269            is_focused: true,
1270            style: TextInputStyle::default(),
1271            on_change: Rc::new(TestAction::Change),
1272            on_submit: Rc::new(TestAction::Submit),
1273            on_cursor_move: None,
1274            on_cancel: None,
1275        };
1276
1277        let _: Vec<_> = input
1278            .handle_event(&EventKind::Key(ctrl_arrow(KeyCode::Left)), props)
1279            .into_iter()
1280            .collect();
1281
1282        assert_eq!(input.cursor, 8); // At start of "world"
1283    }
1284
1285    // ========================================================================
1286    // ComponentInput::Command tests (command-aware TextInput)
1287    // ========================================================================
1288
1289    fn command_props<'a>(value: &'a str) -> TextInputProps<'a, TestAction> {
1290        TextInputProps {
1291            value,
1292            placeholder: "",
1293            is_focused: true,
1294            style: TextInputStyle::default(),
1295            on_change: Rc::new(TestAction::Change),
1296            on_submit: Rc::new(TestAction::Submit),
1297            on_cursor_move: None,
1298            on_cancel: None,
1299        }
1300    }
1301
1302    fn run_command<'a>(
1303        input: &mut TextInput,
1304        name: &str,
1305        props: TextInputProps<'a, TestAction>,
1306    ) -> HandlerResponse<TestAction> {
1307        <TextInput as InteractiveComponent<TestAction, ()>>::update(
1308            input,
1309            ComponentInput::Command { name, ctx: () },
1310            props,
1311        )
1312    }
1313
1314    #[test]
1315    fn command_move_forward_backward() {
1316        let mut input = TextInput::new();
1317        input.cursor = 3;
1318
1319        let response = run_command(&mut input, "move_backward", command_props("hello"));
1320        assert!(response.actions.is_empty());
1321        assert!(response.consumed);
1322        assert!(response.needs_render);
1323        assert_eq!(input.cursor, 2);
1324
1325        let response = run_command(&mut input, "move_forward", command_props("hello"));
1326        assert!(response.actions.is_empty());
1327        assert_eq!(input.cursor, 3);
1328    }
1329
1330    #[test]
1331    fn command_move_word_forward_backward() {
1332        let mut input = TextInput::new();
1333        input.cursor = 11;
1334
1335        let response = run_command(
1336            &mut input,
1337            "move_word_backward",
1338            command_props("hello world"),
1339        );
1340        assert!(response.actions.is_empty());
1341        assert_eq!(input.cursor, 6);
1342
1343        let response = run_command(
1344            &mut input,
1345            "move_word_forward",
1346            command_props("hello world"),
1347        );
1348        assert!(response.actions.is_empty());
1349        assert_eq!(input.cursor, 11);
1350    }
1351
1352    #[test]
1353    fn command_move_home_end() {
1354        let mut input = TextInput::new();
1355        input.cursor = 3;
1356
1357        let response = run_command(&mut input, "move_home", command_props("hello"));
1358        assert!(response.actions.is_empty());
1359        assert_eq!(input.cursor, 0);
1360
1361        let response = run_command(&mut input, "move_end", command_props("hello"));
1362        assert!(response.actions.is_empty());
1363        assert_eq!(input.cursor, 5);
1364    }
1365
1366    #[test]
1367    fn command_delete_backward_forward() {
1368        let mut input = TextInput::new();
1369        input.cursor = 3;
1370
1371        let response = run_command(&mut input, "delete_backward", command_props("hello"));
1372        assert_eq!(response.actions, vec![TestAction::Change("helo".into())]);
1373        assert_eq!(input.cursor, 2);
1374
1375        let response = run_command(&mut input, "delete_forward", command_props("helo"));
1376        assert_eq!(response.actions, vec![TestAction::Change("heo".into())]);
1377        assert_eq!(input.cursor, 2);
1378    }
1379
1380    #[test]
1381    fn command_delete_word_backward_forward() {
1382        let mut input = TextInput::new();
1383        input.cursor = 11;
1384
1385        let response = run_command(
1386            &mut input,
1387            "delete_word_backward",
1388            command_props("hello world"),
1389        );
1390        assert_eq!(response.actions, vec![TestAction::Change("hello ".into())]);
1391        assert_eq!(input.cursor, 6);
1392
1393        let mut input = TextInput::new();
1394        input.cursor = 0;
1395        let response = run_command(
1396            &mut input,
1397            "delete_word_forward",
1398            command_props("hello world"),
1399        );
1400        assert_eq!(response.actions, vec![TestAction::Change("world".into())]);
1401    }
1402
1403    #[test]
1404    fn command_directional_aliases_work() {
1405        let mut input = TextInput::new();
1406        input.cursor = 3;
1407
1408        let response = run_command(
1409            &mut input,
1410            crate::commands::text_input::MOVE_LEFT,
1411            command_props("hello"),
1412        );
1413        assert!(response.actions.is_empty());
1414        assert_eq!(input.cursor, 2);
1415
1416        let response = run_command(
1417            &mut input,
1418            crate::commands::text_input::DELETE_RIGHT,
1419            command_props("hello"),
1420        );
1421        assert_eq!(response.actions, vec![TestAction::Change("helo".into())]);
1422        assert_eq!(input.cursor, 2);
1423    }
1424
1425    #[test]
1426    fn command_submit() {
1427        let mut input = TextInput::new();
1428        let response = run_command(&mut input, "submit", command_props("hello"));
1429        assert_eq!(response.actions, vec![TestAction::Submit("hello".into())]);
1430    }
1431
1432    #[test]
1433    fn command_cancel_emits_when_callback_set() {
1434        let mut input = TextInput::new();
1435        let mut props = command_props("hello");
1436        props.on_cancel = Some(Rc::new(|_| TestAction::Submit("cancelled".into())));
1437
1438        let response = run_command(&mut input, "cancel", props);
1439        assert_eq!(
1440            response.actions,
1441            vec![TestAction::Submit("cancelled".into())]
1442        );
1443    }
1444
1445    #[test]
1446    fn command_cancel_ignored_without_callback() {
1447        let mut input = TextInput::new();
1448        let response = run_command(&mut input, "cancel", command_props("hello"));
1449        assert!(response.actions.is_empty());
1450        assert!(!response.consumed);
1451        assert!(!response.needs_render);
1452    }
1453
1454    #[test]
1455    fn command_unknown_is_ignored() {
1456        let mut input = TextInput::new();
1457        let response = run_command(&mut input, "totally_made_up", command_props("hello"));
1458        assert!(response.actions.is_empty());
1459        assert!(!response.consumed);
1460        assert!(!response.needs_render);
1461    }
1462
1463    #[test]
1464    fn command_unfocused_returns_ignored() {
1465        let mut input = TextInput::new();
1466        let mut props = command_props("hello");
1467        props.is_focused = false;
1468        let response = run_command(&mut input, "submit", props);
1469        assert!(response.actions.is_empty());
1470    }
1471
1472    #[test]
1473    fn command_movement_emits_on_cursor_move_when_set() {
1474        let mut input = TextInput::new();
1475        input.cursor = 3;
1476
1477        let mut props = command_props("hello");
1478        props.on_cursor_move = Some(Rc::new(|pos: usize| TestAction::Change(format!("@{pos}"))));
1479
1480        let response = run_command(&mut input, "move_backward", props);
1481        assert_eq!(
1482            response.actions,
1483            vec![TestAction::Change("@2".into())],
1484            "on_cursor_move should fire when movement command actually moves"
1485        );
1486    }
1487
1488    #[test]
1489    fn command_movement_at_boundary_does_not_emit_on_cursor_move() {
1490        let mut input = TextInput::new();
1491
1492        let mut props = command_props("hello");
1493        props.on_cursor_move = Some(Rc::new(|pos: usize| TestAction::Change(format!("@{pos}"))));
1494
1495        let response = run_command(&mut input, "move_backward", props);
1496        assert!(response.actions.is_empty());
1497        assert!(!response.consumed);
1498        assert!(!response.needs_render);
1499        assert_eq!(input.cursor, 0);
1500    }
1501}