1use 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#[derive(Debug, Clone)]
20pub struct TextInputStyle {
21 pub base: BaseStyle,
23 pub placeholder_style: Option<Style>,
25 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 pub fn borderless() -> Self {
45 let mut style = Self::default();
46 style.base.border = None;
47 style
48 }
49
50 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
65pub type TextInputCallback<A> = Rc<dyn Fn(String) -> A>;
67
68pub type TextInputCursorCallback<A> = Rc<dyn Fn(usize) -> A>;
70
71#[derive(Clone)]
73pub struct TextInputProps<'a, A> {
74 pub value: &'a str,
76 pub placeholder: &'a str,
78 pub is_focused: bool,
80 pub style: TextInputStyle,
82 pub on_change: TextInputCallback<A>,
84 pub on_submit: TextInputCallback<A>,
86 pub on_cursor_move: Option<TextInputCursorCallback<A>>,
88 pub on_cancel: Option<TextInputCallback<A>>,
90}
91
92pub struct TextInputRenderProps<'a> {
94 pub value: &'a str,
96 pub placeholder: &'a str,
98 pub is_focused: bool,
100 pub style: TextInputStyle,
102}
103
104#[derive(Default)]
110pub struct TextInput {
111 cursor: usize,
113}
114
115impl TextInput {
116 pub fn new() -> Self {
118 Self::default()
119 }
120
121 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 fn clamp_cursor(&mut self, value: &str) {
140 self.cursor = self.cursor.min(value.len());
141 }
142
143 fn move_cursor_left(&mut self, value: &str) {
145 if self.cursor > 0 {
146 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 fn move_cursor_right(&mut self, value: &str) {
157 if self.cursor < value.len() {
158 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 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 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 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 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 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 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 while let Some(&(_, c)) = chars.last() {
232 if c.is_alphanumeric() || c == '_' || c.is_whitespace() {
233 break;
234 }
235 chars.pop();
236 }
237
238 while let Some(&(_, c)) = chars.last() {
240 if !c.is_whitespace() {
241 break;
242 }
243 chars.pop();
244 }
245
246 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 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 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 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 pos == self.cursor {
288 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 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 fn move_word_backward(&mut self, value: &str) {
311 self.cursor = self.prev_word_boundary(value);
312 }
313
314 fn move_word_forward(&mut self, value: &str) {
316 self.cursor = self.next_word_boundary(value);
317 }
318
319 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 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 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 fn transpose_chars(&mut self, value: &str) -> Option<String> {
356 if value.len() < 2 || self.cursor == 0 {
358 return None;
359 }
360
361 let pos = if self.cursor >= value.len() {
363 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 let before = &value[..self.cursor];
377 before.char_indices().last().map(|(i, _)| i).unwrap_or(0)
378 };
379
380 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 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 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 (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 (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 (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 (KeyCode::Char('t'), true, false) => self
472 .transpose_chars(props.value)
473 .map(|value| (props.on_change.as_ref())(value)),
474
475 (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 (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 (KeyCode::Backspace, false, false) => self
505 .delete_char_before(props.value)
506 .map(|value| (props.on_change.as_ref())(value)),
507
508 (KeyCode::Delete, _, _) => self
510 .delete_char_at(props.value)
511 .map(|value| (props.on_change.as_ref())(value)),
512
513 (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 (KeyCode::Enter, _, _) => Some((props.on_submit.as_ref())(props.value.to_string())),
537
538 (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 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 self.clamp_cursor(value);
655
656 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 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 let display_text = if value.is_empty() { placeholder } else { value };
676
677 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 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 if is_focused {
709 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 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; 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 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; 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 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; 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; 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; 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()); assert_eq!(input.cursor, 6); }
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); }
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; 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 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 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; 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); }
1284
1285 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}