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, 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::Widget;
17use crate::undo_support::{TextEditOperation, TextInputUndoExt, UndoSupport, UndoWidgetId};
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    /// Placeholder text.
33    placeholder: String,
34    /// Mask character for password mode.
35    mask_char: Option<char>,
36    /// Maximum length in graphemes (None = unlimited).
37    max_length: Option<usize>,
38    /// Base style.
39    style: Style,
40    /// Cursor style.
41    cursor_style: Style,
42    /// Placeholder style.
43    placeholder_style: Style,
44    /// Selection highlight style.
45    selection_style: Style,
46    /// Whether the input is focused (controls cursor output).
47    focused: bool,
48}
49
50impl TextInput {
51    /// Create a new empty text input.
52    pub fn new() -> Self {
53        Self::default()
54    }
55
56    // --- Builder methods ---
57
58    /// Set the text value (builder).
59    #[must_use]
60    pub fn with_value(mut self, value: impl Into<String>) -> Self {
61        self.value = value.into();
62        self.cursor = self.value.graphemes(true).count();
63        self.selection_anchor = None;
64        self
65    }
66
67    /// Set the placeholder text (builder).
68    #[must_use]
69    pub fn with_placeholder(mut self, placeholder: impl Into<String>) -> Self {
70        self.placeholder = placeholder.into();
71        self
72    }
73
74    /// Set password mode with mask character (builder).
75    #[must_use]
76    pub fn with_mask(mut self, mask: char) -> Self {
77        self.mask_char = Some(mask);
78        self
79    }
80
81    /// Set maximum length in graphemes (builder).
82    #[must_use]
83    pub fn with_max_length(mut self, max: usize) -> Self {
84        self.max_length = Some(max);
85        self
86    }
87
88    /// Set base style (builder).
89    #[must_use]
90    pub fn with_style(mut self, style: Style) -> Self {
91        self.style = style;
92        self
93    }
94
95    /// Set cursor style (builder).
96    #[must_use]
97    pub fn with_cursor_style(mut self, style: Style) -> Self {
98        self.cursor_style = style;
99        self
100    }
101
102    /// Set placeholder style (builder).
103    #[must_use]
104    pub fn with_placeholder_style(mut self, style: Style) -> Self {
105        self.placeholder_style = style;
106        self
107    }
108
109    /// Set selection style (builder).
110    #[must_use]
111    pub fn with_selection_style(mut self, style: Style) -> Self {
112        self.selection_style = style;
113        self
114    }
115
116    /// Set whether the input is focused (builder).
117    #[must_use]
118    pub fn with_focused(mut self, focused: bool) -> Self {
119        self.focused = focused;
120        self
121    }
122
123    // --- Value access ---
124
125    /// Get the current value.
126    pub fn value(&self) -> &str {
127        &self.value
128    }
129
130    /// Set the value, clamping cursor to valid range.
131    pub fn set_value(&mut self, value: impl Into<String>) {
132        self.value = value.into();
133        let max = self.grapheme_count();
134        self.cursor = self.cursor.min(max);
135        self.scroll_cells.set(0);
136        self.selection_anchor = None;
137    }
138
139    /// Clear all text.
140    pub fn clear(&mut self) {
141        self.value.clear();
142        self.cursor = 0;
143        self.scroll_cells.set(0);
144        self.selection_anchor = None;
145    }
146
147    /// Get the cursor position (grapheme index).
148    #[inline]
149    pub fn cursor(&self) -> usize {
150        self.cursor
151    }
152
153    /// Check if the input is focused.
154    #[inline]
155    pub fn focused(&self) -> bool {
156        self.focused
157    }
158
159    /// Set focus state.
160    pub fn set_focused(&mut self, focused: bool) {
161        self.focused = focused;
162    }
163
164    /// Get the cursor screen position relative to a render area.
165    ///
166    /// Returns `(x, y)` where x is the column and y is the row.
167    /// Useful for `Frame::set_cursor()`.
168    pub fn cursor_position(&self, area: Rect) -> (u16, u16) {
169        let cursor_visual = self.cursor_visual_pos();
170        let effective_scroll = self.effective_scroll(area.width as usize);
171        let rel_x = cursor_visual.saturating_sub(effective_scroll);
172        let x = area
173            .x
174            .saturating_add(rel_x as u16)
175            .min(area.right().saturating_sub(1));
176        (x, area.y)
177    }
178
179    /// Get selected text, if any.
180    #[must_use]
181    pub fn selected_text(&self) -> Option<&str> {
182        let anchor = self.selection_anchor?;
183        let (start, end) = self.selection_range(anchor);
184        let byte_start = self.grapheme_byte_offset(start);
185        let byte_end = self.grapheme_byte_offset(end);
186        Some(&self.value[byte_start..byte_end])
187    }
188
189    // --- Event handling ---
190
191    /// Handle a terminal event.
192    ///
193    /// Returns `true` if the state changed.
194    pub fn handle_event(&mut self, event: &Event) -> bool {
195        match event {
196            Event::Key(key)
197                if key.kind == KeyEventKind::Press || key.kind == KeyEventKind::Repeat =>
198            {
199                self.handle_key(key)
200            }
201            Event::Paste(paste) => {
202                self.delete_selection();
203                self.insert_text(&paste.text);
204                true
205            }
206            _ => false,
207        }
208    }
209
210    fn handle_key(&mut self, key: &KeyEvent) -> bool {
211        let ctrl = key.modifiers.contains(Modifiers::CTRL);
212        let shift = key.modifiers.contains(Modifiers::SHIFT);
213
214        match key.code {
215            KeyCode::Char(c) if !ctrl => {
216                self.delete_selection();
217                self.insert_char(c);
218                true
219            }
220            // Ctrl+A: select all
221            KeyCode::Char('a') if ctrl => {
222                self.select_all();
223                true
224            }
225            // Ctrl+W: delete word back
226            KeyCode::Char('w') if ctrl => {
227                self.delete_word_back();
228                true
229            }
230            KeyCode::Backspace => {
231                if self.selection_anchor.is_some() {
232                    self.delete_selection();
233                } else if ctrl {
234                    self.delete_word_back();
235                } else {
236                    self.delete_char_back();
237                }
238                true
239            }
240            KeyCode::Delete => {
241                if self.selection_anchor.is_some() {
242                    self.delete_selection();
243                } else if ctrl {
244                    self.delete_word_forward();
245                } else {
246                    self.delete_char_forward();
247                }
248                true
249            }
250            KeyCode::Left => {
251                if ctrl {
252                    self.move_cursor_word_left(shift);
253                } else if shift {
254                    self.move_cursor_left_select();
255                } else {
256                    self.move_cursor_left();
257                }
258                true
259            }
260            KeyCode::Right => {
261                if ctrl {
262                    self.move_cursor_word_right(shift);
263                } else if shift {
264                    self.move_cursor_right_select();
265                } else {
266                    self.move_cursor_right();
267                }
268                true
269            }
270            KeyCode::Home => {
271                if shift {
272                    self.ensure_selection_anchor();
273                } else {
274                    self.selection_anchor = None;
275                }
276                self.cursor = 0;
277                self.scroll_cells.set(0);
278                true
279            }
280            KeyCode::End => {
281                if shift {
282                    self.ensure_selection_anchor();
283                } else {
284                    self.selection_anchor = None;
285                }
286                self.cursor = self.grapheme_count();
287                true
288            }
289            _ => false,
290        }
291    }
292
293    // --- Editing operations ---
294
295    /// Insert text at the current cursor position.
296    ///
297    /// This method:
298    /// - Replaces newlines and tabs with spaces.
299    /// - Filters out other control characters.
300    /// - Respects `max_length` (truncating if necessary).
301    /// - Efficiently inserts the result in one operation.
302    pub fn insert_text(&mut self, text: &str) {
303        // Map line breaks/tabs to spaces, filter other control chars
304        let clean_text: String = text
305            .chars()
306            .map(|c| {
307                if c == '\n' || c == '\r' || c == '\t' {
308                    ' '
309                } else {
310                    c
311                }
312            })
313            .filter(|c| !c.is_control())
314            .collect();
315
316        if clean_text.is_empty() {
317            return;
318        }
319
320        let current_count = self.grapheme_count();
321        let old_cursor = self.cursor;
322        let avail = if let Some(max) = self.max_length {
323            if current_count >= max {
324                // Allow trying to insert 1 grapheme to see if it merges (combining char)
325                1
326            } else {
327                max - current_count
328            }
329        } else {
330            usize::MAX
331        };
332
333        // Calculate grapheme count of new text to see if we need to truncate
334        let new_graphemes = clean_text.graphemes(true).count();
335        let to_insert = if new_graphemes > avail {
336            // Find byte index to truncate at
337            let end_byte = clean_text
338                .grapheme_indices(true)
339                .map(|(i, _)| i)
340                .nth(avail)
341                .unwrap_or(clean_text.len());
342            &clean_text[..end_byte]
343        } else {
344            clean_text.as_str()
345        };
346
347        if to_insert.is_empty() {
348            return;
349        }
350
351        let byte_offset = self.grapheme_byte_offset(self.cursor);
352        self.value.insert_str(byte_offset, to_insert);
353
354        // Check if we exceeded max_length
355        let new_total = self.grapheme_count();
356        if let Some(max) = self.max_length
357            && new_total > max
358        {
359            // Revert change
360            self.value.drain(byte_offset..byte_offset + to_insert.len());
361            return;
362        }
363
364        let gc = self.grapheme_count();
365        let delta = gc.saturating_sub(current_count);
366        self.cursor = (old_cursor + delta).min(gc);
367    }
368
369    fn insert_char(&mut self, c: char) {
370        // Strict control character filtering to prevent terminal corruption
371        if c.is_control() {
372            return;
373        }
374
375        let old_count = self.grapheme_count();
376        let byte_offset = self.grapheme_byte_offset(self.cursor);
377        self.value.insert(byte_offset, c);
378
379        let new_count = self.grapheme_count();
380
381        // Check constraints
382        if let Some(max) = self.max_length
383            && new_count > max
384        {
385            // Revert change
386            let char_len = c.len_utf8();
387            self.value.drain(byte_offset..byte_offset + char_len);
388            return;
389        }
390
391        // Only advance cursor if we added a new grapheme.
392        // If we inserted a combining char that merged with the previous one,
393        // the count stays the same, and the cursor should stay after that merged grapheme (same index).
394        if new_count > old_count {
395            self.cursor += 1;
396        }
397    }
398
399    fn delete_char_back(&mut self) {
400        if self.cursor > 0 {
401            let byte_start = self.grapheme_byte_offset(self.cursor - 1);
402            let byte_end = self.grapheme_byte_offset(self.cursor);
403            self.value.drain(byte_start..byte_end);
404            self.cursor -= 1;
405        }
406    }
407
408    fn delete_char_forward(&mut self) {
409        let count = self.grapheme_count();
410        if self.cursor < count {
411            let byte_start = self.grapheme_byte_offset(self.cursor);
412            let byte_end = self.grapheme_byte_offset(self.cursor + 1);
413            self.value.drain(byte_start..byte_end);
414        }
415    }
416
417    fn delete_word_back(&mut self) {
418        let old_cursor = self.cursor;
419        self.move_cursor_word_left(false);
420        let new_cursor = self.cursor;
421        if new_cursor < old_cursor {
422            let byte_start = self.grapheme_byte_offset(new_cursor);
423            let byte_end = self.grapheme_byte_offset(old_cursor);
424            self.value.drain(byte_start..byte_end);
425        }
426    }
427
428    fn delete_word_forward(&mut self) {
429        let old_cursor = self.cursor;
430        // Use standard movement logic to find end of deletion
431        self.move_cursor_word_right(false);
432        let new_cursor = self.cursor;
433        // Reset cursor to start (deletion happens forward from here)
434        self.cursor = old_cursor;
435
436        if new_cursor > old_cursor {
437            let byte_start = self.grapheme_byte_offset(old_cursor);
438            let byte_end = self.grapheme_byte_offset(new_cursor);
439            self.value.drain(byte_start..byte_end);
440        }
441    }
442
443    // --- Selection ---
444
445    /// Select all text.
446    pub fn select_all(&mut self) {
447        self.selection_anchor = Some(0);
448        self.cursor = self.grapheme_count();
449    }
450
451    /// Delete selected text. No-op if no selection.
452    fn delete_selection(&mut self) {
453        if let Some(anchor) = self.selection_anchor.take() {
454            let (start, end) = self.selection_range(anchor);
455            let byte_start = self.grapheme_byte_offset(start);
456            let byte_end = self.grapheme_byte_offset(end);
457            self.value.drain(byte_start..byte_end);
458            self.cursor = start;
459        }
460    }
461
462    fn ensure_selection_anchor(&mut self) {
463        if self.selection_anchor.is_none() {
464            self.selection_anchor = Some(self.cursor);
465        }
466    }
467
468    fn selection_range(&self, anchor: usize) -> (usize, usize) {
469        if anchor <= self.cursor {
470            (anchor, self.cursor)
471        } else {
472            (self.cursor, anchor)
473        }
474    }
475
476    fn is_in_selection(&self, grapheme_idx: usize) -> bool {
477        if let Some(anchor) = self.selection_anchor {
478            let (start, end) = self.selection_range(anchor);
479            grapheme_idx >= start && grapheme_idx < end
480        } else {
481            false
482        }
483    }
484
485    // --- Cursor movement ---
486
487    fn move_cursor_left(&mut self) {
488        if let Some(anchor) = self.selection_anchor.take() {
489            self.cursor = self.cursor.min(anchor);
490        } else if self.cursor > 0 {
491            self.cursor -= 1;
492        }
493    }
494
495    fn move_cursor_right(&mut self) {
496        if let Some(anchor) = self.selection_anchor.take() {
497            self.cursor = self.cursor.max(anchor);
498        } else if self.cursor < self.grapheme_count() {
499            self.cursor += 1;
500        }
501    }
502
503    fn move_cursor_left_select(&mut self) {
504        self.ensure_selection_anchor();
505        if self.cursor > 0 {
506            self.cursor -= 1;
507        }
508    }
509
510    fn move_cursor_right_select(&mut self) {
511        self.ensure_selection_anchor();
512        if self.cursor < self.grapheme_count() {
513            self.cursor += 1;
514        }
515    }
516
517    fn get_grapheme_class(g: &str) -> u8 {
518        if g.chars().all(char::is_whitespace) {
519            0
520        } else if g.chars().any(char::is_alphanumeric) {
521            1
522        } else {
523            2
524        }
525    }
526
527    fn move_cursor_word_left(&mut self, select: bool) {
528        if select {
529            self.ensure_selection_anchor();
530        } else {
531            self.selection_anchor = None;
532        }
533
534        if self.cursor == 0 {
535            return;
536        }
537
538        let graphemes: Vec<&str> = self.value.graphemes(true).collect();
539        let mut pos = self.cursor;
540
541        // 1. Skip trailing whitespace
542        while pos > 0 && Self::get_grapheme_class(graphemes[pos - 1]) == 0 {
543            pos -= 1;
544        }
545
546        // 2. Skip trailing punctuation
547        while pos > 0 && Self::get_grapheme_class(graphemes[pos - 1]) == 2 {
548            pos -= 1;
549        }
550
551        // 3. Skip the previous word
552        while pos > 0 && Self::get_grapheme_class(graphemes[pos - 1]) == 1 {
553            pos -= 1;
554        }
555
556        self.cursor = pos;
557    }
558
559    fn move_cursor_word_right(&mut self, select: bool) {
560        if select {
561            self.ensure_selection_anchor();
562        } else {
563            self.selection_anchor = None;
564        }
565
566        let graphemes: Vec<&str> = self.value.graphemes(true).collect();
567        let max = graphemes.len();
568
569        if self.cursor >= max {
570            return;
571        }
572
573        let mut pos = self.cursor;
574
575        // 1. Skip current word
576        while pos < max && Self::get_grapheme_class(graphemes[pos]) == 1 {
577            pos += 1;
578        }
579
580        // 2. Skip whitespace and punctuation
581        while pos < max && Self::get_grapheme_class(graphemes[pos]) != 1 {
582            pos += 1;
583        }
584
585        self.cursor = pos;
586    }
587
588    // --- Internal helpers ---
589
590    fn grapheme_count(&self) -> usize {
591        self.value.graphemes(true).count()
592    }
593
594    fn grapheme_byte_offset(&self, grapheme_idx: usize) -> usize {
595        self.value
596            .grapheme_indices(true)
597            .nth(grapheme_idx)
598            .map(|(i, _)| i)
599            .unwrap_or(self.value.len())
600    }
601
602    fn grapheme_width(&self, g: &str) -> usize {
603        if let Some(mask) = self.mask_char {
604            let mut buf = [0u8; 4];
605            let mask_str = mask.encode_utf8(&mut buf);
606            grapheme_width(mask_str)
607        } else {
608            grapheme_width(g)
609        }
610    }
611
612    fn prev_grapheme_width(&self) -> usize {
613        if self.cursor == 0 {
614            return 0;
615        }
616        self.value
617            .graphemes(true)
618            .nth(self.cursor - 1)
619            .map(|g| self.grapheme_width(g))
620            .unwrap_or(0)
621    }
622
623    fn cursor_visual_pos(&self) -> usize {
624        if self.value.is_empty() {
625            return 0;
626        }
627        self.value
628            .graphemes(true)
629            .take(self.cursor)
630            .map(|g| self.grapheme_width(g))
631            .sum()
632    }
633
634    fn effective_scroll(&self, viewport_width: usize) -> usize {
635        let cursor_visual = self.cursor_visual_pos();
636        let mut scroll = self.scroll_cells.get();
637        if cursor_visual < scroll {
638            scroll = cursor_visual;
639        }
640        if cursor_visual >= scroll + viewport_width {
641            let candidate_scroll = cursor_visual - viewport_width + 1;
642            // Ensure the character BEFORE the cursor is also fully visible
643            // (prevent "hole" artifact for wide characters where start < scroll)
644            let prev_width = self.prev_grapheme_width();
645            let max_scroll_for_prev = cursor_visual.saturating_sub(prev_width);
646
647            scroll = candidate_scroll.min(max_scroll_for_prev);
648        }
649        self.scroll_cells.set(scroll);
650        scroll
651    }
652}
653
654impl Widget for TextInput {
655    fn render(&self, area: Rect, frame: &mut Frame) {
656        #[cfg(feature = "tracing")]
657        let _span = tracing::debug_span!(
658            "widget_render",
659            widget = "TextInput",
660            x = area.x,
661            y = area.y,
662            w = area.width,
663            h = area.height
664        )
665        .entered();
666
667        if area.width < 1 || area.height < 1 {
668            return;
669        }
670
671        let deg = frame.buffer.degradation;
672
673        // TextInput is essential — always render content, but skip styling
674        // at NoStyling+. At Skeleton, still render the raw text value.
675        // We explicitly DO NOT check deg.render_content() here because this widget is essential.
676        if deg.apply_styling() {
677            crate::set_style_area(&mut frame.buffer, area, self.style);
678        }
679
680        let graphemes: Vec<&str> = self.value.graphemes(true).collect();
681        let show_placeholder = self.value.is_empty() && !self.placeholder.is_empty();
682
683        let viewport_width = area.width as usize;
684        let cursor_visual_pos = self.cursor_visual_pos();
685        let effective_scroll = self.effective_scroll(viewport_width);
686
687        // Render content
688        let mut visual_x: usize = 0;
689        let y = area.y;
690
691        if show_placeholder {
692            let placeholder_style = if deg.apply_styling() {
693                self.placeholder_style
694            } else {
695                Style::default()
696            };
697            for g in self.placeholder.graphemes(true) {
698                let w = self.grapheme_width(g);
699                if w == 0 {
700                    continue;
701                }
702
703                // Fully scrolled out (left)
704                if visual_x + w <= effective_scroll {
705                    visual_x += w;
706                    continue;
707                }
708
709                // Partially scrolled out (left) - skip drawing
710                if visual_x < effective_scroll {
711                    visual_x += w;
712                    continue;
713                }
714
715                let rel_x = visual_x - effective_scroll;
716
717                // Fully clipped (right)
718                if rel_x >= viewport_width {
719                    break;
720                }
721
722                // Partially clipped (right) - skip drawing
723                if rel_x + w > viewport_width {
724                    break;
725                }
726
727                let mut cell = if g.chars().count() > 1 || w > 1 {
728                    let id = frame.intern_with_width(g, w as u8);
729                    Cell::new(CellContent::from_grapheme(id))
730                } else if let Some(c) = g.chars().next() {
731                    Cell::from_char(c)
732                } else {
733                    visual_x += w;
734                    continue;
735                };
736                crate::apply_style(&mut cell, placeholder_style);
737
738                frame
739                    .buffer
740                    .set(area.x.saturating_add(rel_x as u16), y, cell);
741                visual_x += w;
742            }
743        } else {
744            for (gi, g) in graphemes.iter().enumerate() {
745                let w = self.grapheme_width(g);
746                if w == 0 {
747                    continue;
748                }
749
750                // Fully scrolled out (left)
751                if visual_x + w <= effective_scroll {
752                    visual_x += w;
753                    continue;
754                }
755
756                // Partially scrolled out (left) - skip drawing
757                if visual_x < effective_scroll {
758                    visual_x += w;
759                    continue;
760                }
761
762                let rel_x = visual_x - effective_scroll;
763
764                // Fully clipped (right)
765                if rel_x >= viewport_width {
766                    break;
767                }
768
769                // Partially clipped (right) - skip drawing
770                if rel_x + w > viewport_width {
771                    break;
772                }
773
774                let cell_style = if !deg.apply_styling() {
775                    Style::default()
776                } else if self.is_in_selection(gi) {
777                    self.selection_style
778                } else {
779                    self.style
780                };
781
782                let mut cell = if let Some(mask) = self.mask_char {
783                    Cell::from_char(mask)
784                } else if g.chars().count() > 1 || w > 1 {
785                    let id = frame.intern_with_width(g, w as u8);
786                    Cell::new(CellContent::from_grapheme(id))
787                } else {
788                    Cell::from_char(g.chars().next().unwrap_or(' '))
789                };
790                crate::apply_style(&mut cell, cell_style);
791
792                frame
793                    .buffer
794                    .set(area.x.saturating_add(rel_x as u16), y, cell);
795                visual_x += w;
796            }
797        }
798
799        if self.focused {
800            // Set cursor style at cursor position
801            let cursor_rel_x = cursor_visual_pos.saturating_sub(effective_scroll);
802            if cursor_rel_x < viewport_width {
803                let cursor_screen_x = area.x.saturating_add(cursor_rel_x as u16);
804                if let Some(cell) = frame.buffer.get_mut(cursor_screen_x, y) {
805                    if !deg.apply_styling() {
806                        // At NoStyling, just use reverse video for cursor
807                        use ftui_render::cell::StyleFlags;
808                        let current_flags = cell.attrs.flags();
809                        let new_flags = current_flags ^ StyleFlags::REVERSE;
810                        cell.attrs = cell.attrs.with_flags(new_flags);
811                    } else if self.cursor_style.is_empty() {
812                        // Default: toggle reverse video for cursor visibility
813                        use ftui_render::cell::StyleFlags;
814                        let current_flags = cell.attrs.flags();
815                        let new_flags = current_flags ^ StyleFlags::REVERSE;
816                        cell.attrs = cell.attrs.with_flags(new_flags);
817                    } else {
818                        crate::apply_style(cell, self.cursor_style);
819                    }
820                }
821            }
822
823            frame.set_cursor(Some(self.cursor_position(area)));
824            frame.set_cursor_visible(true);
825        }
826    }
827
828    fn is_essential(&self) -> bool {
829        true
830    }
831}
832
833// ============================================================================
834// Undo Support Implementation
835// ============================================================================
836
837/// Snapshot of TextInput state for undo.
838#[derive(Debug, Clone)]
839pub struct TextInputSnapshot {
840    value: String,
841    cursor: usize,
842    selection_anchor: Option<usize>,
843}
844
845impl UndoSupport for TextInput {
846    fn undo_widget_id(&self) -> UndoWidgetId {
847        self.undo_id
848    }
849
850    fn create_snapshot(&self) -> Box<dyn std::any::Any + Send> {
851        Box::new(TextInputSnapshot {
852            value: self.value.clone(),
853            cursor: self.cursor,
854            selection_anchor: self.selection_anchor,
855        })
856    }
857
858    fn restore_snapshot(&mut self, snapshot: &dyn std::any::Any) -> bool {
859        if let Some(snap) = snapshot.downcast_ref::<TextInputSnapshot>() {
860            self.value = snap.value.clone();
861            self.cursor = snap.cursor;
862            self.selection_anchor = snap.selection_anchor;
863            self.scroll_cells.set(0); // Reset scroll on restore
864            true
865        } else {
866            false
867        }
868    }
869}
870
871impl TextInputUndoExt for TextInput {
872    fn text_value(&self) -> &str {
873        &self.value
874    }
875
876    fn set_text_value(&mut self, value: &str) {
877        self.value = value.to_string();
878        let max = self.grapheme_count();
879        self.cursor = self.cursor.min(max);
880        self.selection_anchor = None;
881    }
882
883    fn cursor_position(&self) -> usize {
884        self.cursor
885    }
886
887    fn set_cursor_position(&mut self, pos: usize) {
888        let max = self.grapheme_count();
889        self.cursor = pos.min(max);
890    }
891
892    fn insert_text_at(&mut self, position: usize, text: &str) {
893        let byte_offset = self.grapheme_byte_offset(position);
894        self.value.insert_str(byte_offset, text);
895        let inserted_graphemes = text.graphemes(true).count();
896        if self.cursor >= position {
897            self.cursor += inserted_graphemes;
898        }
899    }
900
901    fn delete_text_range(&mut self, start: usize, end: usize) {
902        if start >= end {
903            return;
904        }
905        let byte_start = self.grapheme_byte_offset(start);
906        let byte_end = self.grapheme_byte_offset(end);
907        self.value.drain(byte_start..byte_end);
908        let deleted_count = end - start;
909        if self.cursor > end {
910            self.cursor -= deleted_count;
911        } else if self.cursor > start {
912            self.cursor = start;
913        }
914    }
915}
916
917impl TextInput {
918    /// Create an undo command for the given text edit operation.
919    ///
920    /// This creates a command that can be added to a [`HistoryManager`] for undo/redo support.
921    /// The command includes callbacks that will be called when the operation is undone or redone.
922    ///
923    /// # Example
924    ///
925    /// ```ignore
926    /// let mut input = TextInput::new();
927    /// let old_value = input.value().to_string();
928    ///
929    /// // Perform the edit
930    /// input.set_value("new text");
931    ///
932    /// // Create undo command
933    /// if let Some(cmd) = input.create_text_edit_command(TextEditOperation::SetValue {
934    ///     old_value,
935    ///     new_value: "new text".to_string(),
936    /// }) {
937    ///     history.push(cmd);
938    /// }
939    /// ```
940    ///
941    /// [`HistoryManager`]: ftui_runtime::undo::HistoryManager
942    #[must_use]
943    pub fn create_text_edit_command(
944        &self,
945        operation: TextEditOperation,
946    ) -> Option<crate::undo_support::WidgetTextEditCmd> {
947        Some(crate::undo_support::WidgetTextEditCmd::new(
948            self.undo_id,
949            operation,
950        ))
951    }
952
953    /// Get the undo widget ID.
954    ///
955    /// This can be used to associate undo commands with this widget instance.
956    #[must_use]
957    pub fn undo_id(&self) -> UndoWidgetId {
958        self.undo_id
959    }
960}
961
962#[cfg(test)]
963mod tests {
964    use super::*;
965
966    #[allow(dead_code)]
967    fn cell_at(frame: &Frame, x: u16, y: u16) -> Cell {
968        frame
969            .buffer
970            .get(x, y)
971            .copied()
972            .unwrap_or_else(|| panic!("test cell should exist at ({x},{y})"))
973    }
974
975    #[allow(dead_code)]
976    #[test]
977    fn test_empty_input() {
978        let input = TextInput::new();
979        assert!(input.value().is_empty());
980        assert_eq!(input.cursor(), 0);
981        assert!(input.selected_text().is_none());
982    }
983
984    #[test]
985    fn test_with_value() {
986        let mut input = TextInput::new().with_value("hello");
987        input.set_focused(true);
988        assert_eq!(input.value(), "hello");
989        assert_eq!(input.cursor(), 5);
990    }
991
992    #[test]
993    fn test_set_value() {
994        let mut input = TextInput::new().with_value("hello world");
995        input.cursor = 11;
996        input.set_value("hi");
997        assert_eq!(input.value(), "hi");
998        assert_eq!(input.cursor(), 2);
999    }
1000
1001    #[test]
1002    fn test_clear() {
1003        let mut input = TextInput::new().with_value("hello");
1004        input.set_focused(true);
1005        input.clear();
1006        assert!(input.value().is_empty());
1007        assert_eq!(input.cursor(), 0);
1008    }
1009
1010    #[test]
1011    fn test_insert_char() {
1012        let mut input = TextInput::new();
1013        input.insert_char('a');
1014        input.insert_char('b');
1015        input.insert_char('c');
1016        assert_eq!(input.value(), "abc");
1017        assert_eq!(input.cursor(), 3);
1018    }
1019
1020    #[test]
1021    fn test_insert_char_mid() {
1022        let mut input = TextInput::new().with_value("ac");
1023        input.cursor = 1;
1024        input.insert_char('b');
1025        assert_eq!(input.value(), "abc");
1026        assert_eq!(input.cursor(), 2);
1027    }
1028
1029    #[test]
1030    fn test_max_length() {
1031        let mut input = TextInput::new().with_max_length(3);
1032        for c in "abcdef".chars() {
1033            input.insert_char(c);
1034        }
1035        assert_eq!(input.value(), "abc");
1036        assert_eq!(input.cursor(), 3);
1037    }
1038
1039    #[test]
1040    fn test_delete_char_back() {
1041        let mut input = TextInput::new().with_value("hello");
1042        input.delete_char_back();
1043        assert_eq!(input.value(), "hell");
1044        assert_eq!(input.cursor(), 4);
1045    }
1046
1047    #[test]
1048    fn test_delete_char_back_at_start() {
1049        let mut input = TextInput::new().with_value("hello");
1050        input.cursor = 0;
1051        input.delete_char_back();
1052        assert_eq!(input.value(), "hello");
1053    }
1054
1055    #[test]
1056    fn test_delete_char_forward() {
1057        let mut input = TextInput::new().with_value("hello");
1058        input.cursor = 0;
1059        input.delete_char_forward();
1060        assert_eq!(input.value(), "ello");
1061        assert_eq!(input.cursor(), 0);
1062    }
1063
1064    #[test]
1065    fn test_delete_char_forward_at_end() {
1066        let mut input = TextInput::new().with_value("hello");
1067        input.delete_char_forward();
1068        assert_eq!(input.value(), "hello");
1069    }
1070
1071    #[test]
1072    fn test_cursor_left_right() {
1073        let mut input = TextInput::new().with_value("hello");
1074        assert_eq!(input.cursor(), 5);
1075        input.move_cursor_left();
1076        assert_eq!(input.cursor(), 4);
1077        input.move_cursor_left();
1078        assert_eq!(input.cursor(), 3);
1079        input.move_cursor_right();
1080        assert_eq!(input.cursor(), 4);
1081    }
1082
1083    #[test]
1084    fn test_cursor_bounds() {
1085        let mut input = TextInput::new().with_value("hi");
1086        input.cursor = 0;
1087        input.move_cursor_left();
1088        assert_eq!(input.cursor(), 0);
1089        input.cursor = 2;
1090        input.move_cursor_right();
1091        assert_eq!(input.cursor(), 2);
1092    }
1093
1094    #[test]
1095    fn test_word_movement_left() {
1096        let mut input = TextInput::new().with_value("hello world test");
1097        // "hello world test"
1098        //                 ^ (16)
1099        input.move_cursor_word_left(false);
1100        assert_eq!(input.cursor(), 12); // "hello world |test"
1101
1102        input.move_cursor_word_left(false);
1103        assert_eq!(input.cursor(), 6); // "hello |world test"
1104
1105        input.move_cursor_word_left(false);
1106        assert_eq!(input.cursor(), 0); // "|hello world test"
1107    }
1108
1109    #[test]
1110    fn test_word_movement_right() {
1111        let mut input = TextInput::new().with_value("hello world test");
1112        input.cursor = 0;
1113        // "|hello world test"
1114
1115        input.move_cursor_word_right(false);
1116        assert_eq!(input.cursor(), 6); // "hello |world test"
1117
1118        input.move_cursor_word_right(false);
1119        assert_eq!(input.cursor(), 12); // "hello world |test"
1120
1121        input.move_cursor_word_right(false);
1122        assert_eq!(input.cursor(), 16); // "hello world test|"
1123    }
1124
1125    #[test]
1126    fn test_word_movement_skips_punctuation() {
1127        let mut input = TextInput::new().with_value("hello, world");
1128        input.cursor = 0;
1129        // "|hello, world"
1130
1131        input.move_cursor_word_right(false);
1132        assert_eq!(input.cursor(), 7); // "hello, |world"
1133
1134        input.move_cursor_word_left(false);
1135        assert_eq!(input.cursor(), 0); // "|hello, world"
1136    }
1137
1138    #[test]
1139    fn test_delete_word_back() {
1140        let mut input = TextInput::new().with_value("hello world");
1141        // "hello world|"
1142        input.delete_word_back();
1143        assert_eq!(input.value(), "hello "); // Deleted "world"
1144
1145        // "hello |" — word-left skips space then "hello"
1146        input.delete_word_back();
1147        assert_eq!(input.value(), ""); // Deleted "hello "
1148    }
1149
1150    #[test]
1151    fn test_delete_word_forward() {
1152        let mut input = TextInput::new().with_value("hello world");
1153        input.cursor = 0;
1154        // "|hello world" — word-right skips "hello" then space
1155        input.delete_word_forward();
1156        assert_eq!(input.value(), "world"); // Deleted "hello "
1157
1158        input.delete_word_forward();
1159        assert_eq!(input.value(), ""); // Deleted "world"
1160    }
1161
1162    #[test]
1163    fn test_select_all() {
1164        let mut input = TextInput::new().with_value("hello");
1165        input.select_all();
1166        assert_eq!(input.selected_text(), Some("hello"));
1167    }
1168
1169    #[test]
1170    fn test_delete_selection() {
1171        let mut input = TextInput::new().with_value("hello world");
1172        input.selection_anchor = Some(0);
1173        input.cursor = 5;
1174        input.delete_selection();
1175        assert_eq!(input.value(), " world");
1176        assert_eq!(input.cursor(), 0);
1177    }
1178
1179    #[test]
1180    fn test_insert_replaces_selection() {
1181        let mut input = TextInput::new().with_value("hello");
1182        input.select_all();
1183        input.delete_selection();
1184        input.insert_char('x');
1185        assert_eq!(input.value(), "x");
1186    }
1187
1188    #[test]
1189    fn test_unicode_grapheme_handling() {
1190        let mut input = TextInput::new();
1191        input.set_value("café");
1192        assert_eq!(input.grapheme_count(), 4);
1193        input.cursor = 4;
1194        input.delete_char_back();
1195        assert_eq!(input.value(), "caf");
1196    }
1197
1198    #[test]
1199    fn test_multi_codepoint_grapheme_cursor_movement() {
1200        let mut input = TextInput::new().with_value("a👩‍💻b");
1201        assert_eq!(input.grapheme_count(), 3);
1202        assert_eq!(input.cursor(), 3);
1203
1204        input.move_cursor_left();
1205        assert_eq!(input.cursor(), 2);
1206        input.move_cursor_left();
1207        assert_eq!(input.cursor(), 1);
1208        input.move_cursor_left();
1209        assert_eq!(input.cursor(), 0);
1210
1211        input.move_cursor_right();
1212        assert_eq!(input.cursor(), 1);
1213        input.move_cursor_right();
1214        assert_eq!(input.cursor(), 2);
1215        input.move_cursor_right();
1216        assert_eq!(input.cursor(), 3);
1217    }
1218
1219    #[test]
1220    fn test_delete_back_multi_codepoint_grapheme() {
1221        let mut input = TextInput::new().with_value("a👩‍💻b");
1222        input.cursor = 2; // after the emoji grapheme
1223        input.delete_char_back();
1224        assert_eq!(input.value(), "ab");
1225        assert_eq!(input.cursor(), 1);
1226        assert_eq!(input.grapheme_count(), 2);
1227    }
1228
1229    #[test]
1230    fn test_handle_event_char() {
1231        let mut input = TextInput::new();
1232        let event = Event::Key(KeyEvent::new(KeyCode::Char('a')));
1233        assert!(input.handle_event(&event));
1234        assert_eq!(input.value(), "a");
1235    }
1236
1237    #[test]
1238    fn test_handle_event_backspace() {
1239        let mut input = TextInput::new().with_value("ab");
1240        let event = Event::Key(KeyEvent::new(KeyCode::Backspace));
1241        assert!(input.handle_event(&event));
1242        assert_eq!(input.value(), "a");
1243    }
1244
1245    #[test]
1246    fn test_handle_event_ctrl_a() {
1247        let mut input = TextInput::new().with_value("hello");
1248        let event = Event::Key(KeyEvent::new(KeyCode::Char('a')).with_modifiers(Modifiers::CTRL));
1249        assert!(input.handle_event(&event));
1250        assert_eq!(input.selected_text(), Some("hello"));
1251    }
1252
1253    #[test]
1254    fn test_handle_event_ctrl_backspace() {
1255        let mut input = TextInput::new().with_value("hello world");
1256        let event = Event::Key(KeyEvent::new(KeyCode::Backspace).with_modifiers(Modifiers::CTRL));
1257        assert!(input.handle_event(&event));
1258        assert_eq!(input.value(), "hello ");
1259    }
1260
1261    #[test]
1262    fn test_handle_event_home_end() {
1263        let mut input = TextInput::new().with_value("hello");
1264        input.cursor = 3;
1265        let home = Event::Key(KeyEvent::new(KeyCode::Home));
1266        assert!(input.handle_event(&home));
1267        assert_eq!(input.cursor(), 0);
1268        let end = Event::Key(KeyEvent::new(KeyCode::End));
1269        assert!(input.handle_event(&end));
1270        assert_eq!(input.cursor(), 5);
1271    }
1272
1273    #[test]
1274    fn test_shift_left_creates_selection() {
1275        let mut input = TextInput::new().with_value("hello");
1276        let event = Event::Key(KeyEvent::new(KeyCode::Left).with_modifiers(Modifiers::SHIFT));
1277        assert!(input.handle_event(&event));
1278        assert_eq!(input.cursor(), 4);
1279        assert_eq!(input.selection_anchor, Some(5));
1280        assert_eq!(input.selected_text(), Some("o"));
1281    }
1282
1283    #[test]
1284    fn test_cursor_position() {
1285        let input = TextInput::new().with_value("hello");
1286        let area = Rect::new(10, 5, 20, 1);
1287        let (x, y) = input.cursor_position(area);
1288        assert_eq!(x, 15);
1289        assert_eq!(y, 5);
1290    }
1291
1292    #[test]
1293    fn test_cursor_position_empty() {
1294        let input = TextInput::new();
1295        let area = Rect::new(0, 0, 80, 1);
1296        let (x, y) = input.cursor_position(area);
1297        assert_eq!(x, 0);
1298        assert_eq!(y, 0);
1299    }
1300
1301    #[test]
1302    fn test_password_mask() {
1303        let input = TextInput::new().with_mask('*').with_value("secret");
1304        assert_eq!(input.value(), "secret");
1305        assert_eq!(input.cursor_visual_pos(), 6);
1306    }
1307
1308    #[test]
1309    fn test_render_basic() {
1310        use ftui_render::frame::Frame;
1311        use ftui_render::grapheme_pool::GraphemePool;
1312
1313        let input = TextInput::new().with_value("hi");
1314        let area = Rect::new(0, 0, 10, 1);
1315        let mut pool = GraphemePool::new();
1316        let mut frame = Frame::new(10, 1, &mut pool);
1317        input.render(area, &mut frame);
1318        let cell_h = cell_at(&frame, 0, 0);
1319        assert_eq!(cell_h.content.as_char(), Some('h'));
1320        let cell_i = cell_at(&frame, 1, 0);
1321        assert_eq!(cell_i.content.as_char(), Some('i'));
1322    }
1323
1324    #[test]
1325    fn test_render_sets_cursor_when_focused() {
1326        use ftui_render::frame::Frame;
1327        use ftui_render::grapheme_pool::GraphemePool;
1328
1329        let input = TextInput::new().with_value("hi").with_focused(true);
1330        let area = Rect::new(0, 0, 10, 1);
1331        let mut pool = GraphemePool::new();
1332        let mut frame = Frame::new(10, 1, &mut pool);
1333        input.render(area, &mut frame);
1334
1335        assert_eq!(frame.cursor_position, Some((2, 0)));
1336        assert!(frame.cursor_visible);
1337    }
1338
1339    #[test]
1340    fn test_render_does_not_set_cursor_when_unfocused() {
1341        use ftui_render::frame::Frame;
1342        use ftui_render::grapheme_pool::GraphemePool;
1343
1344        let input = TextInput::new().with_value("hi");
1345        let area = Rect::new(0, 0, 10, 1);
1346        let mut pool = GraphemePool::new();
1347        let mut frame = Frame::new(10, 1, &mut pool);
1348        input.render(area, &mut frame);
1349
1350        assert!(frame.cursor_position.is_none());
1351    }
1352
1353    #[test]
1354    fn test_render_grapheme_uses_pool() {
1355        use ftui_render::frame::Frame;
1356        use ftui_render::grapheme_pool::GraphemePool;
1357
1358        let grapheme = "👩‍💻";
1359        let input = TextInput::new().with_value(grapheme);
1360        let area = Rect::new(0, 0, 6, 1);
1361        let mut pool = GraphemePool::new();
1362        let mut frame = Frame::new(6, 1, &mut pool);
1363        input.render(area, &mut frame);
1364
1365        let cell = cell_at(&frame, 0, 0);
1366        assert!(cell.content.is_grapheme());
1367        let width = grapheme_width(grapheme);
1368        if width > 1 {
1369            assert!(cell_at(&frame, 1, 0).is_continuation());
1370        }
1371    }
1372
1373    #[test]
1374    fn test_left_collapses_selection() {
1375        let mut input = TextInput::new().with_value("hello");
1376        input.selection_anchor = Some(1);
1377        input.cursor = 4;
1378        input.move_cursor_left();
1379        assert_eq!(input.cursor(), 1);
1380        assert!(input.selection_anchor.is_none());
1381    }
1382
1383    #[test]
1384    fn test_right_collapses_selection() {
1385        let mut input = TextInput::new().with_value("hello");
1386        input.selection_anchor = Some(1);
1387        input.cursor = 4;
1388        input.move_cursor_right();
1389        assert_eq!(input.cursor(), 4);
1390        assert!(input.selection_anchor.is_none());
1391    }
1392
1393    #[test]
1394    fn test_render_sets_frame_cursor() {
1395        use ftui_render::frame::Frame;
1396        use ftui_render::grapheme_pool::GraphemePool;
1397
1398        let input = TextInput::new().with_value("hello").with_focused(true);
1399        let area = Rect::new(5, 3, 20, 1);
1400        let mut pool = GraphemePool::new();
1401        let mut frame = Frame::new(30, 10, &mut pool);
1402        input.render(area, &mut frame);
1403
1404        // Cursor should be positioned at the end of "hello" (5 chars)
1405        // area.x = 5, cursor_visual_pos = 5, effective_scroll = 0
1406        // So cursor_screen_x = 5 + 5 = 10
1407        assert_eq!(frame.cursor_position, Some((10, 3)));
1408    }
1409
1410    #[test]
1411    fn test_render_cursor_mid_text() {
1412        use ftui_render::frame::Frame;
1413        use ftui_render::grapheme_pool::GraphemePool;
1414
1415        let mut input = TextInput::new().with_value("hello").with_focused(true);
1416        input.cursor = 2; // After "he"
1417        let area = Rect::new(0, 0, 20, 1);
1418        let mut pool = GraphemePool::new();
1419        let mut frame = Frame::new(20, 1, &mut pool);
1420        input.render(area, &mut frame);
1421
1422        // Cursor after "he" = visual position 2
1423        assert_eq!(frame.cursor_position, Some((2, 0)));
1424    }
1425
1426    // ========================================================================
1427    // Undo Support Tests
1428    // ========================================================================
1429
1430    #[test]
1431    fn test_undo_widget_id_is_stable() {
1432        let input = TextInput::new();
1433        let id1 = input.undo_id();
1434        let id2 = input.undo_id();
1435        assert_eq!(id1, id2);
1436    }
1437
1438    #[test]
1439    fn test_undo_widget_id_unique_per_instance() {
1440        let input1 = TextInput::new();
1441        let input2 = TextInput::new();
1442        assert_ne!(input1.undo_id(), input2.undo_id());
1443    }
1444
1445    #[test]
1446    fn test_snapshot_and_restore() {
1447        let mut input = TextInput::new().with_value("hello");
1448        input.cursor = 3;
1449        input.selection_anchor = Some(1);
1450
1451        let snapshot = input.create_snapshot();
1452
1453        // Modify the input
1454        input.set_value("world");
1455        input.cursor = 5;
1456        input.selection_anchor = None;
1457
1458        assert_eq!(input.value(), "world");
1459        assert_eq!(input.cursor(), 5);
1460
1461        // Restore from snapshot
1462        assert!(input.restore_snapshot(snapshot.as_ref()));
1463        assert_eq!(input.value(), "hello");
1464        assert_eq!(input.cursor(), 3);
1465        assert_eq!(input.selection_anchor, Some(1));
1466    }
1467
1468    #[test]
1469    fn test_text_input_undo_ext_insert() {
1470        let mut input = TextInput::new().with_value("hello");
1471        input.cursor = 2;
1472
1473        input.insert_text_at(2, " world");
1474        // "hello" with " world" inserted at position 2 = "he" + " world" + "llo"
1475        assert_eq!(input.value(), "he worldllo");
1476        assert_eq!(input.cursor(), 8); // cursor moved by inserted text length (6 graphemes)
1477    }
1478
1479    #[test]
1480    fn test_text_input_undo_ext_delete() {
1481        let mut input = TextInput::new().with_value("hello world");
1482        input.cursor = 8;
1483
1484        input.delete_text_range(5, 11); // Delete " world"
1485        assert_eq!(input.value(), "hello");
1486        assert_eq!(input.cursor(), 5); // cursor clamped to end of remaining text
1487    }
1488
1489    #[test]
1490    fn test_create_text_edit_command() {
1491        let input = TextInput::new().with_value("hello");
1492        let cmd = input.create_text_edit_command(TextEditOperation::Insert {
1493            position: 0,
1494            text: "hi".to_string(),
1495        });
1496        assert!(cmd.is_some());
1497        let cmd = cmd.expect("test command should exist");
1498        assert_eq!(cmd.widget_id(), input.undo_id());
1499        assert_eq!(cmd.description(), "Insert text");
1500    }
1501
1502    #[test]
1503    fn test_paste_bulk_insert() {
1504        let mut input = TextInput::new().with_value("hello");
1505        input.cursor = 5;
1506        let event = Event::Paste(ftui_core::event::PasteEvent::bracketed(" world"));
1507        assert!(input.handle_event(&event));
1508        assert_eq!(input.value(), "hello world");
1509        assert_eq!(input.cursor(), 11);
1510    }
1511
1512    #[test]
1513    fn test_paste_multi_grapheme_sequence() {
1514        let mut input = TextInput::new().with_value("hi");
1515        input.cursor = 2;
1516        let event = Event::Paste(ftui_core::event::PasteEvent::new("👩‍💻🔥", false));
1517        assert!(input.handle_event(&event));
1518        assert_eq!(input.value(), "hi👩‍💻🔥");
1519        assert_eq!(input.cursor(), 4);
1520    }
1521
1522    #[test]
1523    fn test_paste_max_length() {
1524        let mut input = TextInput::new().with_value("abc").with_max_length(5);
1525        input.cursor = 3;
1526        // Paste "def" (3 chars). Should be truncated to "de" (2 chars) to fit max 5.
1527        let event = Event::Paste(ftui_core::event::PasteEvent::bracketed("def"));
1528        assert!(input.handle_event(&event));
1529        assert_eq!(input.value(), "abcde");
1530        assert_eq!(input.cursor(), 5);
1531    }
1532
1533    #[test]
1534    fn test_paste_combining_merge() {
1535        let mut input = TextInput::new().with_value("e");
1536        input.cursor = 1;
1537        // Paste combining acute accent (U+0301). Should merge with 'e' -> 'é'.
1538        // Grapheme count stays 1. Cursor stays 1 (after the merged grapheme).
1539        let event = Event::Paste(ftui_core::event::PasteEvent::bracketed("\u{0301}"));
1540        assert!(input.handle_event(&event));
1541        assert_eq!(input.value(), "e\u{0301}");
1542        assert_eq!(input.grapheme_count(), 1);
1543        assert_eq!(input.cursor(), 1);
1544    }
1545
1546    #[test]
1547    fn test_paste_combining_merge_mid_string() {
1548        let mut input = TextInput::new().with_value("ab");
1549        input.cursor = 1; // between a and b
1550        let event = Event::Paste(ftui_core::event::PasteEvent::bracketed("\u{0301}"));
1551        assert!(input.handle_event(&event));
1552        assert_eq!(input.value(), "a\u{0301}b");
1553        assert_eq!(input.grapheme_count(), 2);
1554        assert_eq!(input.cursor(), 1);
1555    }
1556
1557    #[test]
1558    fn test_wide_char_scroll_visibility() {
1559        use ftui_render::frame::Frame;
1560        use ftui_render::grapheme_pool::GraphemePool;
1561
1562        let wide_char = "\u{3000}"; // Ideographic space, Width 2
1563        let mut input = TextInput::new().with_value(wide_char).with_focused(true);
1564        input.cursor = 1; // After the char
1565
1566        // Viewport width 2.
1567        // cursor_visual_pos = 2.
1568        // effective_scroll: 2 >= 0 + 2 -> scroll = 1.
1569        // Render: char at 0..2. 0 < 1 -> Skipped!
1570        // Expectation: We should see it.
1571        let area = Rect::new(0, 0, 2, 1);
1572        let mut pool = GraphemePool::new();
1573        let mut frame = Frame::new(2, 1, &mut pool);
1574        input.render(area, &mut frame);
1575
1576        let cell = cell_at(&frame, 0, 0);
1577        // If bug exists, this assertion will fail because cell is empty/default
1578        assert!(!cell.is_empty(), "Wide char should be visible");
1579    }
1580}