1#![forbid(unsafe_code)]
2
3use ftui_core::event::{Event, ImeEvent, ImePhase, KeyCode, KeyEvent, KeyEventKind, Modifiers};
9use ftui_core::geometry::Rect;
10use ftui_render::cell::{Cell, CellContent};
11use ftui_render::frame::Frame;
12use ftui_style::Style;
13use ftui_text::grapheme_width;
14use unicode_segmentation::UnicodeSegmentation;
15
16use crate::undo_support::{TextEditOperation, TextInputUndoExt, UndoSupport, UndoWidgetId};
17use crate::{Widget, clear_text_area};
18
19#[derive(Debug, Clone, Default)]
21pub struct TextInput {
22 undo_id: UndoWidgetId,
24 value: String,
26 cursor: usize,
28 scroll_cells: std::cell::Cell<usize>,
30 selection_anchor: Option<usize>,
32 ime_composition: Option<String>,
34 placeholder: String,
36 mask_char: Option<char>,
38 max_length: Option<usize>,
40 style: Style,
42 cursor_style: Style,
44 placeholder_style: Style,
46 selection_style: Style,
48 focused: bool,
50}
51
52impl TextInput {
53 pub fn new() -> Self {
55 Self::default()
56 }
57
58 #[must_use]
62 pub fn with_value(mut self, value: impl Into<String>) -> Self {
63 self.value = value.into();
64 self.cursor = self.value.graphemes(true).count();
65 self.selection_anchor = None;
66 self
67 }
68
69 #[must_use]
71 pub fn with_placeholder(mut self, placeholder: impl Into<String>) -> Self {
72 self.placeholder = placeholder.into();
73 self
74 }
75
76 #[must_use]
78 pub fn with_mask(mut self, mask: char) -> Self {
79 self.mask_char = Some(mask);
80 self
81 }
82
83 #[must_use]
85 pub fn with_max_length(mut self, max: usize) -> Self {
86 self.max_length = Some(max);
87 self
88 }
89
90 #[must_use]
92 pub fn with_style(mut self, style: Style) -> Self {
93 self.style = style;
94 self
95 }
96
97 #[must_use]
99 pub fn with_cursor_style(mut self, style: Style) -> Self {
100 self.cursor_style = style;
101 self
102 }
103
104 #[must_use]
106 pub fn with_placeholder_style(mut self, style: Style) -> Self {
107 self.placeholder_style = style;
108 self
109 }
110
111 #[must_use]
113 pub fn with_selection_style(mut self, style: Style) -> Self {
114 self.selection_style = style;
115 self
116 }
117
118 #[must_use]
120 pub fn with_focused(mut self, focused: bool) -> Self {
121 self.focused = focused;
122 self
123 }
124
125 pub fn value(&self) -> &str {
129 &self.value
130 }
131
132 pub fn set_value(&mut self, value: impl Into<String>) {
134 self.value = value.into();
135 let max = self.grapheme_count();
136 self.cursor = self.cursor.min(max);
137 self.scroll_cells.set(0);
138 self.selection_anchor = None;
139 }
140
141 pub fn clear(&mut self) {
143 self.value.clear();
144 self.cursor = 0;
145 self.scroll_cells.set(0);
146 self.selection_anchor = None;
147 }
148
149 #[inline]
151 pub fn cursor(&self) -> usize {
152 self.cursor
153 }
154
155 #[inline]
157 pub fn focused(&self) -> bool {
158 self.focused
159 }
160
161 pub fn set_focused(&mut self, focused: bool) {
163 self.focused = focused;
164 }
165
166 pub fn cursor_position(&self, area: Rect) -> (u16, u16) {
171 let cursor_visual = self.cursor_visual_pos();
172 let effective_scroll = self.effective_scroll(area.width as usize);
173 let rel_x = cursor_visual.saturating_sub(effective_scroll);
174 let x = area
175 .x
176 .saturating_add(rel_x as u16)
177 .min(area.right().saturating_sub(1));
178 (x, area.y)
179 }
180
181 #[must_use]
183 pub fn selected_text(&self) -> Option<&str> {
184 let anchor = self.selection_anchor?;
185 let (start, end) = self.selection_range(anchor);
186 let byte_start = self.grapheme_byte_offset(start);
187 let byte_end = self.grapheme_byte_offset(end);
188 Some(&self.value[byte_start..byte_end])
189 }
190
191 pub fn ime_start_composition(&mut self) {
193 if self.ime_composition.is_none() {
194 self.delete_selection();
195 }
196 self.ime_composition = Some(String::new());
197 #[cfg(feature = "tracing")]
198 self.trace_edit("ime_start");
199 }
200
201 pub fn ime_update_composition(&mut self, preedit: impl Into<String>) {
205 if self.ime_composition.is_none() {
206 self.delete_selection();
207 }
208 self.ime_composition = Some(preedit.into());
209 #[cfg(feature = "tracing")]
210 self.trace_edit("ime_update");
211 }
212
213 pub fn ime_commit_composition(&mut self) -> bool {
217 let Some(preedit) = self.ime_composition.take() else {
218 return false;
219 };
220
221 if !preedit.is_empty() {
222 self.insert_text(&preedit);
223 }
224
225 #[cfg(feature = "tracing")]
226 self.trace_edit("ime_commit");
227
228 true
229 }
230
231 pub fn ime_cancel_composition(&mut self) -> bool {
235 let cancelled = self.ime_composition.take().is_some();
236 #[cfg(feature = "tracing")]
237 if cancelled {
238 self.trace_edit("ime_cancel");
239 }
240 cancelled
241 }
242
243 #[must_use]
245 pub fn ime_composition(&self) -> Option<&str> {
246 self.ime_composition.as_deref()
247 }
248
249 pub fn handle_event(&mut self, event: &Event) -> bool {
255 let changed = match event {
256 Event::Key(key)
257 if key.kind == KeyEventKind::Press || key.kind == KeyEventKind::Repeat =>
258 {
259 self.handle_key(key)
260 }
261 Event::Ime(ime) => self.handle_ime_event(ime),
262 Event::Paste(paste) => {
263 let had_selection = self.selection_anchor.is_some();
264
265 if had_selection {
268 let clean_text = Self::sanitize_input_text(&paste.text);
269 if let Some(max) = self.max_length {
270 let selection_len = if let Some(anchor) = self.selection_anchor {
271 let (start, end) = self.selection_range(anchor);
272 end.saturating_sub(start)
273 } else {
274 0
275 };
276 let available =
277 max.saturating_sub(self.grapheme_count().saturating_sub(selection_len));
278 if clean_text.graphemes(true).count() > available {
279 return false;
280 }
281 }
282 }
283
284 self.delete_selection();
285 self.insert_text(&paste.text);
286 true
287 }
288 _ => false,
289 };
290
291 #[cfg(feature = "tracing")]
292 if changed {
293 self.trace_edit(Self::event_operation_name(event));
294 }
295
296 changed
297 }
298
299 fn handle_ime_event(&mut self, ime: &ImeEvent) -> bool {
300 match ime.phase {
301 ImePhase::Start => {
302 self.ime_start_composition();
303 true
304 }
305 ImePhase::Update => {
306 self.ime_update_composition(&ime.text);
307 true
308 }
309 ImePhase::Commit => {
310 if self.ime_composition.is_some() {
311 self.ime_update_composition(&ime.text);
312 self.ime_commit_composition()
313 } else if !ime.text.is_empty() {
314 self.insert_text(&ime.text);
315 true
316 } else {
317 false
318 }
319 }
320 ImePhase::Cancel => self.ime_cancel_composition(),
321 }
322 }
323
324 fn handle_key(&mut self, key: &KeyEvent) -> bool {
325 let ctrl = key.modifiers.contains(Modifiers::CTRL);
326 let shift = key.modifiers.contains(Modifiers::SHIFT);
327
328 match key.code {
329 KeyCode::Char(c) if !ctrl => {
330 self.insert_char(c);
331 true
332 }
333 KeyCode::Char('a') if ctrl => {
335 self.select_all();
336 true
337 }
338 KeyCode::Char('w') if ctrl => {
340 self.delete_word_back();
341 true
342 }
343 KeyCode::Backspace => {
344 if self.selection_anchor.is_some() {
345 self.delete_selection();
346 } else if ctrl {
347 self.delete_word_back();
348 } else {
349 self.delete_char_back();
350 }
351 true
352 }
353 KeyCode::Delete => {
354 if self.selection_anchor.is_some() {
355 self.delete_selection();
356 } else if ctrl {
357 self.delete_word_forward();
358 } else {
359 self.delete_char_forward();
360 }
361 true
362 }
363 KeyCode::Left => {
364 if ctrl {
365 self.move_cursor_word_left(shift);
366 } else if shift {
367 self.move_cursor_left_select();
368 } else {
369 self.move_cursor_left();
370 }
371 true
372 }
373 KeyCode::Right => {
374 if ctrl {
375 self.move_cursor_word_right(shift);
376 } else if shift {
377 self.move_cursor_right_select();
378 } else {
379 self.move_cursor_right();
380 }
381 true
382 }
383 KeyCode::Home => {
384 if shift {
385 self.ensure_selection_anchor();
386 } else {
387 self.selection_anchor = None;
388 }
389 self.cursor = 0;
390 self.scroll_cells.set(0);
391 true
392 }
393 KeyCode::End => {
394 if shift {
395 self.ensure_selection_anchor();
396 } else {
397 self.selection_anchor = None;
398 }
399 self.cursor = self.grapheme_count();
400 true
401 }
402 _ => false,
403 }
404 }
405
406 #[cfg(feature = "tracing")]
407 fn trace_edit(&self, operation: &'static str) {
408 let _span = tracing::debug_span!(
409 "input.edit",
410 operation,
411 cursor_position = self.cursor,
412 grapheme_count = self.grapheme_count(),
413 has_selection = self.selection_anchor.is_some()
414 )
415 .entered();
416 }
417
418 #[cfg(feature = "tracing")]
419 fn event_operation_name(event: &Event) -> &'static str {
420 match event {
421 Event::Key(key) => Self::key_operation_name(key),
422 Event::Paste(_) => "paste",
423 Event::Ime(ime) => match ime.phase {
424 ImePhase::Start => "ime_start",
425 ImePhase::Update => "ime_update",
426 ImePhase::Commit => "ime_commit",
427 ImePhase::Cancel => "ime_cancel",
428 },
429 Event::Resize { .. } => "resize",
430 Event::Focus(_) => "focus",
431 Event::Mouse(_) => "mouse",
432 Event::Clipboard(_) => "clipboard",
433 Event::Tick => "tick",
434 }
435 }
436
437 #[cfg(feature = "tracing")]
438 fn key_operation_name(key: &KeyEvent) -> &'static str {
439 let ctrl = key.modifiers.contains(Modifiers::CTRL);
440 let shift = key.modifiers.contains(Modifiers::SHIFT);
441
442 match key.code {
443 KeyCode::Char(_) if !ctrl => "insert_char",
444 KeyCode::Char('a') if ctrl => "select_all",
445 KeyCode::Char('w') if ctrl => "delete_word_back",
446 KeyCode::Backspace if ctrl => "delete_word_back",
447 KeyCode::Backspace => "delete_back",
448 KeyCode::Delete if ctrl => "delete_word_forward",
449 KeyCode::Delete => "delete_forward",
450 KeyCode::Left if ctrl && shift => "move_word_left_select",
451 KeyCode::Left if ctrl => "move_word_left",
452 KeyCode::Left if shift => "move_left_select",
453 KeyCode::Left => "move_left",
454 KeyCode::Right if ctrl && shift => "move_word_right_select",
455 KeyCode::Right if ctrl => "move_word_right",
456 KeyCode::Right if shift => "move_right_select",
457 KeyCode::Right => "move_right",
458 KeyCode::Home if shift => "move_home_select",
459 KeyCode::Home => "move_home",
460 KeyCode::End if shift => "move_end_select",
461 KeyCode::End => "move_end",
462 _ => "key_other",
463 }
464 }
465
466 fn sanitize_input_text(text: &str) -> String {
469 text.chars()
471 .map(|c| {
472 if c == '\n' || c == '\r' || c == '\t' {
473 ' '
474 } else {
475 c
476 }
477 })
478 .filter(|c| !c.is_control())
479 .collect()
480 }
481
482 pub fn insert_text(&mut self, text: &str) {
490 self.delete_selection();
491
492 let clean_text = Self::sanitize_input_text(text);
493
494 if clean_text.is_empty() {
495 return;
496 }
497
498 let current_count = self.grapheme_count();
499 let avail = if let Some(max) = self.max_length {
500 if current_count >= max {
501 1
503 } else {
504 max - current_count
505 }
506 } else {
507 usize::MAX
508 };
509
510 let new_graphemes = clean_text.graphemes(true).count();
512 let to_insert = if new_graphemes > avail {
513 let end_byte = clean_text
515 .grapheme_indices(true)
516 .map(|(i, _)| i)
517 .nth(avail)
518 .unwrap_or(clean_text.len());
519 &clean_text[..end_byte]
520 } else {
521 clean_text.as_str()
522 };
523
524 if to_insert.is_empty() {
525 return;
526 }
527
528 let byte_offset = self.grapheme_byte_offset(self.cursor);
529 self.value.insert_str(byte_offset, to_insert);
530
531 let new_total = self.grapheme_count();
533 if let Some(max) = self.max_length
534 && new_total > max
535 {
536 self.value.drain(byte_offset..byte_offset + to_insert.len());
538 return;
539 }
540
541 self.cursor = self
542 .value
543 .grapheme_indices(true)
544 .take_while(|(i, _)| *i < byte_offset + to_insert.len())
545 .count();
546 }
547
548 fn insert_char(&mut self, c: char) {
549 if c.is_control() {
551 return;
552 }
553
554 self.delete_selection();
555
556 let byte_offset = self.grapheme_byte_offset(self.cursor);
557 self.value.insert(byte_offset, c);
558
559 let new_count = self.grapheme_count();
560
561 if let Some(max) = self.max_length
563 && new_count > max
564 {
565 let char_len = c.len_utf8();
567 self.value.drain(byte_offset..byte_offset + char_len);
568 return;
569 }
570
571 let char_len = c.len_utf8();
572 self.cursor = self
573 .value
574 .grapheme_indices(true)
575 .take_while(|(i, _)| *i < byte_offset + char_len)
576 .count();
577 }
578
579 fn delete_char_back(&mut self) {
580 if self.cursor > 0 {
581 let byte_start = self.grapheme_byte_offset(self.cursor - 1);
582 let byte_end = self.grapheme_byte_offset(self.cursor);
583 self.value.drain(byte_start..byte_end);
584 self.cursor -= 1;
585 let gc = self.grapheme_count();
586 if self.cursor > gc {
587 self.cursor = gc;
588 }
589 }
590 }
591
592 fn delete_char_forward(&mut self) {
593 let count = self.grapheme_count();
594 if self.cursor < count {
595 let byte_start = self.grapheme_byte_offset(self.cursor);
596 let byte_end = self.grapheme_byte_offset(self.cursor + 1);
597 self.value.drain(byte_start..byte_end);
598 let gc = self.grapheme_count();
599 if self.cursor > gc {
600 self.cursor = gc;
601 }
602 }
603 }
604
605 fn delete_word_back(&mut self) {
606 if self.cursor == 0 {
607 return;
608 }
609
610 let old_cursor = self.cursor;
611 self.move_cursor_word_left(false);
613 let new_cursor = self.cursor;
614
615 if new_cursor < old_cursor {
616 let byte_start = self.grapheme_byte_offset(new_cursor);
617 let byte_end = self.grapheme_byte_offset(old_cursor);
618 self.value.drain(byte_start..byte_end);
619 self.cursor = new_cursor;
620 let gc = self.grapheme_count();
621 if self.cursor > gc {
622 self.cursor = gc;
623 }
624 }
625 }
626
627 fn delete_word_forward(&mut self) {
628 let old_cursor = self.cursor;
629 self.move_cursor_word_right(false);
631 let new_cursor = self.cursor;
632 self.cursor = old_cursor;
634
635 if new_cursor > old_cursor {
636 let byte_start = self.grapheme_byte_offset(old_cursor);
637 let byte_end = self.grapheme_byte_offset(new_cursor);
638 self.value.drain(byte_start..byte_end);
639 let gc = self.grapheme_count();
640 if self.cursor > gc {
641 self.cursor = gc;
642 }
643 }
644 }
645
646 pub fn select_all(&mut self) {
650 self.selection_anchor = Some(0);
651 self.cursor = self.grapheme_count();
652 }
653
654 fn delete_selection(&mut self) {
656 if let Some(anchor) = self.selection_anchor.take() {
657 let (start, end) = self.selection_range(anchor);
658 let byte_start = self.grapheme_byte_offset(start);
659 let byte_end = self.grapheme_byte_offset(end);
660 self.value.drain(byte_start..byte_end);
661 self.cursor = start;
662 let gc = self.grapheme_count();
663 if self.cursor > gc {
664 self.cursor = gc;
665 }
666 }
667 }
668
669 fn ensure_selection_anchor(&mut self) {
670 if self.selection_anchor.is_none() {
671 self.selection_anchor = Some(self.cursor);
672 }
673 }
674
675 fn selection_range(&self, anchor: usize) -> (usize, usize) {
676 if anchor <= self.cursor {
677 (anchor, self.cursor)
678 } else {
679 (self.cursor, anchor)
680 }
681 }
682
683 fn is_in_selection(&self, grapheme_idx: usize) -> bool {
684 if let Some(anchor) = self.selection_anchor {
685 let (start, end) = self.selection_range(anchor);
686 grapheme_idx >= start && grapheme_idx < end
687 } else {
688 false
689 }
690 }
691
692 fn move_cursor_left(&mut self) {
695 if let Some(anchor) = self.selection_anchor.take() {
696 self.cursor = self.cursor.min(anchor);
697 } else if self.cursor > 0 {
698 self.cursor -= 1;
699 }
700 }
701
702 fn move_cursor_right(&mut self) {
703 if let Some(anchor) = self.selection_anchor.take() {
704 self.cursor = self.cursor.max(anchor);
705 } else if self.cursor < self.grapheme_count() {
706 self.cursor += 1;
707 }
708 }
709
710 fn move_cursor_left_select(&mut self) {
711 self.ensure_selection_anchor();
712 if self.cursor > 0 {
713 self.cursor -= 1;
714 }
715 }
716
717 fn move_cursor_right_select(&mut self) {
718 self.ensure_selection_anchor();
719 if self.cursor < self.grapheme_count() {
720 self.cursor += 1;
721 }
722 }
723
724 fn get_grapheme_class(g: &str) -> u8 {
725 if g.chars().all(char::is_whitespace) {
726 0
727 } else if g.chars().any(char::is_alphanumeric) {
728 1
729 } else {
730 2
731 }
732 }
733
734 fn move_cursor_word_left(&mut self, select: bool) {
735 if select {
736 self.ensure_selection_anchor();
737 } else {
738 self.selection_anchor = None;
739 }
740
741 if self.cursor == 0 {
742 return;
743 }
744
745 let byte_offset = self.grapheme_byte_offset(self.cursor);
746 let before_cursor = &self.value[..byte_offset];
747 let mut pos = self.cursor;
748
749 let mut iter = before_cursor.graphemes(true).rev();
750
751 while let Some(g) = iter.next() {
752 if Self::get_grapheme_class(g) == 0 {
753 pos = pos.saturating_sub(1);
754 } else {
755 pos = pos.saturating_sub(1);
756 let target = Self::get_grapheme_class(g);
757 for g_next in iter {
758 if Self::get_grapheme_class(g_next) == target {
759 pos = pos.saturating_sub(1);
760 } else {
761 break;
762 }
763 }
764 break;
765 }
766 }
767
768 self.cursor = pos;
769 }
770
771 fn move_cursor_word_right(&mut self, select: bool) {
772 if select {
773 self.ensure_selection_anchor();
774 } else {
775 self.selection_anchor = None;
776 }
777
778 let mut iter = self.value.graphemes(true).peekable();
779 for _ in 0..self.cursor {
780 iter.next();
781 }
782
783 let mut pos = self.cursor;
784
785 if let Some(&g) = iter.peek() {
786 let start_class = Self::get_grapheme_class(g);
787 if start_class != 0 {
788 while let Some(&next_g) = iter.peek() {
789 if Self::get_grapheme_class(next_g) == start_class {
790 iter.next();
791 pos += 1;
792 } else {
793 break;
794 }
795 }
796 }
797 }
798
799 while let Some(&g) = iter.peek() {
800 if Self::get_grapheme_class(g) == 0 {
801 iter.next();
802 pos += 1;
803 } else {
804 break;
805 }
806 }
807
808 self.cursor = pos;
809 }
810
811 fn grapheme_count(&self) -> usize {
814 self.value.graphemes(true).count()
815 }
816
817 fn grapheme_byte_offset(&self, grapheme_idx: usize) -> usize {
818 self.value
819 .grapheme_indices(true)
820 .nth(grapheme_idx)
821 .map(|(i, _)| i)
822 .unwrap_or(self.value.len())
823 }
824
825 fn grapheme_width(&self, g: &str) -> usize {
826 if let Some(mask) = self.mask_char {
827 let mut buf = [0u8; 4];
828 let mask_str = mask.encode_utf8(&mut buf);
829 grapheme_width(mask_str)
830 } else {
831 grapheme_width(g)
832 }
833 }
834
835 fn prev_grapheme_width(&self) -> usize {
836 if self.cursor == 0 {
837 return 0;
838 }
839 self.value
840 .graphemes(true)
841 .nth(self.cursor - 1)
842 .map(|g| self.grapheme_width(g))
843 .unwrap_or(0)
844 }
845
846 fn cursor_visual_pos(&self) -> usize {
847 let mut pos = 0;
848 if !self.value.is_empty() {
849 pos += self
850 .value
851 .graphemes(true)
852 .take(self.cursor)
853 .map(|g| self.grapheme_width(g))
854 .sum::<usize>();
855 }
856 if let Some(ime) = &self.ime_composition {
857 pos += ime
858 .graphemes(true)
859 .map(|g| self.grapheme_width(g))
860 .sum::<usize>();
861 }
862 pos
863 }
864
865 fn effective_scroll(&self, viewport_width: usize) -> usize {
866 let cursor_visual = self.cursor_visual_pos();
867 let mut scroll = self.scroll_cells.get();
868 if cursor_visual < scroll {
869 scroll = cursor_visual;
870 }
871 if cursor_visual >= scroll + viewport_width {
872 let candidate_scroll = cursor_visual - viewport_width + 1;
873 let prev_width = self.prev_grapheme_width();
876 let max_scroll_for_prev = cursor_visual.saturating_sub(prev_width);
877
878 if viewport_width > prev_width {
881 scroll = candidate_scroll.min(max_scroll_for_prev);
882 } else {
883 scroll = candidate_scroll;
884 }
885 }
886
887 scroll = self.snap_scroll_to_grapheme_boundary(scroll, viewport_width);
889
890 self.scroll_cells.set(scroll);
891 scroll
892 }
893
894 fn snap_scroll_to_grapheme_boundary(&self, scroll: usize, viewport_width: usize) -> usize {
895 let mut pos = 0;
896 let cursor_visual = self.cursor_visual_pos();
897
898 for g in self.value.graphemes(true) {
899 let w = self.grapheme_width(g);
900 let next_pos = pos + w;
901
902 if pos < scroll && scroll < next_pos {
904 if cursor_visual <= pos + viewport_width {
908 return pos;
909 } else {
910 return next_pos;
912 }
913 }
914
915 if next_pos > scroll {
916 break;
918 }
919 pos = next_pos;
920 }
921 scroll
922 }
923}
924
925impl Widget for TextInput {
926 fn render(&self, area: Rect, frame: &mut Frame) {
927 #[cfg(feature = "tracing")]
928 let _span = tracing::debug_span!(
929 "widget_render",
930 widget = "TextInput",
931 x = area.x,
932 y = area.y,
933 w = area.width,
934 h = area.height
935 )
936 .entered();
937
938 if area.width < 1 || area.height < 1 {
939 return;
940 }
941
942 let deg = frame.buffer.degradation;
943
944 let base_style = if deg.apply_styling() {
948 self.style
949 } else {
950 Style::default()
951 };
952 clear_text_area(frame, area, base_style);
953
954 let arena = frame.arena;
956 let graphemes_heap;
957 let graphemes: &[&str] = if let Some(a) = arena {
958 a.alloc_iter(self.value.graphemes(true))
959 } else {
960 graphemes_heap = self.value.graphemes(true).collect::<Vec<_>>();
961 &graphemes_heap
962 };
963 let show_placeholder =
964 self.value.is_empty() && self.ime_composition.is_none() && !self.placeholder.is_empty();
965
966 let viewport_width = area.width as usize;
967 let cursor_visual_pos = self.cursor_visual_pos();
968 let effective_scroll = self.effective_scroll(viewport_width);
969
970 let mut visual_x: usize = 0;
972 let y = area.y;
973
974 if show_placeholder {
975 let placeholder_style = if deg.apply_styling() {
976 self.placeholder_style
977 } else {
978 Style::default()
979 };
980 for g in self.placeholder.graphemes(true) {
981 let w = self.grapheme_width(g);
982 if w == 0 {
983 continue;
984 }
985
986 if visual_x + w <= effective_scroll {
988 visual_x += w;
989 continue;
990 }
991
992 if visual_x < effective_scroll {
994 visual_x += w;
995 continue;
996 }
997
998 let rel_x = visual_x - effective_scroll;
999
1000 if rel_x >= viewport_width {
1002 break;
1003 }
1004
1005 if rel_x + w > viewport_width {
1007 break;
1008 }
1009
1010 let mut cell = if g.chars().count() > 1 || w > 1 {
1011 let id = frame.intern_with_width(g, w as u8);
1012 Cell::new(CellContent::from_grapheme(id))
1013 } else if let Some(c) = g.chars().next() {
1014 Cell::from_char(c)
1015 } else {
1016 visual_x += w;
1017 continue;
1018 };
1019 crate::apply_style(&mut cell, placeholder_style);
1020
1021 frame
1022 .buffer
1023 .set(area.x.saturating_add(rel_x as u16), y, cell);
1024 visual_x += w;
1025 }
1026 } else {
1027 let mut display_spans: Vec<(&str, Style, bool)> = Vec::new();
1028 for (gi, g) in graphemes.iter().enumerate() {
1029 if gi == self.cursor
1030 && let Some(ime) = &self.ime_composition
1031 {
1032 let ime_style = if deg.apply_styling() {
1033 self.style
1034 } else {
1035 Style::default()
1036 };
1037 for ig in ime.graphemes(true) {
1038 display_spans.push((ig, ime_style, true));
1039 }
1040 }
1041
1042 let cell_style = if !deg.apply_styling() {
1043 Style::default()
1044 } else if self.is_in_selection(gi) {
1045 self.selection_style
1046 } else {
1047 self.style
1048 };
1049 display_spans.push((g, cell_style, false));
1050 }
1051 if self.cursor == graphemes.len()
1052 && let Some(ime) = &self.ime_composition
1053 {
1054 let ime_style = if deg.apply_styling() {
1055 self.style
1056 } else {
1057 Style::default()
1058 };
1059 for ig in ime.graphemes(true) {
1060 display_spans.push((ig, ime_style, true));
1061 }
1062 }
1063
1064 for (g, cell_style, is_ime) in display_spans {
1065 let w = self.grapheme_width(g);
1066 if w == 0 {
1067 continue;
1068 }
1069
1070 if visual_x + w <= effective_scroll {
1072 visual_x += w;
1073 continue;
1074 }
1075
1076 if visual_x < effective_scroll {
1078 visual_x += w;
1079 continue;
1080 }
1081
1082 let rel_x = visual_x - effective_scroll;
1083
1084 if rel_x >= viewport_width {
1086 break;
1087 }
1088
1089 if rel_x + w > viewport_width {
1091 break;
1092 }
1093
1094 let mut cell = if let Some(mask) = self.mask_char {
1095 Cell::from_char(mask)
1096 } else if g.chars().count() > 1 || w > 1 {
1097 let id = frame.intern_with_width(g, w as u8);
1098 Cell::new(CellContent::from_grapheme(id))
1099 } else {
1100 Cell::from_char(g.chars().next().unwrap_or(' '))
1101 };
1102 crate::apply_style(&mut cell, cell_style);
1103
1104 if is_ime && deg.apply_styling() {
1105 use ftui_render::cell::StyleFlags;
1106 let current_flags = cell.attrs.flags();
1107 cell.attrs = cell.attrs.with_flags(current_flags | StyleFlags::UNDERLINE);
1108 }
1109
1110 frame
1111 .buffer
1112 .set(area.x.saturating_add(rel_x as u16), y, cell);
1113 visual_x += w;
1114 }
1115 }
1116
1117 if self.focused {
1118 let cursor_rel_x = cursor_visual_pos.saturating_sub(effective_scroll);
1120 if cursor_rel_x < viewport_width {
1121 let cursor_screen_x = area.x.saturating_add(cursor_rel_x as u16);
1122 if let Some(cell) = frame.buffer.get_mut(cursor_screen_x, y) {
1123 if !deg.apply_styling() {
1124 use ftui_render::cell::StyleFlags;
1126 let current_flags = cell.attrs.flags();
1127 let new_flags = current_flags ^ StyleFlags::REVERSE;
1128 cell.attrs = cell.attrs.with_flags(new_flags);
1129 } else if self.cursor_style.is_empty() {
1130 use ftui_render::cell::StyleFlags;
1132 let current_flags = cell.attrs.flags();
1133 let new_flags = current_flags ^ StyleFlags::REVERSE;
1134 cell.attrs = cell.attrs.with_flags(new_flags);
1135 } else {
1136 crate::apply_style(cell, self.cursor_style);
1137 }
1138 }
1139 }
1140
1141 frame.set_cursor(Some(self.cursor_position(area)));
1142 frame.set_cursor_visible(true);
1143 }
1144 }
1145
1146 fn is_essential(&self) -> bool {
1147 true
1148 }
1149}
1150
1151impl ftui_a11y::Accessible for TextInput {
1156 fn accessibility_nodes(&self, area: Rect) -> Vec<ftui_a11y::node::A11yNodeInfo> {
1157 use ftui_a11y::node::{A11yNodeInfo, A11yRole, A11yState};
1158
1159 let id = crate::a11y_node_id(area);
1160 let name = if self.value.is_empty() {
1161 self.placeholder.clone()
1162 } else if self.mask_char.is_some() {
1163 String::from("password field")
1165 } else {
1166 self.value.clone()
1167 };
1168
1169 let state = A11yState {
1170 focused: self.focused,
1171 disabled: self.max_length == Some(0),
1172 ..A11yState::default()
1173 };
1174
1175 let mut node = A11yNodeInfo::new(id, A11yRole::TextInput, area).with_state(state);
1176 if !name.is_empty() {
1177 node = node.with_name(name);
1178 }
1179 if self.mask_char.is_some() {
1180 node = node.with_description("password input");
1181 }
1182
1183 vec![node]
1184 }
1185}
1186
1187#[derive(Debug, Clone)]
1193pub struct TextInputSnapshot {
1194 value: String,
1195 cursor: usize,
1196 selection_anchor: Option<usize>,
1197}
1198
1199impl UndoSupport for TextInput {
1200 fn undo_widget_id(&self) -> UndoWidgetId {
1201 self.undo_id
1202 }
1203
1204 fn create_snapshot(&self) -> Box<dyn std::any::Any + Send> {
1205 Box::new(TextInputSnapshot {
1206 value: self.value.clone(),
1207 cursor: self.cursor,
1208 selection_anchor: self.selection_anchor,
1209 })
1210 }
1211
1212 fn restore_snapshot(&mut self, snapshot: &dyn std::any::Any) -> bool {
1213 if let Some(snap) = snapshot.downcast_ref::<TextInputSnapshot>() {
1214 self.value = snap.value.clone();
1215 self.cursor = snap.cursor;
1216 self.selection_anchor = snap.selection_anchor;
1217 self.scroll_cells.set(0); true
1219 } else {
1220 false
1221 }
1222 }
1223}
1224
1225impl TextInputUndoExt for TextInput {
1226 fn text_value(&self) -> &str {
1227 &self.value
1228 }
1229
1230 fn set_text_value(&mut self, value: &str) {
1231 self.value = value.to_string();
1232 let max = self.grapheme_count();
1233 self.cursor = self.cursor.min(max);
1234 self.selection_anchor = None;
1235 }
1236
1237 fn cursor_position(&self) -> usize {
1238 self.cursor
1239 }
1240
1241 fn set_cursor_position(&mut self, pos: usize) {
1242 let max = self.grapheme_count();
1243 self.cursor = pos.min(max);
1244 }
1245
1246 fn insert_text_at(&mut self, position: usize, text: &str) {
1247 let byte_offset = self.grapheme_byte_offset(position);
1248 self.value.insert_str(byte_offset, text);
1249 let inserted_graphemes = text.graphemes(true).count();
1250 if self.cursor >= position {
1251 self.cursor += inserted_graphemes;
1252 }
1253 }
1254
1255 fn delete_text_range(&mut self, start: usize, end: usize) {
1256 if start >= end {
1257 return;
1258 }
1259 let byte_start = self.grapheme_byte_offset(start);
1260 let byte_end = self.grapheme_byte_offset(end);
1261 self.value.drain(byte_start..byte_end);
1262 let deleted_count = end - start;
1263 if self.cursor > end {
1264 self.cursor -= deleted_count;
1265 } else if self.cursor > start {
1266 self.cursor = start;
1267 }
1268 let gc = self.grapheme_count();
1269 if self.cursor > gc {
1270 self.cursor = gc;
1271 }
1272 }
1273}
1274
1275impl TextInput {
1276 #[must_use]
1301 pub fn create_text_edit_command(
1302 &self,
1303 operation: TextEditOperation,
1304 ) -> Option<crate::undo_support::WidgetTextEditCmd> {
1305 Some(crate::undo_support::WidgetTextEditCmd::new(
1306 self.undo_id,
1307 operation,
1308 ))
1309 }
1310
1311 #[must_use]
1315 pub fn undo_id(&self) -> UndoWidgetId {
1316 self.undo_id
1317 }
1318}
1319
1320#[cfg(test)]
1321mod tests {
1322 use super::*;
1323 #[cfg(feature = "tracing")]
1324 use std::sync::{Arc, Mutex};
1325
1326 #[cfg(feature = "tracing")]
1327 use tracing::Subscriber;
1328 #[cfg(feature = "tracing")]
1329 use tracing_subscriber::Layer;
1330 #[cfg(feature = "tracing")]
1331 use tracing_subscriber::layer::{Context, SubscriberExt};
1332
1333 #[allow(dead_code)]
1334 fn cell_at(frame: &Frame, x: u16, y: u16) -> Cell {
1335 frame
1336 .buffer
1337 .get(x, y)
1338 .copied()
1339 .unwrap_or_else(|| panic!("test cell should exist at ({x},{y})"))
1340 }
1341
1342 fn raw_row_text(frame: &Frame, y: u16, width: u16) -> String {
1343 (0..width)
1344 .map(|x| {
1345 frame
1346 .buffer
1347 .get(x, y)
1348 .and_then(|cell| cell.content.as_char())
1349 .unwrap_or(' ')
1350 })
1351 .collect()
1352 }
1353
1354 #[cfg(feature = "tracing")]
1355 #[derive(Debug, Default)]
1356 struct InputTraceState {
1357 span_count: usize,
1358 has_cursor_position_field: bool,
1359 cursor_positions: Vec<usize>,
1360 operations: Vec<String>,
1361 }
1362
1363 #[cfg(feature = "tracing")]
1364 struct InputTraceCapture {
1365 state: Arc<Mutex<InputTraceState>>,
1366 }
1367
1368 #[cfg(feature = "tracing")]
1369 impl<S> Layer<S> for InputTraceCapture
1370 where
1371 S: Subscriber + for<'lookup> tracing_subscriber::registry::LookupSpan<'lookup>,
1372 {
1373 fn on_new_span(
1374 &self,
1375 attrs: &tracing::span::Attributes<'_>,
1376 _id: &tracing::Id,
1377 _ctx: Context<'_, S>,
1378 ) {
1379 if attrs.metadata().name() != "input.edit" {
1380 return;
1381 }
1382
1383 #[derive(Default)]
1384 struct InputEditVisitor {
1385 cursor_position: Option<usize>,
1386 operation: Option<String>,
1387 }
1388
1389 impl tracing::field::Visit for InputEditVisitor {
1390 fn record_i64(&mut self, field: &tracing::field::Field, value: i64) {
1391 if field.name() == "cursor_position" {
1392 self.cursor_position = usize::try_from(value).ok();
1393 }
1394 }
1395
1396 fn record_u64(&mut self, field: &tracing::field::Field, value: u64) {
1397 if field.name() == "cursor_position" {
1398 self.cursor_position = usize::try_from(value).ok();
1399 }
1400 }
1401
1402 fn record_debug(
1403 &mut self,
1404 field: &tracing::field::Field,
1405 value: &dyn std::fmt::Debug,
1406 ) {
1407 if field.name() == "operation" {
1408 self.operation = Some(format!("{value:?}").trim_matches('"').to_owned());
1409 }
1410 }
1411
1412 fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
1413 if field.name() == "operation" {
1414 self.operation = Some(value.to_owned());
1415 }
1416 }
1417 }
1418
1419 let fields = attrs.metadata().fields();
1420 let mut visitor = InputEditVisitor::default();
1421 attrs.record(&mut visitor);
1422
1423 let mut state = self.state.lock().expect("trace state lock");
1424 state.span_count += 1;
1425 state.has_cursor_position_field |= fields.field("cursor_position").is_some();
1426 if let Some(cursor) = visitor.cursor_position {
1427 state.cursor_positions.push(cursor);
1428 }
1429 if let Some(operation) = visitor.operation {
1430 state.operations.push(operation);
1431 }
1432 }
1433 }
1434
1435 #[allow(dead_code)]
1436 #[test]
1437 fn test_empty_input() {
1438 let input = TextInput::new();
1439 assert!(input.value().is_empty());
1440 assert_eq!(input.cursor(), 0);
1441 assert!(input.selected_text().is_none());
1442 }
1443
1444 #[test]
1445 fn test_with_value() {
1446 let mut input = TextInput::new().with_value("hello");
1447 input.set_focused(true);
1448 assert_eq!(input.value(), "hello");
1449 assert_eq!(input.cursor(), 5);
1450 }
1451
1452 #[test]
1453 fn test_set_value() {
1454 let mut input = TextInput::new().with_value("hello world");
1455 input.cursor = 11;
1456 input.set_value("hi");
1457 assert_eq!(input.value(), "hi");
1458 assert_eq!(input.cursor(), 2);
1459 }
1460
1461 #[test]
1462 fn test_clear() {
1463 let mut input = TextInput::new().with_value("hello");
1464 input.set_focused(true);
1465 input.clear();
1466 assert!(input.value().is_empty());
1467 assert_eq!(input.cursor(), 0);
1468 }
1469
1470 #[test]
1471 fn test_insert_char() {
1472 let mut input = TextInput::new();
1473 input.insert_char('a');
1474 input.insert_char('b');
1475 input.insert_char('c');
1476 assert_eq!(input.value(), "abc");
1477 assert_eq!(input.cursor(), 3);
1478 }
1479
1480 #[test]
1481 fn test_insert_char_mid() {
1482 let mut input = TextInput::new().with_value("ac");
1483 input.cursor = 1;
1484 input.insert_char('b');
1485 assert_eq!(input.value(), "abc");
1486 assert_eq!(input.cursor(), 2);
1487 }
1488
1489 #[test]
1490 fn test_max_length() {
1491 let mut input = TextInput::new().with_max_length(3);
1492 for c in "abcdef".chars() {
1493 input.insert_char(c);
1494 }
1495 assert_eq!(input.value(), "abc");
1496 assert_eq!(input.cursor(), 3);
1497 }
1498
1499 #[test]
1500 fn test_delete_char_back() {
1501 let mut input = TextInput::new().with_value("hello");
1502 input.delete_char_back();
1503 assert_eq!(input.value(), "hell");
1504 assert_eq!(input.cursor(), 4);
1505 }
1506
1507 #[test]
1508 fn test_delete_char_back_at_start() {
1509 let mut input = TextInput::new().with_value("hello");
1510 input.cursor = 0;
1511 input.delete_char_back();
1512 assert_eq!(input.value(), "hello");
1513 }
1514
1515 #[test]
1516 fn test_delete_char_forward() {
1517 let mut input = TextInput::new().with_value("hello");
1518 input.cursor = 0;
1519 input.delete_char_forward();
1520 assert_eq!(input.value(), "ello");
1521 assert_eq!(input.cursor(), 0);
1522 }
1523
1524 #[test]
1525 fn test_delete_char_forward_at_end() {
1526 let mut input = TextInput::new().with_value("hello");
1527 input.delete_char_forward();
1528 assert_eq!(input.value(), "hello");
1529 }
1530
1531 #[test]
1532 fn test_cursor_left_right() {
1533 let mut input = TextInput::new().with_value("hello");
1534 assert_eq!(input.cursor(), 5);
1535 input.move_cursor_left();
1536 assert_eq!(input.cursor(), 4);
1537 input.move_cursor_left();
1538 assert_eq!(input.cursor(), 3);
1539 input.move_cursor_right();
1540 assert_eq!(input.cursor(), 4);
1541 }
1542
1543 #[test]
1544 fn test_cursor_bounds() {
1545 let mut input = TextInput::new().with_value("hi");
1546 input.cursor = 0;
1547 input.move_cursor_left();
1548 assert_eq!(input.cursor(), 0);
1549 input.cursor = 2;
1550 input.move_cursor_right();
1551 assert_eq!(input.cursor(), 2);
1552 }
1553
1554 #[test]
1555 fn test_word_movement_left() {
1556 let mut input = TextInput::new().with_value("hello world test");
1557 input.move_cursor_word_left(false);
1560 assert_eq!(input.cursor(), 12); input.move_cursor_word_left(false);
1563 assert_eq!(input.cursor(), 6); input.move_cursor_word_left(false);
1566 assert_eq!(input.cursor(), 0); }
1568
1569 #[test]
1570 fn test_word_movement_right() {
1571 let mut input = TextInput::new().with_value("hello world test");
1572 input.cursor = 0;
1573 input.move_cursor_word_right(false);
1576 assert_eq!(input.cursor(), 6); input.move_cursor_word_right(false);
1579 assert_eq!(input.cursor(), 12); input.move_cursor_word_right(false);
1582 assert_eq!(input.cursor(), 16); }
1584
1585 #[test]
1586 fn test_word_movement_skips_punctuation() {
1587 let mut input = TextInput::new().with_value("hello, world");
1588 input.cursor = 0;
1589 input.move_cursor_word_right(false);
1592 assert_eq!(input.cursor(), 5); input.move_cursor_word_right(false);
1595 assert_eq!(input.cursor(), 7); input.move_cursor_word_right(false);
1598 assert_eq!(input.cursor(), 12); input.move_cursor_word_left(false);
1601 assert_eq!(input.cursor(), 7); }
1603
1604 #[test]
1605 fn test_delete_word_back() {
1606 let mut input = TextInput::new().with_value("hello world");
1607 input.delete_word_back();
1609 assert_eq!(input.value(), "hello "); input.delete_word_back();
1613 assert_eq!(input.value(), ""); }
1615
1616 #[test]
1617 fn test_delete_word_forward() {
1618 let mut input = TextInput::new().with_value("hello world");
1619 input.cursor = 0;
1620 input.delete_word_forward();
1622 assert_eq!(input.value(), "world"); input.delete_word_forward();
1625 assert_eq!(input.value(), ""); }
1627
1628 #[test]
1629 fn test_select_all() {
1630 let mut input = TextInput::new().with_value("hello");
1631 input.select_all();
1632 assert_eq!(input.selected_text(), Some("hello"));
1633 }
1634
1635 #[test]
1636 fn test_delete_selection() {
1637 let mut input = TextInput::new().with_value("hello world");
1638 input.selection_anchor = Some(0);
1639 input.cursor = 5;
1640 input.delete_selection();
1641 assert_eq!(input.value(), " world");
1642 assert_eq!(input.cursor(), 0);
1643 }
1644
1645 #[test]
1646 fn test_insert_replaces_selection() {
1647 let mut input = TextInput::new().with_value("hello");
1648 input.select_all();
1649 input.delete_selection();
1650 input.insert_char('x');
1651 assert_eq!(input.value(), "x");
1652 }
1653
1654 #[test]
1655 fn test_unicode_grapheme_handling() {
1656 let mut input = TextInput::new();
1657 input.set_value("café");
1658 assert_eq!(input.grapheme_count(), 4);
1659 input.cursor = 4;
1660 input.delete_char_back();
1661 assert_eq!(input.value(), "caf");
1662 }
1663
1664 #[test]
1665 fn test_multi_codepoint_grapheme_cursor_movement() {
1666 let mut input = TextInput::new().with_value("a👩💻b");
1667 assert_eq!(input.grapheme_count(), 3);
1668 assert_eq!(input.cursor(), 3);
1669
1670 input.move_cursor_left();
1671 assert_eq!(input.cursor(), 2);
1672 input.move_cursor_left();
1673 assert_eq!(input.cursor(), 1);
1674 input.move_cursor_left();
1675 assert_eq!(input.cursor(), 0);
1676
1677 input.move_cursor_right();
1678 assert_eq!(input.cursor(), 1);
1679 input.move_cursor_right();
1680 assert_eq!(input.cursor(), 2);
1681 input.move_cursor_right();
1682 assert_eq!(input.cursor(), 3);
1683 }
1684
1685 #[test]
1686 fn test_delete_back_multi_codepoint_grapheme() {
1687 let mut input = TextInput::new().with_value("a👩💻b");
1688 input.cursor = 2; input.delete_char_back();
1690 assert_eq!(input.value(), "ab");
1691 assert_eq!(input.cursor(), 1);
1692 assert_eq!(input.grapheme_count(), 2);
1693 }
1694
1695 #[test]
1696 fn test_ime_composition_start_update_commit() {
1697 let mut input = TextInput::new().with_value("ab");
1698 input.cursor = 1;
1699
1700 input.ime_start_composition();
1701 assert_eq!(input.ime_composition(), Some(""));
1702
1703 input.ime_update_composition("漢");
1704 assert_eq!(input.ime_composition(), Some("漢"));
1705
1706 assert!(input.ime_commit_composition());
1707 assert_eq!(input.ime_composition(), None);
1708 assert_eq!(input.value(), "a漢b");
1709 assert_eq!(input.cursor(), 2);
1710 }
1711
1712 #[test]
1713 fn test_ime_composition_cancel_keeps_value() {
1714 let mut input = TextInput::new().with_value("hello");
1715 input.ime_start_composition();
1716 input.ime_update_composition("👩💻");
1717 assert_eq!(input.ime_composition(), Some("👩💻"));
1718 assert!(input.ime_cancel_composition());
1719 assert_eq!(input.ime_composition(), None);
1720 assert_eq!(input.value(), "hello");
1721 assert_eq!(input.cursor(), 5);
1722 }
1723
1724 #[test]
1725 fn test_ime_commit_without_session_is_noop() {
1726 let mut input = TextInput::new().with_value("abc");
1727 assert!(!input.ime_commit_composition());
1728 assert_eq!(input.value(), "abc");
1729 assert_eq!(input.cursor(), 3);
1730 }
1731
1732 #[test]
1733 fn test_handle_event_ime_update_and_commit() {
1734 let mut input = TextInput::new().with_value("ab");
1735 input.cursor = 1;
1736
1737 assert!(input.handle_event(&Event::Ime(ImeEvent::start())));
1738 assert!(input.handle_event(&Event::Ime(ImeEvent::update("漢"))));
1739 assert_eq!(input.ime_composition(), Some("漢"));
1740 assert!(input.handle_event(&Event::Ime(ImeEvent::commit("漢"))));
1741 assert_eq!(input.ime_composition(), None);
1742 assert_eq!(input.value(), "a漢b");
1743 assert_eq!(input.cursor(), 2);
1744 }
1745
1746 #[test]
1747 fn test_handle_event_ime_cancel() {
1748 let mut input = TextInput::new().with_value("hello");
1749 input.cursor = 5;
1750 assert!(input.handle_event(&Event::Ime(ImeEvent::start())));
1751 assert!(input.handle_event(&Event::Ime(ImeEvent::update("👩💻"))));
1752 assert!(input.handle_event(&Event::Ime(ImeEvent::cancel())));
1753 assert_eq!(input.ime_composition(), None);
1754 assert_eq!(input.value(), "hello");
1755 assert_eq!(input.cursor(), 5);
1756 }
1757
1758 #[test]
1759 fn test_flag_emoji_grapheme_delete_and_cursor() {
1760 let mut input = TextInput::new().with_value("a🇺🇸b");
1761 assert_eq!(input.grapheme_count(), 3);
1762 input.cursor = 2;
1763 input.delete_char_back();
1764 assert_eq!(input.value(), "ab");
1765 assert_eq!(input.cursor(), 1);
1766 }
1767
1768 #[test]
1769 fn test_combining_grapheme_delete_and_cursor() {
1770 let mut input = TextInput::new().with_value("a\u{0301}b");
1771 assert_eq!(input.grapheme_count(), 2);
1772 input.cursor = 1;
1773 input.delete_char_back();
1774 assert_eq!(input.value(), "b");
1775 assert_eq!(input.cursor(), 0);
1776 }
1777
1778 #[test]
1779 fn test_bidi_logical_cursor_movement_over_graphemes() {
1780 let mut input = TextInput::new().with_value("AאבB");
1781 assert_eq!(input.grapheme_count(), 4);
1782
1783 input.move_cursor_left();
1784 assert_eq!(input.cursor(), 3);
1785 input.move_cursor_left();
1786 assert_eq!(input.cursor(), 2);
1787 input.move_cursor_left();
1788 assert_eq!(input.cursor(), 1);
1789 input.move_cursor_left();
1790 assert_eq!(input.cursor(), 0);
1791
1792 input.move_cursor_right();
1793 assert_eq!(input.cursor(), 1);
1794 input.move_cursor_right();
1795 assert_eq!(input.cursor(), 2);
1796 input.move_cursor_right();
1797 assert_eq!(input.cursor(), 3);
1798 input.move_cursor_right();
1799 assert_eq!(input.cursor(), 4);
1800 }
1801
1802 #[test]
1803 fn test_handle_event_char() {
1804 let mut input = TextInput::new();
1805 let event = Event::Key(KeyEvent::new(KeyCode::Char('a')));
1806 assert!(input.handle_event(&event));
1807 assert_eq!(input.value(), "a");
1808 }
1809
1810 #[test]
1811 fn test_handle_event_backspace() {
1812 let mut input = TextInput::new().with_value("ab");
1813 let event = Event::Key(KeyEvent::new(KeyCode::Backspace));
1814 assert!(input.handle_event(&event));
1815 assert_eq!(input.value(), "a");
1816 }
1817
1818 #[test]
1819 fn test_handle_event_ctrl_a() {
1820 let mut input = TextInput::new().with_value("hello");
1821 let event = Event::Key(KeyEvent::new(KeyCode::Char('a')).with_modifiers(Modifiers::CTRL));
1822 assert!(input.handle_event(&event));
1823 assert_eq!(input.selected_text(), Some("hello"));
1824 }
1825
1826 #[test]
1827 fn test_handle_event_ctrl_backspace() {
1828 let mut input = TextInput::new().with_value("hello world");
1829 let event = Event::Key(KeyEvent::new(KeyCode::Backspace).with_modifiers(Modifiers::CTRL));
1830 assert!(input.handle_event(&event));
1831 assert_eq!(input.value(), "hello ");
1832 }
1833
1834 #[test]
1835 fn test_handle_event_home_end() {
1836 let mut input = TextInput::new().with_value("hello");
1837 input.cursor = 3;
1838 let home = Event::Key(KeyEvent::new(KeyCode::Home));
1839 assert!(input.handle_event(&home));
1840 assert_eq!(input.cursor(), 0);
1841 let end = Event::Key(KeyEvent::new(KeyCode::End));
1842 assert!(input.handle_event(&end));
1843 assert_eq!(input.cursor(), 5);
1844 }
1845
1846 #[test]
1847 fn test_shift_left_creates_selection() {
1848 let mut input = TextInput::new().with_value("hello");
1849 let event = Event::Key(KeyEvent::new(KeyCode::Left).with_modifiers(Modifiers::SHIFT));
1850 assert!(input.handle_event(&event));
1851 assert_eq!(input.cursor(), 4);
1852 assert_eq!(input.selection_anchor, Some(5));
1853 assert_eq!(input.selected_text(), Some("o"));
1854 }
1855
1856 #[test]
1857 fn test_cursor_position() {
1858 let input = TextInput::new().with_value("hello");
1859 let area = Rect::new(10, 5, 20, 1);
1860 let (x, y) = input.cursor_position(area);
1861 assert_eq!(x, 15);
1862 assert_eq!(y, 5);
1863 }
1864
1865 #[test]
1866 fn test_cursor_position_empty() {
1867 let input = TextInput::new();
1868 let area = Rect::new(0, 0, 80, 1);
1869 let (x, y) = input.cursor_position(area);
1870 assert_eq!(x, 0);
1871 assert_eq!(y, 0);
1872 }
1873
1874 #[test]
1875 fn test_password_mask() {
1876 let input = TextInput::new().with_mask('*').with_value("secret");
1877 assert_eq!(input.value(), "secret");
1878 assert_eq!(input.cursor_visual_pos(), 6);
1879 }
1880
1881 #[test]
1882 fn test_render_basic() {
1883 use ftui_render::frame::Frame;
1884 use ftui_render::grapheme_pool::GraphemePool;
1885
1886 let input = TextInput::new().with_value("hi");
1887 let area = Rect::new(0, 0, 10, 1);
1888 let mut pool = GraphemePool::new();
1889 let mut frame = Frame::new(10, 1, &mut pool);
1890 input.render(area, &mut frame);
1891 let cell_h = cell_at(&frame, 0, 0);
1892 assert_eq!(cell_h.content.as_char(), Some('h'));
1893 let cell_i = cell_at(&frame, 1, 0);
1894 assert_eq!(cell_i.content.as_char(), Some('i'));
1895 }
1896
1897 #[test]
1898 fn test_render_sets_cursor_when_focused() {
1899 use ftui_render::frame::Frame;
1900 use ftui_render::grapheme_pool::GraphemePool;
1901
1902 let input = TextInput::new().with_value("hi").with_focused(true);
1903 let area = Rect::new(0, 0, 10, 1);
1904 let mut pool = GraphemePool::new();
1905 let mut frame = Frame::new(10, 1, &mut pool);
1906 input.render(area, &mut frame);
1907
1908 assert_eq!(frame.cursor_position, Some((2, 0)));
1909 assert!(frame.cursor_visible);
1910 }
1911
1912 #[test]
1913 fn test_render_does_not_set_cursor_when_unfocused() {
1914 use ftui_render::frame::Frame;
1915 use ftui_render::grapheme_pool::GraphemePool;
1916
1917 let input = TextInput::new().with_value("hi");
1918 let area = Rect::new(0, 0, 10, 1);
1919 let mut pool = GraphemePool::new();
1920 let mut frame = Frame::new(10, 1, &mut pool);
1921 input.render(area, &mut frame);
1922
1923 assert!(frame.cursor_position.is_none());
1924 }
1925
1926 #[test]
1927 fn test_render_grapheme_uses_pool() {
1928 use ftui_render::frame::Frame;
1929 use ftui_render::grapheme_pool::GraphemePool;
1930
1931 let grapheme = "👩💻";
1932 let input = TextInput::new().with_value(grapheme);
1933 let area = Rect::new(0, 0, 6, 1);
1934 let mut pool = GraphemePool::new();
1935 let mut frame = Frame::new(6, 1, &mut pool);
1936 input.render(area, &mut frame);
1937
1938 let cell = cell_at(&frame, 0, 0);
1939 assert!(cell.content.is_grapheme());
1940 let width = grapheme_width(grapheme);
1941 if width > 1 {
1942 assert!(cell_at(&frame, 1, 0).is_continuation());
1943 }
1944 }
1945
1946 #[test]
1947 fn test_render_shorter_value_clears_stale_suffix_and_owned_rows() {
1948 use ftui_render::frame::Frame;
1949 use ftui_render::grapheme_pool::GraphemePool;
1950
1951 let area = Rect::new(0, 0, 8, 2);
1952 let mut pool = GraphemePool::new();
1953 let mut frame = Frame::new(8, 2, &mut pool);
1954
1955 TextInput::new()
1956 .with_value("abcdef")
1957 .render(area, &mut frame);
1958 TextInput::new().with_value("xy").render(area, &mut frame);
1959
1960 assert_eq!(raw_row_text(&frame, 0, 8), "xy ");
1961 assert_eq!(raw_row_text(&frame, 1, 8), " ");
1962 }
1963
1964 #[test]
1965 fn test_left_collapses_selection() {
1966 let mut input = TextInput::new().with_value("hello");
1967 input.selection_anchor = Some(1);
1968 input.cursor = 4;
1969 input.move_cursor_left();
1970 assert_eq!(input.cursor(), 1);
1971 assert!(input.selection_anchor.is_none());
1972 }
1973
1974 #[test]
1975 fn test_right_collapses_selection() {
1976 let mut input = TextInput::new().with_value("hello");
1977 input.selection_anchor = Some(1);
1978 input.cursor = 4;
1979 input.move_cursor_right();
1980 assert_eq!(input.cursor(), 4);
1981 assert!(input.selection_anchor.is_none());
1982 }
1983
1984 #[test]
1985 fn test_render_sets_frame_cursor() {
1986 use ftui_render::frame::Frame;
1987 use ftui_render::grapheme_pool::GraphemePool;
1988
1989 let input = TextInput::new().with_value("hello").with_focused(true);
1990 let area = Rect::new(5, 3, 20, 1);
1991 let mut pool = GraphemePool::new();
1992 let mut frame = Frame::new(30, 10, &mut pool);
1993 input.render(area, &mut frame);
1994
1995 assert_eq!(frame.cursor_position, Some((10, 3)));
1999 }
2000
2001 #[test]
2002 fn test_render_cursor_mid_text() {
2003 use ftui_render::frame::Frame;
2004 use ftui_render::grapheme_pool::GraphemePool;
2005
2006 let mut input = TextInput::new().with_value("hello").with_focused(true);
2007 input.cursor = 2; let area = Rect::new(0, 0, 20, 1);
2009 let mut pool = GraphemePool::new();
2010 let mut frame = Frame::new(20, 1, &mut pool);
2011 input.render(area, &mut frame);
2012
2013 assert_eq!(frame.cursor_position, Some((2, 0)));
2015 }
2016
2017 #[test]
2022 fn test_undo_widget_id_is_stable() {
2023 let input = TextInput::new();
2024 let id1 = input.undo_id();
2025 let id2 = input.undo_id();
2026 assert_eq!(id1, id2);
2027 }
2028
2029 #[test]
2030 fn test_undo_widget_id_unique_per_instance() {
2031 let input1 = TextInput::new();
2032 let input2 = TextInput::new();
2033 assert_ne!(input1.undo_id(), input2.undo_id());
2034 }
2035
2036 #[test]
2037 fn test_snapshot_and_restore() {
2038 let mut input = TextInput::new().with_value("hello");
2039 input.cursor = 3;
2040 input.selection_anchor = Some(1);
2041
2042 let snapshot = input.create_snapshot();
2043
2044 input.set_value("world");
2046 input.cursor = 5;
2047 input.selection_anchor = None;
2048
2049 assert_eq!(input.value(), "world");
2050 assert_eq!(input.cursor(), 5);
2051
2052 assert!(input.restore_snapshot(snapshot.as_ref()));
2054 assert_eq!(input.value(), "hello");
2055 assert_eq!(input.cursor(), 3);
2056 assert_eq!(input.selection_anchor, Some(1));
2057 }
2058
2059 #[test]
2060 fn test_text_input_undo_ext_insert() {
2061 let mut input = TextInput::new().with_value("hello");
2062 input.cursor = 2;
2063
2064 input.insert_text_at(2, " world");
2065 assert_eq!(input.value(), "he worldllo");
2067 assert_eq!(input.cursor(), 8); }
2069
2070 #[test]
2071 fn test_text_input_undo_ext_delete() {
2072 let mut input = TextInput::new().with_value("hello world");
2073 input.cursor = 8;
2074
2075 input.delete_text_range(5, 11); assert_eq!(input.value(), "hello");
2077 assert_eq!(input.cursor(), 5); }
2079
2080 #[test]
2081 fn test_create_text_edit_command() {
2082 let input = TextInput::new().with_value("hello");
2083 let cmd = input.create_text_edit_command(TextEditOperation::Insert {
2084 position: 0,
2085 text: "hi".to_string(),
2086 });
2087 assert!(cmd.is_some());
2088 let cmd = cmd.expect("test command should exist");
2089 assert_eq!(cmd.widget_id(), input.undo_id());
2090 assert_eq!(cmd.description(), "Insert text");
2091 }
2092
2093 #[test]
2094 fn test_paste_bulk_insert() {
2095 let mut input = TextInput::new().with_value("hello");
2096 input.cursor = 5;
2097 let event = Event::Paste(ftui_core::event::PasteEvent::bracketed(" world"));
2098 assert!(input.handle_event(&event));
2099 assert_eq!(input.value(), "hello world");
2100 assert_eq!(input.cursor(), 11);
2101 }
2102
2103 #[test]
2104 fn test_paste_multi_grapheme_sequence() {
2105 let mut input = TextInput::new().with_value("hi");
2106 input.cursor = 2;
2107 let event = Event::Paste(ftui_core::event::PasteEvent::new("👩💻🔥", false));
2108 assert!(input.handle_event(&event));
2109 assert_eq!(input.value(), "hi👩💻🔥");
2110 assert_eq!(input.cursor(), 4);
2111 }
2112
2113 #[test]
2114 fn test_paste_max_length() {
2115 let mut input = TextInput::new().with_value("abc").with_max_length(5);
2116 input.cursor = 3;
2117 let event = Event::Paste(ftui_core::event::PasteEvent::bracketed("def"));
2119 assert!(input.handle_event(&event));
2120 assert_eq!(input.value(), "abcde");
2121 assert_eq!(input.cursor(), 5);
2122 }
2123
2124 #[test]
2125 fn test_paste_combining_merge() {
2126 let mut input = TextInput::new().with_value("e");
2127 input.cursor = 1;
2128 let event = Event::Paste(ftui_core::event::PasteEvent::bracketed("\u{0301}"));
2131 assert!(input.handle_event(&event));
2132 assert_eq!(input.value(), "e\u{0301}");
2133 assert_eq!(input.grapheme_count(), 1);
2134 assert_eq!(input.cursor(), 1);
2135 }
2136
2137 #[test]
2138 fn test_paste_combining_merge_mid_string() {
2139 let mut input = TextInput::new().with_value("ab");
2140 input.cursor = 1; let event = Event::Paste(ftui_core::event::PasteEvent::bracketed("\u{0301}"));
2142 assert!(input.handle_event(&event));
2143 assert_eq!(input.value(), "a\u{0301}b");
2144 assert_eq!(input.grapheme_count(), 2);
2145 assert_eq!(input.cursor(), 1);
2146 }
2147
2148 #[test]
2149 fn test_wide_char_scroll_visibility() {
2150 use ftui_render::frame::Frame;
2151 use ftui_render::grapheme_pool::GraphemePool;
2152
2153 let wide_char = "\u{3000}"; let mut input = TextInput::new().with_value(wide_char).with_focused(true);
2155 input.cursor = 1; let area = Rect::new(0, 0, 2, 1);
2163 let mut pool = GraphemePool::new();
2164 let mut frame = Frame::new(2, 1, &mut pool);
2165 input.render(area, &mut frame);
2166
2167 let cell = cell_at(&frame, 0, 0);
2168 assert!(!cell.is_empty(), "Wide char should be visible");
2170 }
2171
2172 #[test]
2173 fn test_wide_char_scroll_snapping() {
2174 let _input = TextInput::new().with_value("a\u{3000}b"); let wide_char = "\u{3000}";
2205 let text = format!("a{wide_char}b");
2206 let mut input = TextInput::new().with_value(&text);
2207
2208 input.cursor = 3; let _area = Rect::new(0, 0, 2, 1); }
2305
2306 #[cfg(feature = "tracing")]
2307 #[test]
2308 fn tracing_input_edit_span_tracks_cursor_positions() {
2309 let state = Arc::new(Mutex::new(InputTraceState::default()));
2310 let _trace_test_guard = crate::tracing_test_support::acquire();
2311 let subscriber = tracing_subscriber::registry().with(InputTraceCapture {
2312 state: Arc::clone(&state),
2313 });
2314 let _guard = tracing::subscriber::set_default(subscriber);
2315 tracing::callsite::rebuild_interest_cache();
2316
2317 let mut input = TextInput::new().with_value("ab");
2318 tracing::callsite::rebuild_interest_cache();
2319 assert!(input.handle_event(&Event::Key(KeyEvent::new(KeyCode::Char('c')))));
2320 tracing::callsite::rebuild_interest_cache();
2321 assert!(input.handle_event(&Event::Key(KeyEvent::new(KeyCode::Left))));
2322 tracing::callsite::rebuild_interest_cache();
2323 assert!(input.handle_event(&Event::Key(KeyEvent::new(KeyCode::Backspace))));
2324
2325 tracing::callsite::rebuild_interest_cache();
2326 let snapshot = state.lock().expect("trace state lock");
2327 assert!(
2328 snapshot.span_count >= 3,
2329 "expected at least 3 input.edit spans, got {}",
2330 snapshot.span_count
2331 );
2332 assert!(
2333 snapshot.has_cursor_position_field,
2334 "input.edit span missing cursor_position field"
2335 );
2336 assert_eq!(
2337 snapshot.cursor_positions,
2338 vec![3, 2, 1],
2339 "expected cursor positions after insert/left/backspace"
2340 );
2341 assert!(
2342 snapshot.operations.starts_with(&[
2343 "insert_char".to_string(),
2344 "move_left".to_string(),
2345 "delete_back".to_string()
2346 ]),
2347 "unexpected operations: {:?}",
2348 snapshot.operations
2349 );
2350 }
2351}
2352
2353#[cfg(test)]
2354mod scroll_edge_tests {
2355 use super::*;
2356 use ftui_render::frame::Frame;
2357 use ftui_render::grapheme_pool::GraphemePool;
2358
2359 #[test]
2360 fn test_scroll_snap_left_cursor_visibility() {
2361 let mut input = TextInput::new().with_value("ABC");
2369 input.cursor = 1; let area = Rect::new(0, 0, 1, 1);
2374 let mut pool = GraphemePool::new();
2375 let mut frame = Frame::new(1, 1, &mut pool);
2376 input.render(area, &mut frame); input.move_cursor_left(); input.render(area, &mut frame);
2383
2384 let cell = frame.buffer.get(0, 0).unwrap();
2386 assert_eq!(cell.content.as_char(), Some('A'));
2387 }
2388
2389 #[test]
2390 fn test_max_length_replacement_failure() {
2391 let mut input = TextInput::new().with_value("abc").with_max_length(3);
2397 input.selection_anchor = Some(1); input.cursor = 2; let event = Event::Paste(ftui_core::event::PasteEvent::new("de", false));
2402 input.handle_event(&event);
2403
2404 assert_eq!(input.value(), "abc");
2406 assert_eq!(input.cursor(), 2);
2407 assert_eq!(input.selection_anchor, Some(1));
2408 }
2409}