Skip to main content

ftui_widgets/
input.rs

1#![forbid(unsafe_code)]
2
3//! Text input widget.
4//!
5//! A single-line text input field with cursor management, scrolling, selection,
6//! word-level operations, and styling. Grapheme-cluster aware for correct Unicode handling.
7
8use 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/// A single-line text input widget.
20#[derive(Debug, Clone, Default)]
21pub struct TextInput {
22    /// Unique ID for undo tracking.
23    undo_id: UndoWidgetId,
24    /// Text value.
25    value: String,
26    /// Cursor position (grapheme index).
27    cursor: usize,
28    /// Scroll offset (visual cells) for horizontal scrolling.
29    scroll_cells: std::cell::Cell<usize>,
30    /// Selection anchor (grapheme index). When set, selection spans from anchor to cursor.
31    selection_anchor: Option<usize>,
32    /// Active IME composition text (preedit), if any.
33    ime_composition: Option<String>,
34    /// Placeholder text.
35    placeholder: String,
36    /// Mask character for password mode.
37    mask_char: Option<char>,
38    /// Maximum length in graphemes (None = unlimited).
39    max_length: Option<usize>,
40    /// Base style.
41    style: Style,
42    /// Cursor style.
43    cursor_style: Style,
44    /// Placeholder style.
45    placeholder_style: Style,
46    /// Selection highlight style.
47    selection_style: Style,
48    /// Whether the input is focused (controls cursor output).
49    focused: bool,
50}
51
52impl TextInput {
53    /// Create a new empty text input.
54    pub fn new() -> Self {
55        Self::default()
56    }
57
58    // --- Builder methods ---
59
60    /// Set the text value (builder).
61    #[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    /// Set the placeholder text (builder).
70    #[must_use]
71    pub fn with_placeholder(mut self, placeholder: impl Into<String>) -> Self {
72        self.placeholder = placeholder.into();
73        self
74    }
75
76    /// Set password mode with mask character (builder).
77    #[must_use]
78    pub fn with_mask(mut self, mask: char) -> Self {
79        self.mask_char = Some(mask);
80        self
81    }
82
83    /// Set maximum length in graphemes (builder).
84    #[must_use]
85    pub fn with_max_length(mut self, max: usize) -> Self {
86        self.max_length = Some(max);
87        self
88    }
89
90    /// Set base style (builder).
91    #[must_use]
92    pub fn with_style(mut self, style: Style) -> Self {
93        self.style = style;
94        self
95    }
96
97    /// Set cursor style (builder).
98    #[must_use]
99    pub fn with_cursor_style(mut self, style: Style) -> Self {
100        self.cursor_style = style;
101        self
102    }
103
104    /// Set placeholder style (builder).
105    #[must_use]
106    pub fn with_placeholder_style(mut self, style: Style) -> Self {
107        self.placeholder_style = style;
108        self
109    }
110
111    /// Set selection style (builder).
112    #[must_use]
113    pub fn with_selection_style(mut self, style: Style) -> Self {
114        self.selection_style = style;
115        self
116    }
117
118    /// Set whether the input is focused (builder).
119    #[must_use]
120    pub fn with_focused(mut self, focused: bool) -> Self {
121        self.focused = focused;
122        self
123    }
124
125    // --- Value access ---
126
127    /// Get the current value.
128    pub fn value(&self) -> &str {
129        &self.value
130    }
131
132    /// Set the value, clamping cursor to valid range.
133    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    /// Clear all text.
142    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    /// Get the cursor position (grapheme index).
150    #[inline]
151    pub fn cursor(&self) -> usize {
152        self.cursor
153    }
154
155    /// Check if the input is focused.
156    #[inline]
157    pub fn focused(&self) -> bool {
158        self.focused
159    }
160
161    /// Set focus state.
162    pub fn set_focused(&mut self, focused: bool) {
163        self.focused = focused;
164    }
165
166    /// Get the cursor screen position relative to a render area.
167    ///
168    /// Returns `(x, y)` where x is the column and y is the row.
169    /// Useful for `Frame::set_cursor()`.
170    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    /// Get selected text, if any.
182    #[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    /// Start an IME composition session.
192    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    /// Update active IME preedit text.
202    ///
203    /// Starts composition automatically if none is active.
204    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    /// Commit active IME preedit text into the input value.
214    ///
215    /// Returns `true` if a composition session existed (even if empty).
216    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    /// Cancel the active IME composition session.
232    ///
233    /// Returns `true` if a composition session was active.
234    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    /// Get active IME preedit text, if any.
244    #[must_use]
245    pub fn ime_composition(&self) -> Option<&str> {
246        self.ime_composition.as_deref()
247    }
248
249    // --- Event handling ---
250
251    /// Handle a terminal event.
252    ///
253    /// Returns `true` if the state changed.
254    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                // For replacement pastes under a max-length constraint, reject
266                // oversized payloads before deleting the selection.
267                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            // Ctrl+A: select all
334            KeyCode::Char('a') if ctrl => {
335                self.select_all();
336                true
337            }
338            // Ctrl+W: delete word back
339            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    // --- Editing operations ---
467
468    fn sanitize_input_text(text: &str) -> String {
469        // Map line breaks/tabs to spaces, filter other control chars
470        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    /// Insert text at the current cursor position.
483    ///
484    /// This method:
485    /// - Replaces newlines and tabs with spaces.
486    /// - Filters out other control characters.
487    /// - Respects `max_length` (truncating if necessary).
488    /// - Efficiently inserts the result in one operation.
489    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                // Allow trying to insert 1 grapheme to see if it merges (combining char)
502                1
503            } else {
504                max - current_count
505            }
506        } else {
507            usize::MAX
508        };
509
510        // Calculate grapheme count of new text to see if we need to truncate
511        let new_graphemes = clean_text.graphemes(true).count();
512        let to_insert = if new_graphemes > avail {
513            // Find byte index to truncate at
514            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        // Check if we exceeded max_length
532        let new_total = self.grapheme_count();
533        if let Some(max) = self.max_length
534            && new_total > max
535        {
536            // Revert change
537            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        // Strict control character filtering to prevent terminal corruption
550        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        // Check constraints
562        if let Some(max) = self.max_length
563            && new_count > max
564        {
565            // Revert change
566            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        // Use standard movement logic to find start of deletion
612        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        // Use standard movement logic to find end of deletion
630        self.move_cursor_word_right(false);
631        let new_cursor = self.cursor;
632        // Reset cursor to start (deletion happens forward from here)
633        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    // --- Selection ---
647
648    /// Select all text.
649    pub fn select_all(&mut self) {
650        self.selection_anchor = Some(0);
651        self.cursor = self.grapheme_count();
652    }
653
654    /// Delete selected text. No-op if no selection.
655    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    // --- Cursor movement ---
693
694    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    // --- Internal helpers ---
812
813    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            // Ensure the character BEFORE the cursor is also fully visible
874            // (prevent "hole" artifact for wide characters where start < scroll)
875            let prev_width = self.prev_grapheme_width();
876            let max_scroll_for_prev = cursor_visual.saturating_sub(prev_width);
877
878            // Only enforce wide-char visibility if the viewport is wide enough to show
879            // both the character and the cursor. Prioritize cursor visibility otherwise.
880            if viewport_width > prev_width {
881                scroll = candidate_scroll.min(max_scroll_for_prev);
882            } else {
883                scroll = candidate_scroll;
884            }
885        }
886
887        // Sanitize: ensure scroll aligns with grapheme boundaries
888        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 scroll is in the middle of this grapheme: [pos < scroll < next_pos]
903            if pos < scroll && scroll < next_pos {
904                // Try snapping to the start of the character to keep it visible.
905                // Only allowed if the cursor remains visible on the right.
906                // Cursor visibility condition: cursor_visual < new_scroll + viewport_width
907                if cursor_visual <= pos + viewport_width {
908                    return pos;
909                } else {
910                    // Cannot snap left without hiding cursor. Snap right (hide the character).
911                    return next_pos;
912                }
913            }
914
915            if next_pos > scroll {
916                // Passed the scroll point, no split found.
917                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        // TextInput is essential — always render content, but skip styling
945        // at NoStyling+. At Skeleton, still render the raw text value.
946        // We explicitly DO NOT check deg.render_content() here because this widget is essential.
947        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        // Use arena allocation when available to avoid per-frame heap churn.
955        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        // Render content
971        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                // Fully scrolled out (left)
987                if visual_x + w <= effective_scroll {
988                    visual_x += w;
989                    continue;
990                }
991
992                // Partially scrolled out (left) - skip drawing
993                if visual_x < effective_scroll {
994                    visual_x += w;
995                    continue;
996                }
997
998                let rel_x = visual_x - effective_scroll;
999
1000                // Fully clipped (right)
1001                if rel_x >= viewport_width {
1002                    break;
1003                }
1004
1005                // Partially clipped (right) - skip drawing
1006                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                // Fully scrolled out (left)
1071                if visual_x + w <= effective_scroll {
1072                    visual_x += w;
1073                    continue;
1074                }
1075
1076                // Partially scrolled out (left) - skip drawing
1077                if visual_x < effective_scroll {
1078                    visual_x += w;
1079                    continue;
1080                }
1081
1082                let rel_x = visual_x - effective_scroll;
1083
1084                // Fully clipped (right)
1085                if rel_x >= viewport_width {
1086                    break;
1087                }
1088
1089                // Partially clipped (right) - skip drawing
1090                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            // Set cursor style at cursor position
1119            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                        // At NoStyling, just use reverse video for cursor
1125                        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                        // Default: toggle reverse video for cursor visibility
1131                        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
1151// ============================================================================
1152// Accessibility
1153// ============================================================================
1154
1155impl 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            // Don't expose masked values to screen readers.
1164            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// ============================================================================
1188// Undo Support Implementation
1189// ============================================================================
1190
1191/// Snapshot of TextInput state for undo.
1192#[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); // Reset scroll on restore
1218            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    /// Create an undo command for the given text edit operation.
1277    ///
1278    /// This creates a command that can be added to a [`HistoryManager`] for undo/redo support.
1279    /// The command includes callbacks that will be called when the operation is undone or redone.
1280    ///
1281    /// # Example
1282    ///
1283    /// ```ignore
1284    /// let mut input = TextInput::new();
1285    /// let old_value = input.value().to_string();
1286    ///
1287    /// // Perform the edit
1288    /// input.set_value("new text");
1289    ///
1290    /// // Create undo command
1291    /// if let Some(cmd) = input.create_text_edit_command(TextEditOperation::SetValue {
1292    ///     old_value,
1293    ///     new_value: "new text".to_string(),
1294    /// }) {
1295    ///     history.push(cmd);
1296    /// }
1297    /// ```
1298    ///
1299    /// [`HistoryManager`]: ftui_runtime::undo::HistoryManager
1300    #[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    /// Get the undo widget ID.
1312    ///
1313    /// This can be used to associate undo commands with this widget instance.
1314    #[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        // "hello world test"
1558        //                 ^ (16)
1559        input.move_cursor_word_left(false);
1560        assert_eq!(input.cursor(), 12); // "hello world |test"
1561
1562        input.move_cursor_word_left(false);
1563        assert_eq!(input.cursor(), 6); // "hello |world test"
1564
1565        input.move_cursor_word_left(false);
1566        assert_eq!(input.cursor(), 0); // "|hello world test"
1567    }
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        // "|hello world test"
1574
1575        input.move_cursor_word_right(false);
1576        assert_eq!(input.cursor(), 6); // "hello |world test"
1577
1578        input.move_cursor_word_right(false);
1579        assert_eq!(input.cursor(), 12); // "hello world |test"
1580
1581        input.move_cursor_word_right(false);
1582        assert_eq!(input.cursor(), 16); // "hello world test|"
1583    }
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        // "|hello, world"
1590
1591        input.move_cursor_word_right(false);
1592        assert_eq!(input.cursor(), 5); // "hello|, world"
1593
1594        input.move_cursor_word_right(false);
1595        assert_eq!(input.cursor(), 7); // "hello, |world"
1596
1597        input.move_cursor_word_right(false);
1598        assert_eq!(input.cursor(), 12); // "hello, world|"
1599
1600        input.move_cursor_word_left(false);
1601        assert_eq!(input.cursor(), 7); // "hello, |world"
1602    }
1603
1604    #[test]
1605    fn test_delete_word_back() {
1606        let mut input = TextInput::new().with_value("hello world");
1607        // "hello world|"
1608        input.delete_word_back();
1609        assert_eq!(input.value(), "hello "); // Deleted "world"
1610
1611        // "hello |" — word-left skips space and deletes the preceding word
1612        input.delete_word_back();
1613        assert_eq!(input.value(), ""); // Deleted "hello "
1614    }
1615
1616    #[test]
1617    fn test_delete_word_forward() {
1618        let mut input = TextInput::new().with_value("hello world");
1619        input.cursor = 0;
1620        // "|hello world" — word-right skips "hello" then space
1621        input.delete_word_forward();
1622        assert_eq!(input.value(), "world"); // Deleted "hello "
1623
1624        input.delete_word_forward();
1625        assert_eq!(input.value(), ""); // Deleted "world"
1626    }
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; // after the emoji grapheme
1689        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        // Cursor should be positioned at the end of "hello" (5 chars)
1996        // area.x = 5, cursor_visual_pos = 5, effective_scroll = 0
1997        // So cursor_screen_x = 5 + 5 = 10
1998        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; // After "he"
2008        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        // Cursor after "he" = visual position 2
2014        assert_eq!(frame.cursor_position, Some((2, 0)));
2015    }
2016
2017    // ========================================================================
2018    // Undo Support Tests
2019    // ========================================================================
2020
2021    #[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        // Modify the input
2045        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        // Restore from snapshot
2053        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        // "hello" with " world" inserted at position 2 = "he" + " world" + "llo"
2066        assert_eq!(input.value(), "he worldllo");
2067        assert_eq!(input.cursor(), 8); // cursor moved by inserted text length (6 graphemes)
2068    }
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); // Delete " world"
2076        assert_eq!(input.value(), "hello");
2077        assert_eq!(input.cursor(), 5); // cursor clamped to end of remaining text
2078    }
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        // Paste "def" (3 chars). Should be truncated to "de" (2 chars) to fit max 5.
2118        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        // Paste combining acute accent (U+0301). Should merge with 'e' -> 'é'.
2129        // Grapheme count stays 1. Cursor stays 1 (after the merged grapheme).
2130        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; // between a and b
2141        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}"; // Ideographic space, Width 2
2154        let mut input = TextInput::new().with_value(wide_char).with_focused(true);
2155        input.cursor = 1; // After the char
2156
2157        // Viewport width 2.
2158        // cursor_visual_pos = 2.
2159        // effective_scroll: 2 >= 0 + 2 -> scroll = 1.
2160        // Render: char at 0..2. 0 < 1 -> Skipped!
2161        // Expectation: We should see it.
2162        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        // If bug exists, this assertion will fail because cell is empty/default
2169        assert!(!cell.is_empty(), "Wide char should be visible");
2170    }
2171
2172    #[test]
2173    fn test_wide_char_scroll_snapping() {
2174        // Verify that effective_scroll snaps to grapheme boundaries
2175        let _input = TextInput::new().with_value("a\u{3000}b"); // "a", "ID_SPACE", "b"
2176        // Widths: 1, 2, 1. Positions: 0, 1, 3. Total 4.
2177
2178        // Force scroll to 2 (middle of wide char)
2179        // We can't directly set scroll_cells (private), but we can manipulate cursor/viewport
2180        // to force the logic.
2181
2182        // Viewport width 2.
2183        // Move cursor to end (pos 4).
2184        // effective_scroll tries to show cursor.
2185        // cursor_visual = 4.
2186        // candidate = 4 - 2 + 1 = 3.
2187        // prev_width (b) = 1. max_scroll_for_prev = 4 - 1 = 3.
2188        // scroll = 3.
2189        // At scroll 3, we show "b" (pos 3).
2190        //
2191        // Let's try to force it to land on 2.
2192        // value: "\u{3000}a" (Wide, a).
2193        // Pos: 0, 2. Total 3.
2194        // Cursor at 2 (after wide char).
2195        // Viewport 1.
2196        // cursor_visual = 2.
2197        // candidate = 2 - 1 + 1 = 2.
2198        // prev_width = 2. max_scroll_for_prev = 2 - 2 = 0.
2199        // scroll = min(2, 0) = 0.
2200        // It snaps to 0!
2201
2202        // Let's use the internal logic test if possible, or observe via render.
2203
2204        let wide_char = "\u{3000}";
2205        let text = format!("a{wide_char}b");
2206        let mut input = TextInput::new().with_value(&text);
2207
2208        // We simulate the logic by calling effective_scroll via render logic paths
2209        // or by unit testing the private method if we expose it?
2210        // No, we rely on behavior.
2211
2212        // Case 1: Scroll lands on 2 (start of wide char). Valid.
2213        // Case 2: Scroll lands on 1 (start of 'a' is 0, wide is 1..3).
2214        // Scroll 1 means we skip 'a'. Wide char moves to x=0. Valid.
2215
2216        // Wait, grapheme 1 starts at visual pos 1.
2217        // If scroll is 2. We skip 'a' (1) and half of wide char?
2218        // No. "a" width 1. "Wide" width 2.
2219        // Visual positions: "a"@[0], "Wide"@[1,2].
2220        // If scroll = 1. "a" is at -1. "Wide" is at 0,1. Visible.
2221        // If scroll = 2. "a" at -2. "Wide" at -1,0.
2222        //   Render loop: g="Wide", w=2. visual_x starts at 1.
2223        //   visual_x (1) < scroll (2). visual_x += 2 -> 3. Continue.
2224        //   Wide char is SKIPPED.
2225        //   We see nothing (or 'b').
2226        //   This is the "pop" artifact.
2227
2228        // We want scroll to NOT be 2. It should be 1 or 3.
2229
2230        // How to force scroll=2?
2231        // Cursor at 3 (after 'b'?).
2232        // 'b' is at 3. width 1.
2233        // If we want 'b' visible at x=0. Scroll must be 3.
2234
2235        // We need a setup where candidate_scroll lands on 2.
2236        // Let cursor be at 4 (end of string).
2237        // Viewport 2.
2238        // candidate = 4 - 2 + 1 = 3.
2239
2240        // Let cursor be at 3 (start of b).
2241        // candidate = 3 - 2 + 1 = 2.
2242        // If we force scroll=2.
2243
2244        input.cursor = 3; // Before 'b', after wide char.
2245        let _area = Rect::new(0, 0, 2, 1); // Width 2
2246        // effective_scroll logic:
2247        // cursor_visual = 3.
2248        // candidate = 3 - 2 + 1 = 2.
2249        // prev_width (Wide) = 2. max_scroll_for_prev = 3 - 2 = 1.
2250        // min(2, 1) = 1.
2251        // The existing logic `max_scroll_for_prev` ALREADY protects the previous char!
2252
2253        // So when does the bug happen?
2254        // When we are NOT near the cursor?
2255        // No, effective_scroll is driven by cursor.
2256
2257        // What if we have multiple wide chars?
2258        // "\u{3000}\u{3000}" (Wide, Wide).
2259        // Widths: 2, 2. Pos: 0, 2. Total 4.
2260        // Cursor at 4. Viewport 3.
2261        // candidate = 4 - 3 + 1 = 2.
2262        // prev (Wide #2) width 2. max_for_prev = 4 - 2 = 2.
2263        // scroll = 2.
2264        // Render:
2265        //   Wide#1 @ 0..2. scroll=2. 0 < 2. Skipped. Correct.
2266        //   Wide#2 @ 2..4. scroll=2. 2 >= 2. Rendered at 0. Correct.
2267
2268        // Wait, what if scroll lands *inside* a wide char?
2269        // Text: "a" (1) + "Wide" (2). Total 3.
2270        // Cursor at 3. Viewport 2.
2271        // candidate = 3 - 2 + 1 = 2.
2272        // prev (Wide) width 2. max_for_prev = 3 - 2 = 1.
2273        // scroll = 1.
2274        // Render:
2275        //   'a' @ 0. < 1. Skipped.
2276        //   Wide @ 1. >= 1. Rendered at 0.
2277        // Correct.
2278
2279        // So `max_scroll_for_prev` protects the char *immediately* before the cursor.
2280        // But what about chars before THAT?
2281        // Text: "Wide1" (2) + "Wide2" (2).
2282        // Cursor at 4. Viewport 2.
2283        // scroll = 2.
2284        // Wide1 @ 0. < 2. Skipped.
2285        // Wide2 @ 2. >= 2. Rendered.
2286
2287        // Is it possible for scroll to land on 1?
2288        // If we manually scroll? Input widget doesn't expose manual scroll.
2289        // It relies on cursor position.
2290
2291        // What if viewport is 1?
2292        // Cursor at 4.
2293        // candidate = 4 - 1 + 1 = 4.
2294        // max_for_prev = 4 - 2 = 2.
2295        // scroll = 2.
2296        // Wide1 @ 0. Skipped.
2297        // Wide2 @ 2. Rendered at 0. (Clipped to width 1).
2298
2299        // It seems `max_scroll_for_prev` handles the "hole" issue for the active character.
2300        // But the user issue mentioned "partial scrolling".
2301
2302        // Let's assume the fix `snap_scroll_to_grapheme_boundary` is robust regardless.
2303        // It prevents ANY scroll value from landing inside a grapheme.
2304    }
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        // Test the scenario: [Char A][Char B][Char C]
2362        // Viewport width 1.
2363        // Scroll is at B.
2364        // Move cursor to A (left).
2365        // Cursor visual pos = 0.
2366        // effective_scroll must become 0.
2367
2368        let mut input = TextInput::new().with_value("ABC");
2369        input.cursor = 1; // At B
2370
2371        // Force internal scroll to 1 (B) by rendering with narrow viewport
2372        // We can't set private scroll_cells directly, but we can simulate state
2373        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); // Scroll should be 1
2377
2378        // Now move left
2379        input.move_cursor_left(); // Cursor at A (0)
2380
2381        // Render again
2382        input.render(area, &mut frame);
2383
2384        // Cell should be A
2385        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        // "abc", max 3. Select "b". Insert "de".
2392        // Should delete "b" -> "ac".
2393        // Try insert "de" -> "adec" (len 4).
2394        // Should revert insert -> "ac".
2395
2396        let mut input = TextInput::new().with_value("abc").with_max_length(3);
2397        input.selection_anchor = Some(1); // "b" start
2398        input.cursor = 2; // "b" end
2399
2400        // Simulate paste "de"
2401        let event = Event::Paste(ftui_core::event::PasteEvent::new("de", false));
2402        input.handle_event(&event);
2403
2404        // Oversized pastes replace nothing and leave selection intact.
2405        assert_eq!(input.value(), "abc");
2406        assert_eq!(input.cursor(), 2);
2407        assert_eq!(input.selection_anchor, Some(1));
2408    }
2409}