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