1use 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#[derive(Debug, Clone)]
16pub struct TextInputStyle {
17 pub base: BaseStyle,
19 pub placeholder_style: Option<Style>,
21 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 pub fn borderless() -> Self {
41 let mut style = Self::default();
42 style.base.border = None;
43 style
44 }
45
46 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
61pub struct TextInputProps<'a, A> {
63 pub value: &'a str,
65 pub placeholder: &'a str,
67 pub is_focused: bool,
69 pub style: TextInputStyle,
71 pub on_change: fn(String) -> A,
73 pub on_submit: fn(String) -> A,
75 pub on_cursor_move: Option<fn(usize) -> A>,
77}
78
79#[derive(Default)]
85pub struct TextInput {
86 cursor: usize,
88}
89
90impl TextInput {
91 pub fn new() -> Self {
93 Self::default()
94 }
95
96 fn clamp_cursor(&mut self, value: &str) {
98 self.cursor = self.cursor.min(value.len());
99 }
100
101 fn move_cursor_left(&mut self, value: &str) {
103 if self.cursor > 0 {
104 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 fn move_cursor_right(&mut self, value: &str) {
115 if self.cursor < value.len() {
116 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 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 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 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 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 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 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 while let Some(&(_, c)) = chars.last() {
190 if c.is_alphanumeric() || c == '_' || c.is_whitespace() {
191 break;
192 }
193 chars.pop();
194 }
195
196 while let Some(&(_, c)) = chars.last() {
198 if !c.is_whitespace() {
199 break;
200 }
201 chars.pop();
202 }
203
204 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 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 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 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 pos == self.cursor {
246 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 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 fn move_word_backward(&mut self, value: &str) {
269 self.cursor = self.prev_word_boundary(value);
270 }
271
272 fn move_word_forward(&mut self, value: &str) {
274 self.cursor = self.next_word_boundary(value);
275 }
276
277 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 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 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 fn transpose_chars(&mut self, value: &str) -> Option<String> {
314 if value.len() < 2 || self.cursor == 0 {
316 return None;
317 }
318
319 let pos = if self.cursor >= value.len() {
321 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 let before = &value[..self.cursor];
335 before.char_indices().last().map(|(i, _)| i).unwrap_or(0)
336 };
337
338 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 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 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 (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 (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 (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 (KeyCode::Char('t'), true, false) => self
439 .transpose_chars(props.value)
440 .map(|v| (props.on_change)(v)),
441
442 (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 (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 (KeyCode::Backspace, false, false) => self
472 .delete_char_before(props.value)
473 .map(|v| (props.on_change)(v)),
474
475 (KeyCode::Delete, _, _) => self
477 .delete_char_at(props.value)
478 .map(|v| (props.on_change)(v)),
479
480 (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 (KeyCode::Enter, _, _) => Some((props.on_submit)(props.value.to_string())),
504
505 (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 self.clamp_cursor(props.value);
532
533 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 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 let display_text = if props.value.is_empty() {
553 props.placeholder
554 } else {
555 props.value
556 };
557
558 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 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 if props.is_focused {
590 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 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; 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 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; 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 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; 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; 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; 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()); assert_eq!(input.cursor, 6); }
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); }
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; 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 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 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; 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); }
1074}