Skip to main content

ftui_widgets/
textarea.rs

1//! Multi-line text editing widget.
2//!
3//! [`TextArea`] wraps [`Editor`] for text manipulation and
4//! provides Frame-based rendering with viewport scrolling and cursor display.
5//!
6//! # Example
7//! ```
8//! use ftui_widgets::textarea::{TextArea, TextAreaState};
9//!
10//! let mut ta = TextArea::new();
11//! ta.insert_text("Hello\nWorld");
12//! assert_eq!(ta.line_count(), 2);
13//! ```
14
15use ftui_core::event::{Event, KeyCode, KeyEvent, KeyEventKind, Modifiers};
16use ftui_core::geometry::Rect;
17use ftui_render::frame::Frame;
18use ftui_style::Style;
19use ftui_text::editor::{Editor, Selection};
20use ftui_text::wrap::display_width;
21use ftui_text::{CursorNavigator, CursorPosition};
22use unicode_segmentation::UnicodeSegmentation;
23
24use crate::{StatefulWidget, Widget, apply_style, draw_text_span};
25
26/// Multi-line text editor widget.
27#[derive(Debug, Clone)]
28pub struct TextArea {
29    editor: Editor,
30    /// Placeholder text shown when empty.
31    placeholder: String,
32    /// Whether the widget has input focus.
33    focused: bool,
34    /// Show line numbers in gutter.
35    show_line_numbers: bool,
36    /// Base style.
37    style: Style,
38    /// Cursor line highlight style.
39    cursor_line_style: Option<Style>,
40    /// Selection highlight style.
41    selection_style: Style,
42    /// Placeholder style.
43    placeholder_style: Style,
44    /// Line number style.
45    line_number_style: Style,
46    /// Soft-wrap long lines.
47    ///
48    /// Visual wrapping preserves whitespace and breaks on word boundaries with
49    /// a grapheme fallback for long segments.
50    soft_wrap: bool,
51    /// Maximum height in lines (0 = unlimited / fill area).
52    max_height: usize,
53    /// Viewport scroll offset (first visible line).
54    scroll_top: std::cell::Cell<usize>,
55    /// Horizontal scroll offset (visual columns).
56    scroll_left: std::cell::Cell<usize>,
57    /// Last viewport height for page movement and visibility checks.
58    #[allow(dead_code)]
59    last_viewport_height: std::cell::Cell<usize>,
60    /// Last viewport width for visibility checks.
61    last_viewport_width: std::cell::Cell<usize>,
62}
63
64impl Default for TextArea {
65    fn default() -> Self {
66        Self::new()
67    }
68}
69
70/// Render state tracked across frames.
71#[derive(Debug, Clone, Default)]
72pub struct TextAreaState {
73    /// Viewport height from last render.
74    pub last_viewport_height: u16,
75    /// Viewport width from last render.
76    pub last_viewport_width: u16,
77}
78
79#[derive(Debug, Clone)]
80struct WrappedSlice {
81    text: String,
82    start_byte: usize,
83    start_col: usize,
84    width: usize,
85}
86
87impl TextArea {
88    /// Create a new empty text area.
89    #[must_use]
90    pub fn new() -> Self {
91        Self {
92            editor: Editor::new(),
93            placeholder: String::new(),
94            focused: false,
95            show_line_numbers: false,
96            style: Style::default(),
97            cursor_line_style: None,
98            selection_style: Style::new().reverse(),
99            placeholder_style: Style::new().dim(),
100            line_number_style: Style::new().dim(),
101            soft_wrap: false,
102            max_height: 0,
103            scroll_top: std::cell::Cell::new(usize::MAX), // sentinel: will be set on first render
104            scroll_left: std::cell::Cell::new(0),
105            last_viewport_height: std::cell::Cell::new(0),
106            last_viewport_width: std::cell::Cell::new(0),
107        }
108    }
109
110    // ── Event Handling ─────────────────────────────────────────────
111
112    /// Handle a terminal event.
113    ///
114    /// Returns `true` if the state changed.
115    pub fn handle_event(&mut self, event: &Event) -> bool {
116        match event {
117            Event::Key(key)
118                if key.kind == KeyEventKind::Press || key.kind == KeyEventKind::Repeat =>
119            {
120                self.handle_key(key)
121            }
122            Event::Paste(paste) => {
123                self.insert_text(&paste.text);
124                true
125            }
126            _ => false,
127        }
128    }
129
130    fn handle_key(&mut self, key: &KeyEvent) -> bool {
131        let ctrl = key.modifiers.contains(Modifiers::CTRL);
132        let shift = key.modifiers.contains(Modifiers::SHIFT);
133        let _alt = key.modifiers.contains(Modifiers::ALT);
134
135        match key.code {
136            KeyCode::Char(c) if !ctrl => {
137                self.insert_char(c);
138                true
139            }
140            KeyCode::Enter => {
141                self.insert_newline();
142                true
143            }
144            KeyCode::Backspace => {
145                if ctrl {
146                    self.delete_word_backward();
147                } else {
148                    self.delete_backward();
149                }
150                true
151            }
152            KeyCode::Delete => {
153                self.delete_forward();
154                true
155            }
156            KeyCode::Left => {
157                if ctrl {
158                    self.move_word_left();
159                } else if shift {
160                    self.select_left();
161                } else {
162                    self.move_left();
163                }
164                true
165            }
166            KeyCode::Right => {
167                if ctrl {
168                    self.move_word_right();
169                } else if shift {
170                    self.select_right();
171                } else {
172                    self.move_right();
173                }
174                true
175            }
176            KeyCode::Up => {
177                if shift {
178                    self.select_up();
179                } else {
180                    self.move_up();
181                }
182                true
183            }
184            KeyCode::Down => {
185                if shift {
186                    self.select_down();
187                } else {
188                    self.move_down();
189                }
190                true
191            }
192            KeyCode::Home => {
193                self.move_to_line_start();
194                true
195            }
196            KeyCode::End => {
197                self.move_to_line_end();
198                true
199            }
200            KeyCode::PageUp => {
201                let page = self.last_viewport_height.get().max(1);
202                for _ in 0..page {
203                    self.editor.move_up();
204                }
205                self.ensure_cursor_visible();
206                true
207            }
208            KeyCode::PageDown => {
209                let page = self.last_viewport_height.get().max(1);
210                for _ in 0..page {
211                    self.editor.move_down();
212                }
213                self.ensure_cursor_visible();
214                true
215            }
216            KeyCode::Char('a') if ctrl => {
217                self.select_all();
218                true
219            }
220            // Ctrl+K: Delete to end of line (common emacs/shell binding)
221            KeyCode::Char('k') if ctrl => {
222                self.delete_to_end_of_line();
223                true
224            }
225            // Ctrl+Z: Undo
226            KeyCode::Char('z') if ctrl => {
227                self.undo();
228                true
229            }
230            // Ctrl+Y: Redo
231            KeyCode::Char('y') if ctrl => {
232                self.redo();
233                true
234            }
235            _ => false,
236        }
237    }
238
239    // ── Builder methods ────────────────────────────────────────────
240
241    /// Set initial text content (builder).
242    #[must_use]
243    pub fn with_text(mut self, text: &str) -> Self {
244        self.editor = Editor::with_text(text);
245        self.editor.move_to_document_start();
246        self
247    }
248
249    /// Set placeholder text (builder).
250    #[must_use]
251    pub fn with_placeholder(mut self, text: impl Into<String>) -> Self {
252        self.placeholder = text.into();
253        self
254    }
255
256    /// Set focused state (builder).
257    #[must_use]
258    pub fn with_focus(mut self, focused: bool) -> Self {
259        self.focused = focused;
260        self
261    }
262
263    /// Enable line numbers (builder).
264    #[must_use]
265    pub fn with_line_numbers(mut self, show: bool) -> Self {
266        self.show_line_numbers = show;
267        self
268    }
269
270    /// Set base style (builder).
271    #[must_use]
272    pub fn with_style(mut self, style: Style) -> Self {
273        self.style = style;
274        self
275    }
276
277    /// Set cursor line highlight style (builder).
278    #[must_use]
279    pub fn with_cursor_line_style(mut self, style: Style) -> Self {
280        self.cursor_line_style = Some(style);
281        self
282    }
283
284    /// Set selection style (builder).
285    #[must_use]
286    pub fn with_selection_style(mut self, style: Style) -> Self {
287        self.selection_style = style;
288        self
289    }
290
291    /// Enable soft wrapping (builder).
292    #[must_use]
293    pub fn with_soft_wrap(mut self, wrap: bool) -> Self {
294        self.soft_wrap = wrap;
295        self
296    }
297
298    /// Set maximum height in lines (builder). 0 = fill available area.
299    #[must_use]
300    pub fn with_max_height(mut self, max: usize) -> Self {
301        self.max_height = max;
302        self
303    }
304
305    // ── State access ───────────────────────────────────────────────
306
307    /// Get the full text content.
308    #[must_use]
309    pub fn text(&self) -> String {
310        self.editor.text()
311    }
312
313    /// Set the full text content (resets cursor and undo history).
314    pub fn set_text(&mut self, text: &str) {
315        self.editor.set_text(text);
316        self.scroll_top.set(0);
317        self.scroll_left.set(0);
318    }
319
320    /// Number of lines.
321    #[must_use]
322    pub fn line_count(&self) -> usize {
323        self.editor.line_count()
324    }
325
326    /// Current cursor position.
327    #[inline]
328    #[must_use]
329    pub fn cursor(&self) -> CursorPosition {
330        self.editor.cursor()
331    }
332
333    /// Set cursor position (clamped to bounds). Clears selection.
334    pub fn set_cursor_position(&mut self, pos: CursorPosition) {
335        self.editor.set_cursor(pos);
336        self.ensure_cursor_visible();
337    }
338
339    /// Whether the textarea is empty.
340    #[inline]
341    #[must_use]
342    pub fn is_empty(&self) -> bool {
343        self.editor.is_empty()
344    }
345
346    /// Current selection, if any.
347    #[must_use = "use the returned selection (if any)"]
348    pub fn selection(&self) -> Option<Selection> {
349        self.editor.selection()
350    }
351
352    /// Get selected text.
353    #[must_use = "use the returned selected text (if any)"]
354    pub fn selected_text(&self) -> Option<String> {
355        self.editor.selected_text()
356    }
357
358    /// Whether the widget has focus.
359    #[must_use]
360    pub fn is_focused(&self) -> bool {
361        self.focused
362    }
363
364    /// Set focus state.
365    pub fn set_focused(&mut self, focused: bool) {
366        self.focused = focused;
367    }
368
369    /// Access the underlying editor.
370    #[must_use]
371    pub fn editor(&self) -> &Editor {
372        &self.editor
373    }
374
375    /// Mutable access to the underlying editor.
376    pub fn editor_mut(&mut self) -> &mut Editor {
377        &mut self.editor
378    }
379
380    // ── Editing operations (delegated to Editor) ───────────────────
381
382    /// Insert text at cursor.
383    pub fn insert_text(&mut self, text: &str) {
384        self.editor.insert_text(text);
385        self.ensure_cursor_visible();
386    }
387
388    /// Insert a single character.
389    pub fn insert_char(&mut self, ch: char) {
390        self.editor.insert_char(ch);
391        self.ensure_cursor_visible();
392    }
393
394    /// Insert a newline.
395    pub fn insert_newline(&mut self) {
396        self.editor.insert_newline();
397        self.ensure_cursor_visible();
398    }
399
400    /// Delete backward (backspace).
401    pub fn delete_backward(&mut self) {
402        self.editor.delete_backward();
403        self.ensure_cursor_visible();
404    }
405
406    /// Delete forward (delete key).
407    pub fn delete_forward(&mut self) {
408        self.editor.delete_forward();
409        self.ensure_cursor_visible();
410    }
411
412    /// Delete word backward (Ctrl+Backspace).
413    pub fn delete_word_backward(&mut self) {
414        self.editor.delete_word_backward();
415        self.ensure_cursor_visible();
416    }
417
418    /// Delete to end of line (Ctrl+K).
419    pub fn delete_to_end_of_line(&mut self) {
420        self.editor.delete_to_end_of_line();
421        self.ensure_cursor_visible();
422    }
423
424    /// Undo last edit.
425    pub fn undo(&mut self) {
426        self.editor.undo();
427        self.ensure_cursor_visible();
428    }
429
430    /// Redo last undo.
431    pub fn redo(&mut self) {
432        self.editor.redo();
433        self.ensure_cursor_visible();
434    }
435
436    // ── Navigation ─────────────────────────────────────────────────
437
438    /// Move cursor left.
439    pub fn move_left(&mut self) {
440        self.editor.move_left();
441        self.ensure_cursor_visible();
442    }
443
444    /// Move cursor right.
445    pub fn move_right(&mut self) {
446        self.editor.move_right();
447        self.ensure_cursor_visible();
448    }
449
450    /// Move cursor up.
451    pub fn move_up(&mut self) {
452        self.editor.move_up();
453        self.ensure_cursor_visible();
454    }
455
456    /// Move cursor down.
457    pub fn move_down(&mut self) {
458        self.editor.move_down();
459        self.ensure_cursor_visible();
460    }
461
462    /// Move cursor left by word.
463    pub fn move_word_left(&mut self) {
464        self.editor.move_word_left();
465        self.ensure_cursor_visible();
466    }
467
468    /// Move cursor right by word.
469    pub fn move_word_right(&mut self) {
470        self.editor.move_word_right();
471        self.ensure_cursor_visible();
472    }
473
474    /// Move to start of line.
475    pub fn move_to_line_start(&mut self) {
476        self.editor.move_to_line_start();
477        self.ensure_cursor_visible();
478    }
479
480    /// Move to end of line.
481    pub fn move_to_line_end(&mut self) {
482        self.editor.move_to_line_end();
483        self.ensure_cursor_visible();
484    }
485
486    /// Move to start of document.
487    pub fn move_to_document_start(&mut self) {
488        self.editor.move_to_document_start();
489        self.ensure_cursor_visible();
490    }
491
492    /// Move to end of document.
493    pub fn move_to_document_end(&mut self) {
494        self.editor.move_to_document_end();
495        self.ensure_cursor_visible();
496    }
497
498    // ── Selection ──────────────────────────────────────────────────
499
500    /// Extend selection left.
501    pub fn select_left(&mut self) {
502        self.editor.select_left();
503        self.ensure_cursor_visible();
504    }
505
506    /// Extend selection right.
507    pub fn select_right(&mut self) {
508        self.editor.select_right();
509        self.ensure_cursor_visible();
510    }
511
512    /// Extend selection up.
513    pub fn select_up(&mut self) {
514        self.editor.select_up();
515        self.ensure_cursor_visible();
516    }
517
518    /// Extend selection down.
519    pub fn select_down(&mut self) {
520        self.editor.select_down();
521        self.ensure_cursor_visible();
522    }
523
524    /// Select all.
525    pub fn select_all(&mut self) {
526        self.editor.select_all();
527    }
528
529    /// Clear selection.
530    pub fn clear_selection(&mut self) {
531        self.editor.clear_selection();
532    }
533
534    // ── Viewport management ────────────────────────────────────────
535
536    /// Page up (move viewport and cursor up by viewport height).
537    pub fn page_up(&mut self, state: &TextAreaState) {
538        let page = state.last_viewport_height.max(1) as usize;
539        for _ in 0..page {
540            self.editor.move_up();
541        }
542        self.ensure_cursor_visible();
543    }
544
545    /// Page down (move viewport and cursor down by viewport height).
546    pub fn page_down(&mut self, state: &TextAreaState) {
547        let page = state.last_viewport_height.max(1) as usize;
548        for _ in 0..page {
549            self.editor.move_down();
550        }
551        self.ensure_cursor_visible();
552    }
553
554    /// Width of the line number gutter.
555    fn gutter_width(&self) -> u16 {
556        if !self.show_line_numbers {
557            return 0;
558        }
559        let digits = {
560            let mut count = self.line_count().max(1);
561            let mut d: u16 = 0;
562            while count > 0 {
563                d += 1;
564                count /= 10;
565            }
566            d
567        };
568        digits + 2 // digit width + space + separator
569    }
570
571    /// Count how many wrapped lines this text will occupy.
572    ///
573    /// This is a zero-allocation version of `wrap_line_slices` for layout calculations.
574    fn measure_wrap_count(line_text: &str, max_width: usize) -> usize {
575        if line_text.is_empty() {
576            return 1;
577        }
578
579        let mut count = 0;
580        let mut current_width = 0;
581        let mut has_content = false;
582
583        Self::run_wrapping_logic(line_text, max_width, |_, width, flush| {
584            if flush {
585                count += 1;
586                current_width = 0;
587                has_content = false;
588            } else {
589                current_width = width;
590                has_content = true;
591            }
592        });
593
594        // If there's pending content or if we flushed but started a new empty line (which shouldn't happen with this logic usually,
595        // but let's be safe), count the last line.
596        // Actually run_wrapping_logic only flushes when a line is full.
597        // We need to count the current line if it has content or if it's the only line.
598        if has_content || count == 0 {
599            count += 1;
600        }
601
602        count
603    }
604
605    /// Core wrapping logic that emits events for layout or slicing.
606    ///
607    /// The callback receives `(start_index, width, flush)`.
608    /// - `flush == true`: The current line is full/done. `width` is the width of the flushed line.
609    /// - `flush == false`: Update current line width.
610    fn run_wrapping_logic<F>(line_text: &str, max_width: usize, mut callback: F)
611    where
612        F: FnMut(usize, usize, bool),
613    {
614        let mut current_width = 0;
615        let mut byte_cursor = 0;
616
617        for segment in line_text.split_word_bounds() {
618            let seg_len = segment.len();
619            let seg_width: usize = segment.graphemes(true).map(display_width).sum();
620
621            if max_width > 0 && current_width + seg_width > max_width {
622                // Flush current
623                callback(byte_cursor, current_width, true);
624                current_width = 0;
625            }
626
627            if max_width > 0 && seg_width > max_width {
628                for grapheme in segment.graphemes(true) {
629                    let g_width = display_width(grapheme);
630                    let g_len = grapheme.len();
631
632                    if max_width > 0 && current_width + g_width > max_width && current_width > 0 {
633                        callback(byte_cursor, current_width, true);
634                        current_width = 0;
635                    }
636
637                    current_width += g_width;
638                    byte_cursor += g_len;
639                    callback(byte_cursor, current_width, false);
640                }
641                continue;
642            }
643
644            current_width += seg_width;
645            byte_cursor += seg_len;
646            callback(byte_cursor, current_width, false);
647        }
648    }
649
650    fn wrap_line_slices(line_text: &str, max_width: usize) -> Vec<WrappedSlice> {
651        if line_text.is_empty() {
652            return vec![WrappedSlice {
653                text: String::new(),
654                start_byte: 0,
655                start_col: 0,
656                width: 0,
657            }];
658        }
659
660        let mut slices = Vec::new();
661        let mut current_text = String::new();
662        let mut current_width = 0;
663        let mut slice_start_byte = 0;
664        let mut slice_start_col = 0;
665        let mut byte_cursor = 0;
666        let mut col_cursor = 0;
667
668        let push_current = |slices: &mut Vec<WrappedSlice>,
669                            text: &mut String,
670                            width: &mut usize,
671                            start_byte: &mut usize,
672                            start_col: &mut usize,
673                            byte_cursor: usize,
674                            col_cursor: usize| {
675            // Push even if empty if it's forced by logic (though usually check width > 0)
676            // But we match original logic:
677            if text.is_empty() && *width == 0 {
678                return;
679            }
680            slices.push(WrappedSlice {
681                text: std::mem::take(text),
682                start_byte: *start_byte,
683                start_col: *start_col,
684                width: *width,
685            });
686            *start_byte = byte_cursor;
687            *start_col = col_cursor;
688            *width = 0;
689        };
690
691        for segment in line_text.split_word_bounds() {
692            let seg_len = segment.len();
693            let seg_width: usize = segment.graphemes(true).map(display_width).sum();
694
695            if max_width > 0 && current_width + seg_width > max_width {
696                push_current(
697                    &mut slices,
698                    &mut current_text,
699                    &mut current_width,
700                    &mut slice_start_byte,
701                    &mut slice_start_col,
702                    byte_cursor,
703                    col_cursor,
704                );
705            }
706
707            if max_width > 0 && seg_width > max_width {
708                for grapheme in segment.graphemes(true) {
709                    let g_width = display_width(grapheme);
710                    let g_len = grapheme.len();
711
712                    if max_width > 0 && current_width + g_width > max_width && current_width > 0 {
713                        push_current(
714                            &mut slices,
715                            &mut current_text,
716                            &mut current_width,
717                            &mut slice_start_byte,
718                            &mut slice_start_col,
719                            byte_cursor,
720                            col_cursor,
721                        );
722                    }
723
724                    current_text.push_str(grapheme);
725                    current_width += g_width;
726                    byte_cursor += g_len;
727                    col_cursor += g_width;
728                }
729                continue;
730            }
731
732            current_text.push_str(segment);
733            current_width += seg_width;
734            byte_cursor += seg_len;
735            col_cursor += seg_width;
736        }
737
738        if !current_text.is_empty() || current_width > 0 || slices.is_empty() {
739            slices.push(WrappedSlice {
740                text: current_text,
741                start_byte: slice_start_byte,
742                start_col: slice_start_col,
743                width: current_width,
744            });
745        }
746
747        slices
748    }
749
750    fn cursor_wrap_position(
751        line_text: &str,
752        max_width: usize,
753        cursor_col: usize,
754    ) -> (usize, usize) {
755        let slices = Self::wrap_line_slices(line_text, max_width);
756        if slices.is_empty() {
757            return (0, 0);
758        }
759
760        for (idx, slice) in slices.iter().enumerate() {
761            let end_col = slice.start_col.saturating_add(slice.width);
762            if cursor_col <= end_col || idx == slices.len().saturating_sub(1) {
763                let col_in_slice = cursor_col.saturating_sub(slice.start_col);
764                return (idx, col_in_slice.min(slice.width));
765            }
766        }
767
768        (0, 0)
769    }
770
771    /// Get the visual width of the character immediately before the cursor.
772    fn get_prev_char_width(&self) -> usize {
773        let cursor = self.editor.cursor();
774        if cursor.grapheme == 0 {
775            return 0;
776        }
777        let rope = self.editor.rope();
778        let line = rope
779            .line(cursor.line)
780            .unwrap_or(std::borrow::Cow::Borrowed(""));
781
782        line.graphemes(true)
783            .nth(cursor.grapheme - 1)
784            .map(display_width)
785            .unwrap_or(0)
786    }
787
788    /// Ensure the cursor line and column are visible in the viewport.
789    fn ensure_cursor_visible(&mut self) {
790        let cursor = self.editor.cursor();
791
792        let last_height = self.last_viewport_height.get();
793
794        // Use a default viewport of 20 lines if we haven't rendered yet (height is 0)
795
796        let vp_height = if last_height == 0 { 20 } else { last_height };
797
798        let last_width = self.last_viewport_width.get();
799
800        let vp_width = if last_width == 0 { 80 } else { last_width };
801
802        if self.scroll_top.get() == usize::MAX {
803            self.scroll_top.set(0);
804        }
805
806        self.ensure_cursor_visible_internal(vp_height, vp_width, cursor);
807    }
808
809    fn ensure_cursor_visible_internal(
810        &mut self,
811
812        vp_height: usize,
813
814        vp_width: usize,
815
816        cursor: CursorPosition,
817    ) {
818        let current_top = self.scroll_top.get();
819
820        // Vertical scroll
821
822        if cursor.line < current_top {
823            self.scroll_top.set(cursor.line);
824        } else if vp_height > 0 && cursor.line >= current_top + vp_height {
825            self.scroll_top
826                .set(cursor.line.saturating_sub(vp_height - 1));
827        }
828
829        // Horizontal scroll
830
831        if !self.soft_wrap {
832            let current_left = self.scroll_left.get();
833
834            let visual_col = cursor.visual_col;
835
836            // Scroll left if cursor is before viewport
837
838            if visual_col < current_left {
839                self.scroll_left.set(visual_col);
840            }
841            // Scroll right if cursor is past viewport
842            // Need space for gutter if we had access to it, but this is a best effort
843            // approximation using the last known width.
844            // Effective text width approx vp_width - gutter (assume ~4 for gutter if unknown)
845            // But we use raw vp_width here.
846            else if vp_width > 0 && visual_col >= current_left + vp_width {
847                let candidate_scroll = visual_col.saturating_sub(vp_width - 1);
848                let prev_width = self.get_prev_char_width();
849                let max_scroll_for_prev = visual_col.saturating_sub(prev_width);
850
851                self.scroll_left
852                    .set(candidate_scroll.min(max_scroll_for_prev));
853            }
854        }
855    }
856}
857
858impl Widget for TextArea {
859    fn render(&self, area: Rect, frame: &mut Frame) {
860        if area.width < 1 || area.height < 1 {
861            return;
862        }
863
864        self.last_viewport_height.set(area.height as usize);
865
866        let deg = frame.buffer.degradation;
867        if deg.apply_styling() {
868            crate::set_style_area(&mut frame.buffer, area, self.style);
869        }
870
871        let gutter_w = self.gutter_width();
872        let text_area_x = area.x.saturating_add(gutter_w);
873        let text_area_w = area.width.saturating_sub(gutter_w) as usize;
874        let vp_height = area.height as usize;
875
876        self.last_viewport_width.set(text_area_w);
877
878        let cursor = self.editor.cursor();
879
880        // Initialize scroll_top from state, handling sentinel
881        let mut scroll_top = if self.scroll_top.get() == usize::MAX {
882            0
883        } else {
884            self.scroll_top.get()
885        };
886
887        // If NOT soft wrapping, scroll_top is a logical line index.
888        // Ensure cursor is visible by adjusting scroll_top.
889        // (If soft wrapping, this adjustment is done in virtual space inside the soft_wrap block)
890        if !self.soft_wrap && vp_height > 0 {
891            if cursor.line < scroll_top {
892                scroll_top = cursor.line;
893            } else if cursor.line >= scroll_top + vp_height {
894                scroll_top = cursor.line.saturating_sub(vp_height - 1);
895            }
896            self.scroll_top.set(scroll_top);
897        }
898
899        let mut scroll_left = self.scroll_left.get();
900        if !self.soft_wrap && text_area_w > 0 {
901            let visual_col = cursor.visual_col;
902            if visual_col < scroll_left {
903                scroll_left = visual_col;
904            } else if visual_col >= scroll_left + text_area_w {
905                let candidate_scroll = visual_col.saturating_sub(text_area_w - 1);
906                let prev_width = self.get_prev_char_width();
907                let max_scroll_for_prev = visual_col.saturating_sub(prev_width);
908
909                scroll_left = candidate_scroll.min(max_scroll_for_prev);
910            }
911        }
912        self.scroll_left.set(scroll_left);
913
914        let rope = self.editor.rope();
915        let nav = CursorNavigator::new(rope);
916
917        // Selection byte range for highlighting
918        let sel_range = self.editor.selection().and_then(|sel| {
919            if sel.is_empty() {
920                None
921            } else {
922                let (a, b) = sel.byte_range(&nav);
923                Some((a, b))
924            }
925        });
926
927        // Show placeholder if empty
928        if self.editor.is_empty() && !self.placeholder.is_empty() {
929            let style = if deg.apply_styling() {
930                self.placeholder_style
931            } else {
932                Style::default()
933            };
934            draw_text_span(
935                frame,
936                text_area_x,
937                area.y,
938                &self.placeholder,
939                style,
940                area.right(),
941            );
942            if self.focused {
943                frame.set_cursor(Some((text_area_x, area.y)));
944            }
945            return;
946        }
947
948        if self.soft_wrap {
949            self.scroll_left.set(0);
950
951            // Compute cursor virtual position (wrapped lines)
952            let mut cursor_virtual = 0;
953            for line_idx in 0..cursor.line {
954                let line_text = rope
955                    .line(line_idx)
956                    .unwrap_or(std::borrow::Cow::Borrowed(""));
957                let line_text = line_text.strip_suffix('\n').unwrap_or(&line_text);
958                cursor_virtual += Self::measure_wrap_count(line_text, text_area_w);
959            }
960
961            let cursor_line_text = rope
962                .line(cursor.line)
963                .unwrap_or(std::borrow::Cow::Borrowed(""));
964            let cursor_line_text = cursor_line_text
965                .strip_suffix('\n')
966                .unwrap_or(&cursor_line_text);
967            let (cursor_wrap_idx, cursor_col_in_wrap) =
968                Self::cursor_wrap_position(cursor_line_text, text_area_w, cursor.visual_col);
969            cursor_virtual = cursor_virtual.saturating_add(cursor_wrap_idx);
970
971            // Adjust scroll to keep cursor visible
972            let mut scroll_virtual = self.scroll_top.get();
973            if cursor_virtual < scroll_virtual {
974                scroll_virtual = cursor_virtual;
975            } else if cursor_virtual >= scroll_virtual + vp_height {
976                scroll_virtual = cursor_virtual.saturating_sub(vp_height - 1);
977            }
978            self.scroll_top.set(scroll_virtual);
979
980            // Render wrapped lines in virtual space
981            let mut virtual_index = 0usize;
982            for line_idx in 0..self.editor.line_count() {
983                if virtual_index >= scroll_virtual + vp_height {
984                    break;
985                }
986
987                let line_text = rope
988                    .line(line_idx)
989                    .unwrap_or(std::borrow::Cow::Borrowed(""));
990                let line_text = line_text.strip_suffix('\n').unwrap_or(&line_text);
991
992                // Fast path: check if this whole physical line is skipped
993                let wrap_count = Self::measure_wrap_count(line_text, text_area_w);
994                if virtual_index + wrap_count <= scroll_virtual {
995                    virtual_index += wrap_count;
996                    continue;
997                }
998
999                let line_start_byte = nav.to_byte_index(nav.from_line_grapheme(line_idx, 0));
1000                let slices = Self::wrap_line_slices(line_text, text_area_w);
1001
1002                for (slice_idx, slice) in slices.iter().enumerate() {
1003                    if virtual_index < scroll_virtual {
1004                        virtual_index += 1;
1005                        continue;
1006                    }
1007
1008                    let row = virtual_index.saturating_sub(scroll_virtual);
1009                    if row >= vp_height {
1010                        break;
1011                    }
1012
1013                    let y = area.y.saturating_add(row as u16);
1014
1015                    // Line number gutter (only for first wrapped slice)
1016                    if self.show_line_numbers && slice_idx == 0 {
1017                        let style = if deg.apply_styling() {
1018                            self.line_number_style
1019                        } else {
1020                            Style::default()
1021                        };
1022                        let num_str =
1023                            format!("{:>width$} ", line_idx + 1, width = (gutter_w - 2) as usize);
1024                        draw_text_span(frame, area.x, y, &num_str, style, text_area_x);
1025                    }
1026
1027                    // Cursor line highlight (only for the active wrapped slice)
1028                    if line_idx == cursor.line
1029                        && slice_idx == cursor_wrap_idx
1030                        && let Some(cl_style) = self.cursor_line_style
1031                        && deg.apply_styling()
1032                    {
1033                        for cx in text_area_x..area.right() {
1034                            if let Some(cell) = frame.buffer.get_mut(cx, y) {
1035                                apply_style(cell, cl_style);
1036                            }
1037                        }
1038                    }
1039
1040                    // Render graphemes inside the wrapped slice
1041                    let mut visual_x: usize = 0;
1042                    let mut grapheme_byte_offset = line_start_byte + slice.start_byte;
1043
1044                    for g in slice.text.graphemes(true) {
1045                        let g_width = display_width(g);
1046                        let g_byte_len = g.len();
1047
1048                        if visual_x >= text_area_w {
1049                            break;
1050                        }
1051
1052                        let px = text_area_x + visual_x as u16;
1053
1054                        // Determine style (selection highlight)
1055                        let mut g_style = self.style;
1056                        if let Some((sel_start, sel_end)) = sel_range
1057                            && grapheme_byte_offset >= sel_start
1058                            && grapheme_byte_offset < sel_end
1059                            && deg.apply_styling()
1060                        {
1061                            g_style = g_style.merge(&self.selection_style);
1062                        }
1063
1064                        if g_width > 0 {
1065                            draw_text_span(frame, px, y, g, g_style, area.right());
1066                        }
1067
1068                        visual_x += g_width;
1069                        grapheme_byte_offset += g_byte_len;
1070                    }
1071
1072                    virtual_index += 1;
1073                }
1074            }
1075
1076            // Set cursor position if focused
1077            if self.focused && cursor_virtual >= scroll_virtual {
1078                let row = cursor_virtual.saturating_sub(scroll_virtual);
1079                if row < vp_height {
1080                    let cursor_screen_x = text_area_x.saturating_add(cursor_col_in_wrap as u16);
1081                    let cursor_screen_y = area.y.saturating_add(row as u16);
1082                    if cursor_screen_x < area.right() && cursor_screen_y < area.bottom() {
1083                        frame.set_cursor(Some((cursor_screen_x, cursor_screen_y)));
1084                    }
1085                }
1086            }
1087
1088            return;
1089        }
1090
1091        // Render visible lines (no soft wrap)
1092        for row in 0..vp_height {
1093            let line_idx = scroll_top + row;
1094            let y = area.y.saturating_add(row as u16);
1095
1096            if line_idx >= self.editor.line_count() {
1097                break;
1098            }
1099
1100            // Line number gutter
1101            if self.show_line_numbers {
1102                let style = if deg.apply_styling() {
1103                    self.line_number_style
1104                } else {
1105                    Style::default()
1106                };
1107                let num_str = format!("{:>width$} ", line_idx + 1, width = (gutter_w - 2) as usize);
1108                draw_text_span(frame, area.x, y, &num_str, style, text_area_x);
1109            }
1110
1111            // Cursor line highlight
1112            if line_idx == cursor.line
1113                && let Some(cl_style) = self.cursor_line_style
1114                && deg.apply_styling()
1115            {
1116                for cx in text_area_x..area.right() {
1117                    if let Some(cell) = frame.buffer.get_mut(cx, y) {
1118                        apply_style(cell, cl_style);
1119                    }
1120                }
1121            }
1122
1123            // Get line text
1124            let line_text = rope
1125                .line(line_idx)
1126                .unwrap_or(std::borrow::Cow::Borrowed(""));
1127            let line_text = line_text.strip_suffix('\n').unwrap_or(&line_text);
1128
1129            // Calculate line byte offset for selection mapping
1130            let line_start_byte = nav.to_byte_index(nav.from_line_grapheme(line_idx, 0));
1131
1132            // Render each grapheme
1133            let mut visual_x: usize = 0;
1134            let graphemes: Vec<&str> = line_text.graphemes(true).collect();
1135            let mut grapheme_byte_offset = line_start_byte;
1136
1137            for g in &graphemes {
1138                let g_width = display_width(g);
1139                let g_byte_len = g.len();
1140
1141                // Skip graphemes before horizontal scroll
1142                if visual_x + g_width <= scroll_left {
1143                    visual_x += g_width;
1144                    grapheme_byte_offset += g_byte_len;
1145                    continue;
1146                }
1147
1148                // Handle partial overlap at left edge
1149                if visual_x < scroll_left {
1150                    visual_x += g_width;
1151                    grapheme_byte_offset += g_byte_len;
1152                    continue;
1153                }
1154
1155                // Stop if past viewport
1156                let screen_x = visual_x.saturating_sub(scroll_left);
1157                if screen_x >= text_area_w {
1158                    break;
1159                }
1160
1161                let px = text_area_x + screen_x as u16;
1162
1163                // Determine style (selection highlight)
1164                let mut g_style = self.style;
1165                if let Some((sel_start, sel_end)) = sel_range
1166                    && grapheme_byte_offset >= sel_start
1167                    && grapheme_byte_offset < sel_end
1168                    && deg.apply_styling()
1169                {
1170                    g_style = g_style.merge(&self.selection_style);
1171                }
1172
1173                // Write grapheme to buffer
1174                if g_width > 0 {
1175                    draw_text_span(frame, px, y, g, g_style, area.right());
1176                }
1177
1178                visual_x += g_width;
1179                grapheme_byte_offset += g_byte_len;
1180            }
1181        }
1182
1183        // Set cursor position if focused
1184        if self.focused {
1185            let cursor_row = cursor.line.saturating_sub(scroll_top);
1186            if cursor_row < vp_height {
1187                let cursor_screen_x = (cursor.visual_col.saturating_sub(scroll_left) as u16)
1188                    .saturating_add(text_area_x);
1189                let cursor_screen_y = area.y.saturating_add(cursor_row as u16);
1190                if cursor_screen_x < area.right() && cursor_screen_y < area.bottom() {
1191                    frame.set_cursor(Some((cursor_screen_x, cursor_screen_y)));
1192                }
1193            }
1194        }
1195    }
1196
1197    fn is_essential(&self) -> bool {
1198        true
1199    }
1200}
1201
1202impl StatefulWidget for TextArea {
1203    type State = TextAreaState;
1204
1205    fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
1206        state.last_viewport_height = area.height;
1207        state.last_viewport_width = area.width;
1208        Widget::render(self, area, frame);
1209    }
1210}
1211
1212#[cfg(test)]
1213mod tests {
1214    use super::*;
1215
1216    #[test]
1217    fn new_textarea_is_empty() {
1218        let ta = TextArea::new();
1219        assert!(ta.is_empty());
1220        assert_eq!(ta.text(), "");
1221        assert_eq!(ta.line_count(), 1); // empty rope has 1 line
1222    }
1223
1224    #[test]
1225    fn with_text_builder() {
1226        let ta = TextArea::new().with_text("hello\nworld");
1227        assert_eq!(ta.text(), "hello\nworld");
1228        assert_eq!(ta.line_count(), 2);
1229    }
1230
1231    #[test]
1232    fn insert_text_and_newline() {
1233        let mut ta = TextArea::new();
1234        ta.insert_text("hello");
1235        ta.insert_newline();
1236        ta.insert_text("world");
1237        assert_eq!(ta.text(), "hello\nworld");
1238        assert_eq!(ta.line_count(), 2);
1239    }
1240
1241    #[test]
1242    fn delete_backward_works() {
1243        let mut ta = TextArea::new().with_text("hello");
1244        ta.move_to_document_end();
1245        ta.delete_backward();
1246        assert_eq!(ta.text(), "hell");
1247    }
1248
1249    #[test]
1250    fn cursor_movement() {
1251        let mut ta = TextArea::new().with_text("abc\ndef\nghi");
1252        ta.move_to_document_start();
1253        assert_eq!(ta.cursor().line, 0);
1254        assert_eq!(ta.cursor().grapheme, 0);
1255
1256        ta.move_down();
1257        assert_eq!(ta.cursor().line, 1);
1258
1259        ta.move_to_line_end();
1260        assert_eq!(ta.cursor().grapheme, 3);
1261
1262        ta.move_to_document_end();
1263        assert_eq!(ta.cursor().line, 2);
1264    }
1265
1266    #[test]
1267    fn undo_redo() {
1268        let mut ta = TextArea::new();
1269        ta.insert_text("abc");
1270        assert_eq!(ta.text(), "abc");
1271        ta.undo();
1272        assert_eq!(ta.text(), "");
1273        ta.redo();
1274        assert_eq!(ta.text(), "abc");
1275    }
1276
1277    #[test]
1278    fn selection_and_delete() {
1279        let mut ta = TextArea::new().with_text("hello world");
1280        ta.move_to_document_start();
1281        for _ in 0..5 {
1282            ta.select_right();
1283        }
1284        assert_eq!(ta.selected_text(), Some("hello".to_string()));
1285        ta.delete_backward();
1286        assert_eq!(ta.text(), " world");
1287    }
1288
1289    #[test]
1290    fn select_all() {
1291        let mut ta = TextArea::new().with_text("abc\ndef");
1292        ta.select_all();
1293        assert_eq!(ta.selected_text(), Some("abc\ndef".to_string()));
1294    }
1295
1296    #[test]
1297    fn set_text_resets() {
1298        let mut ta = TextArea::new().with_text("old");
1299        ta.insert_text(" stuff");
1300        ta.set_text("new");
1301        assert_eq!(ta.text(), "new");
1302    }
1303
1304    #[test]
1305    fn scroll_follows_cursor() {
1306        let mut ta = TextArea::new();
1307        // Insert many lines
1308        for i in 0..50 {
1309            ta.insert_text(&format!("line {}\n", i));
1310        }
1311        // Cursor should be at the bottom, scroll_top adjusted
1312        assert!(ta.scroll_top.get() > 0);
1313        assert!(ta.cursor().line >= 49);
1314
1315        // Move to top
1316        ta.move_to_document_start();
1317        assert_eq!(ta.scroll_top.get(), 0);
1318    }
1319
1320    #[test]
1321    fn gutter_width_without_line_numbers() {
1322        let ta = TextArea::new();
1323        assert_eq!(ta.gutter_width(), 0);
1324    }
1325
1326    #[test]
1327    fn gutter_width_with_line_numbers() {
1328        let mut ta = TextArea::new().with_line_numbers(true);
1329        ta.insert_text("a\nb\nc");
1330        assert_eq!(ta.gutter_width(), 3); // 1 digit + space + separator
1331    }
1332
1333    #[test]
1334    fn gutter_width_many_lines() {
1335        let mut ta = TextArea::new().with_line_numbers(true);
1336        for i in 0..100 {
1337            ta.insert_text(&format!("line {}\n", i));
1338        }
1339        assert_eq!(ta.gutter_width(), 5); // 3 digits + space + separator
1340    }
1341
1342    #[test]
1343    fn focus_state() {
1344        let mut ta = TextArea::new();
1345        assert!(!ta.is_focused());
1346        ta.set_focused(true);
1347        assert!(ta.is_focused());
1348    }
1349
1350    #[test]
1351    fn word_movement() {
1352        let mut ta = TextArea::new().with_text("hello world foo");
1353        ta.move_to_document_start();
1354        ta.move_word_right();
1355        assert_eq!(ta.cursor().grapheme, 6);
1356        ta.move_word_left();
1357        assert_eq!(ta.cursor().grapheme, 0);
1358    }
1359
1360    #[test]
1361    fn page_up_down() {
1362        let mut ta = TextArea::new();
1363        for i in 0..50 {
1364            ta.insert_text(&format!("line {}\n", i));
1365        }
1366        ta.move_to_document_start();
1367        let state = TextAreaState {
1368            last_viewport_height: 10,
1369            last_viewport_width: 80,
1370        };
1371        ta.page_down(&state);
1372        assert!(ta.cursor().line >= 10);
1373        ta.page_up(&state);
1374        assert_eq!(ta.cursor().line, 0);
1375    }
1376
1377    #[test]
1378    fn insert_replaces_selection() {
1379        let mut ta = TextArea::new().with_text("hello world");
1380        ta.move_to_document_start();
1381        for _ in 0..5 {
1382            ta.select_right();
1383        }
1384        ta.insert_text("goodbye");
1385        assert_eq!(ta.text(), "goodbye world");
1386    }
1387
1388    #[test]
1389    fn insert_single_char() {
1390        let mut ta = TextArea::new();
1391        ta.insert_char('X');
1392        assert_eq!(ta.text(), "X");
1393        assert_eq!(ta.cursor().grapheme, 1);
1394    }
1395
1396    #[test]
1397    fn insert_multiline_text() {
1398        let mut ta = TextArea::new();
1399        ta.insert_text("line1\nline2\nline3");
1400        assert_eq!(ta.line_count(), 3);
1401        assert_eq!(ta.cursor().line, 2);
1402    }
1403
1404    #[test]
1405    fn delete_forward_works() {
1406        let mut ta = TextArea::new().with_text("hello");
1407        ta.move_to_document_start();
1408        ta.delete_forward();
1409        assert_eq!(ta.text(), "ello");
1410    }
1411
1412    #[test]
1413    fn delete_backward_at_line_start_joins_lines() {
1414        let mut ta = TextArea::new().with_text("abc\ndef");
1415        // Move to start of line 2
1416        ta.move_to_document_start();
1417        ta.move_down();
1418        ta.move_to_line_start();
1419        ta.delete_backward();
1420        assert_eq!(ta.text(), "abcdef");
1421        assert_eq!(ta.line_count(), 1);
1422    }
1423
1424    #[test]
1425    fn cursor_horizontal_movement() {
1426        let mut ta = TextArea::new().with_text("abc");
1427        ta.move_to_document_start();
1428        ta.move_right();
1429        assert_eq!(ta.cursor().grapheme, 1);
1430        ta.move_right();
1431        assert_eq!(ta.cursor().grapheme, 2);
1432        ta.move_left();
1433        assert_eq!(ta.cursor().grapheme, 1);
1434    }
1435
1436    #[test]
1437    fn cursor_vertical_maintains_column() {
1438        let mut ta = TextArea::new().with_text("abcde\nfg\nhijkl");
1439        ta.move_to_document_start();
1440        ta.move_to_line_end(); // col 5
1441        ta.move_down(); // line 1 only has 2 chars, should clamp
1442        assert_eq!(ta.cursor().line, 1);
1443        ta.move_down(); // line 2 has 5 chars, should restore col
1444        assert_eq!(ta.cursor().line, 2);
1445    }
1446
1447    #[test]
1448    fn selection_shift_arrow() {
1449        let mut ta = TextArea::new().with_text("abcdef");
1450        ta.move_to_document_start();
1451        ta.select_right();
1452        ta.select_right();
1453        ta.select_right();
1454        assert_eq!(ta.selected_text(), Some("abc".to_string()));
1455    }
1456
1457    #[test]
1458    fn selection_extends_up_down() {
1459        let mut ta = TextArea::new().with_text("line1\nline2\nline3");
1460        ta.move_to_document_start();
1461        ta.select_down();
1462        let sel = ta.selected_text().unwrap();
1463        assert!(sel.contains('\n'));
1464    }
1465
1466    #[test]
1467    fn undo_chain() {
1468        let mut ta = TextArea::new();
1469        ta.insert_text("a");
1470        ta.insert_text("b");
1471        ta.insert_text("c");
1472        assert_eq!(ta.text(), "abc");
1473        ta.undo();
1474        ta.undo();
1475        ta.undo();
1476        assert_eq!(ta.text(), "");
1477    }
1478
1479    #[test]
1480    fn redo_discarded_on_new_edit() {
1481        let mut ta = TextArea::new();
1482        ta.insert_text("abc");
1483        ta.undo();
1484        ta.insert_text("xyz");
1485        ta.redo(); // should be no-op
1486        assert_eq!(ta.text(), "xyz");
1487    }
1488
1489    #[test]
1490    fn clear_selection() {
1491        let mut ta = TextArea::new().with_text("hello");
1492        ta.select_all();
1493        assert!(ta.selection().is_some());
1494        ta.clear_selection();
1495        assert!(ta.selection().is_none());
1496    }
1497
1498    #[test]
1499    fn delete_word_backward() {
1500        let mut ta = TextArea::new().with_text("hello world");
1501        ta.move_to_document_end();
1502        ta.delete_word_backward();
1503        assert_eq!(ta.text(), "hello ");
1504    }
1505
1506    #[test]
1507    fn delete_to_end_of_line() {
1508        let mut ta = TextArea::new().with_text("hello world");
1509        ta.move_to_document_start();
1510        ta.move_right(); // after 'h'
1511        ta.delete_to_end_of_line();
1512        assert_eq!(ta.text(), "h");
1513    }
1514
1515    #[test]
1516    fn placeholder_builder() {
1517        let ta = TextArea::new().with_placeholder("Enter text...");
1518        assert!(ta.is_empty());
1519        assert_eq!(ta.placeholder, "Enter text...");
1520    }
1521
1522    #[test]
1523    fn soft_wrap_builder() {
1524        let ta = TextArea::new().with_soft_wrap(true);
1525        assert!(ta.soft_wrap);
1526    }
1527
1528    #[test]
1529    fn soft_wrap_renders_wrapped_lines() {
1530        use crate::Widget;
1531        use ftui_render::grapheme_pool::GraphemePool;
1532
1533        let ta = TextArea::new().with_soft_wrap(true).with_text("abcdef");
1534        let area = Rect::new(0, 0, 3, 2);
1535        let mut pool = GraphemePool::new();
1536        let mut frame = Frame::new(3, 2, &mut pool);
1537        Widget::render(&ta, area, &mut frame);
1538
1539        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('a'));
1540        assert_eq!(frame.buffer.get(2, 0).unwrap().content.as_char(), Some('c'));
1541        assert_eq!(frame.buffer.get(0, 1).unwrap().content.as_char(), Some('d'));
1542        assert_eq!(frame.buffer.get(2, 1).unwrap().content.as_char(), Some('f'));
1543    }
1544
1545    #[test]
1546    fn max_height_builder() {
1547        let ta = TextArea::new().with_max_height(10);
1548        assert_eq!(ta.max_height, 10);
1549    }
1550
1551    #[test]
1552    fn editor_access() {
1553        let mut ta = TextArea::new().with_text("test");
1554        assert_eq!(ta.editor().text(), "test");
1555        ta.editor_mut().insert_char('!');
1556        assert!(ta.text().contains('!'));
1557    }
1558
1559    #[test]
1560    fn move_to_line_start_and_end() {
1561        let mut ta = TextArea::new().with_text("hello world");
1562        ta.move_to_document_start();
1563        ta.move_to_line_end();
1564        assert_eq!(ta.cursor().grapheme, 11);
1565        ta.move_to_line_start();
1566        assert_eq!(ta.cursor().grapheme, 0);
1567    }
1568
1569    #[test]
1570    fn render_empty_with_placeholder() {
1571        use ftui_render::grapheme_pool::GraphemePool;
1572        let ta = TextArea::new()
1573            .with_placeholder("Type here")
1574            .with_focus(true);
1575        let mut pool = GraphemePool::new();
1576        let mut frame = Frame::new(20, 5, &mut pool);
1577        let area = Rect::new(0, 0, 20, 5);
1578        Widget::render(&ta, area, &mut frame);
1579        // Placeholder should be rendered
1580        let cell = frame.buffer.get(0, 0).unwrap();
1581        assert_eq!(cell.content.as_char(), Some('T'));
1582        // Cursor should be set
1583        assert!(frame.cursor_position.is_some());
1584    }
1585
1586    #[test]
1587    fn render_with_content() {
1588        use ftui_render::grapheme_pool::GraphemePool;
1589        let ta = TextArea::new().with_text("abc\ndef").with_focus(true);
1590        let mut pool = GraphemePool::new();
1591        let mut frame = Frame::new(20, 5, &mut pool);
1592        let area = Rect::new(0, 0, 20, 5);
1593        Widget::render(&ta, area, &mut frame);
1594        let cell = frame.buffer.get(0, 0).unwrap();
1595        assert_eq!(cell.content.as_char(), Some('a'));
1596    }
1597
1598    #[test]
1599    fn render_line_numbers_without_styling() {
1600        use ftui_render::budget::DegradationLevel;
1601        use ftui_render::grapheme_pool::GraphemePool;
1602
1603        let ta = TextArea::new().with_text("a\nb").with_line_numbers(true);
1604        let mut pool = GraphemePool::new();
1605        let mut frame = Frame::new(8, 2, &mut pool);
1606        frame.set_degradation(DegradationLevel::NoStyling);
1607
1608        Widget::render(&ta, Rect::new(0, 0, 8, 2), &mut frame);
1609
1610        let cell = frame.buffer.get(0, 0).unwrap();
1611        assert_eq!(cell.content.as_char(), Some('1'));
1612    }
1613
1614    #[test]
1615    fn stateful_render_updates_viewport_state() {
1616        use ftui_render::grapheme_pool::GraphemePool;
1617
1618        let ta = TextArea::new();
1619        let mut state = TextAreaState::default();
1620        let mut pool = GraphemePool::new();
1621        let mut frame = Frame::new(10, 3, &mut pool);
1622        let area = Rect::new(0, 0, 10, 3);
1623
1624        StatefulWidget::render(&ta, area, &mut frame, &mut state);
1625
1626        assert_eq!(state.last_viewport_height, 3);
1627        assert_eq!(state.last_viewport_width, 10);
1628    }
1629
1630    #[test]
1631    fn render_zero_area_no_panic() {
1632        let ta = TextArea::new().with_text("test");
1633        use ftui_render::grapheme_pool::GraphemePool;
1634        let mut pool = GraphemePool::new();
1635        let mut frame = Frame::new(10, 10, &mut pool);
1636        Widget::render(&ta, Rect::new(0, 0, 0, 0), &mut frame);
1637    }
1638
1639    #[test]
1640    fn is_essential() {
1641        let ta = TextArea::new();
1642        assert!(Widget::is_essential(&ta));
1643    }
1644
1645    #[test]
1646    fn default_impl() {
1647        let ta = TextArea::default();
1648        assert!(ta.is_empty());
1649    }
1650
1651    #[test]
1652    fn insert_newline_splits_line() {
1653        let mut ta = TextArea::new().with_text("abcdef");
1654        ta.move_to_document_start();
1655        ta.move_right();
1656        ta.move_right();
1657        ta.move_right();
1658        ta.insert_newline();
1659        assert_eq!(ta.line_count(), 2);
1660        assert_eq!(ta.cursor().line, 1);
1661    }
1662
1663    #[test]
1664    fn unicode_grapheme_cluster() {
1665        let mut ta = TextArea::new();
1666        ta.insert_text("café");
1667        // 'é' is a single grapheme even if composed
1668        assert_eq!(ta.text(), "café");
1669    }
1670
1671    mod proptests {
1672        use super::*;
1673        use proptest::prelude::*;
1674
1675        proptest! {
1676            #[test]
1677            fn insert_delete_inverse(text in "[a-zA-Z0-9 ]{1,50}") {
1678                let mut ta = TextArea::new();
1679                ta.insert_text(&text);
1680                // Delete all characters backwards
1681                for _ in 0..text.len() {
1682                    ta.delete_backward();
1683                }
1684                prop_assert!(ta.is_empty() || ta.text().is_empty());
1685            }
1686
1687            #[test]
1688            fn undo_redo_inverse(text in "[a-zA-Z0-9]{1,30}") {
1689                let mut ta = TextArea::new();
1690                ta.insert_text(&text);
1691                let after_insert = ta.text();
1692                ta.undo();
1693                ta.redo();
1694                prop_assert_eq!(ta.text(), after_insert);
1695            }
1696
1697            #[test]
1698            fn cursor_always_valid(ops in proptest::collection::vec(0u8..10, 1..20)) {
1699                let mut ta = TextArea::new().with_text("abc\ndef\nghi\njkl");
1700                for op in ops {
1701                    match op {
1702                        0 => ta.move_left(),
1703                        1 => ta.move_right(),
1704                        2 => ta.move_up(),
1705                        3 => ta.move_down(),
1706                        4 => ta.move_to_line_start(),
1707                        5 => ta.move_to_line_end(),
1708                        6 => ta.move_to_document_start(),
1709                        7 => ta.move_to_document_end(),
1710                        8 => ta.move_word_left(),
1711                        _ => ta.move_word_right(),
1712                    }
1713                    let cursor = ta.cursor();
1714                    prop_assert!(cursor.line < ta.line_count(),
1715                        "cursor line {} >= line_count {}", cursor.line, ta.line_count());
1716                }
1717            }
1718
1719            #[test]
1720            fn selection_ordered(n in 1usize..20) {
1721                let mut ta = TextArea::new().with_text("hello world foo bar");
1722                ta.move_to_document_start();
1723                for _ in 0..n {
1724                    ta.select_right();
1725                }
1726                if let Some(sel) = ta.selection() {
1727                    // When selecting right from start, anchor should be at/before head
1728                    prop_assert!(sel.anchor.line <= sel.head.line
1729                        || (sel.anchor.line == sel.head.line
1730                            && sel.anchor.grapheme <= sel.head.grapheme));
1731                }
1732            }
1733        }
1734    }
1735}