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