Skip to main content

ratatui_interact/components/
textarea.rs

1//! TextArea component - Multi-line text input with cursor
2//!
3//! Supports multi-line text editing with cursor movement, line numbers,
4//! scrolling, focus styling, and click-to-focus.
5//!
6//! # Example
7//!
8//! ```rust
9//! use ratatui_interact::components::{TextArea, TextAreaState, TextAreaStyle};
10//!
11//! let mut state = TextAreaState::new("Hello\nWorld");
12//!
13//! // Cursor starts at beginning
14//! assert_eq!(state.cursor_line, 0);
15//! assert_eq!(state.cursor_col, 0);
16//!
17//! // Navigate to end
18//! state.move_to_end();
19//! assert_eq!(state.cursor_line, 1);
20//! assert_eq!(state.cursor_col, 5);
21//!
22//! // Create widget (state passed to render_stateful)
23//! let textarea = TextArea::new()
24//!     .label("Editor")
25//!     .placeholder("Enter text...");
26//! ```
27
28use ratatui::{
29    Frame,
30    layout::Rect,
31    style::{Color, Style},
32    text::{Line, Span},
33    widgets::{Block, Borders, Paragraph},
34};
35
36use crate::traits::{ClickRegion, FocusId};
37
38/// Convert character index to byte index in a string.
39fn char_to_byte_index(s: &str, char_idx: usize) -> usize {
40    s.char_indices()
41        .nth(char_idx)
42        .map(|(i, _)| i)
43        .unwrap_or(s.len())
44}
45
46/// Get character at index in a string.
47fn char_at(s: &str, index: usize) -> Option<char> {
48    s.chars().nth(index)
49}
50
51/// Actions a textarea can emit.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum TextAreaAction {
54    /// Focus the textarea.
55    Focus,
56}
57
58/// Tab handling configuration.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum TabConfig {
61    /// Insert spaces (default: 4 spaces).
62    Spaces(usize),
63    /// Insert a literal tab character.
64    Literal,
65}
66
67impl Default for TabConfig {
68    fn default() -> Self {
69        TabConfig::Spaces(4)
70    }
71}
72
73/// Wrap mode for long lines.
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
75pub enum WrapMode {
76    /// No wrapping - horizontal scroll instead.
77    #[default]
78    None,
79    /// Soft wrap at word boundaries.
80    Soft,
81}
82
83/// Cursor rendering mode.
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
85pub enum CursorMode {
86    /// Render block cursor via inverted spans (no blinking).
87    #[default]
88    Block,
89    /// Return screen coordinates for `Frame::set_cursor_position()` (blinking).
90    Terminal,
91}
92
93/// Scroll tracking mode.
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
95pub enum ScrollMode {
96    /// Minimal scroll — only scroll when cursor goes out of view.
97    #[default]
98    Minimal,
99    /// Center-tracking — keep cursor near the vertical midpoint.
100    CenterTracking,
101}
102
103/// Result of rendering a textarea.
104pub struct TextAreaRender {
105    /// Click region for focus handling.
106    pub click_region: ClickRegion<TextAreaAction>,
107    /// Screen position for terminal cursor (only set when `CursorMode::Terminal` + focused).
108    pub cursor_position: Option<(u16, u16)>,
109}
110
111/// State for a multi-line text area.
112#[derive(Debug, Clone)]
113pub struct TextAreaState {
114    /// Lines of text.
115    pub lines: Vec<String>,
116    /// Current line (0-indexed).
117    pub cursor_line: usize,
118    /// Cursor column (character index within line).
119    pub cursor_col: usize,
120    /// Vertical scroll offset.
121    pub scroll_y: usize,
122    /// Horizontal scroll offset (for no-wrap mode).
123    pub scroll_x: usize,
124    /// Visible viewport height (set during render).
125    pub visible_height: usize,
126    /// Whether the textarea has focus.
127    pub focused: bool,
128    /// Whether the textarea is enabled.
129    pub enabled: bool,
130    /// Tab configuration.
131    pub tab_config: TabConfig,
132}
133
134impl Default for TextAreaState {
135    fn default() -> Self {
136        Self {
137            lines: vec![String::new()],
138            cursor_line: 0,
139            cursor_col: 0,
140            scroll_y: 0,
141            scroll_x: 0,
142            visible_height: 0,
143            focused: false,
144            enabled: true,
145            tab_config: TabConfig::default(),
146        }
147    }
148}
149
150impl TextAreaState {
151    /// Create a new textarea state with initial text.
152    ///
153    /// Cursor is positioned at the start of the text.
154    pub fn new(text: impl Into<String>) -> Self {
155        let text = text.into();
156        let lines: Vec<String> = if text.is_empty() {
157            vec![String::new()]
158        } else {
159            text.lines().map(|s| s.to_string()).collect()
160        };
161        // Ensure at least one line
162        let lines = if lines.is_empty() {
163            vec![String::new()]
164        } else {
165            lines
166        };
167
168        Self {
169            lines,
170            cursor_line: 0,
171            cursor_col: 0,
172            scroll_y: 0,
173            scroll_x: 0,
174            visible_height: 0,
175            focused: false,
176            enabled: true,
177            tab_config: TabConfig::default(),
178        }
179    }
180
181    /// Create an empty textarea state.
182    pub fn empty() -> Self {
183        Self::default()
184    }
185
186    /// Set the tab configuration.
187    pub fn with_tab_config(mut self, config: TabConfig) -> Self {
188        self.tab_config = config;
189        self
190    }
191
192    // ========================================================================
193    // Character operations
194    // ========================================================================
195
196    /// Insert a character at cursor position.
197    pub fn insert_char(&mut self, c: char) {
198        if !self.enabled {
199            return;
200        }
201        let byte_pos = char_to_byte_index(&self.lines[self.cursor_line], self.cursor_col);
202        self.lines[self.cursor_line].insert(byte_pos, c);
203        self.cursor_col += 1;
204    }
205
206    /// Insert a string at cursor position (handles multi-line input).
207    pub fn insert_str(&mut self, s: &str) {
208        if !self.enabled {
209            return;
210        }
211        for c in s.chars() {
212            if c == '\n' {
213                self.insert_newline();
214            } else if c != '\r' {
215                self.insert_char(c);
216            }
217        }
218    }
219
220    /// Insert a newline at cursor position.
221    pub fn insert_newline(&mut self) {
222        if !self.enabled {
223            return;
224        }
225
226        let byte_pos = char_to_byte_index(&self.lines[self.cursor_line], self.cursor_col);
227
228        // Split the current line
229        let rest = self.lines[self.cursor_line][byte_pos..].to_string();
230        self.lines[self.cursor_line].truncate(byte_pos);
231
232        // Insert new line after current
233        self.cursor_line += 1;
234        self.lines.insert(self.cursor_line, rest);
235        self.cursor_col = 0;
236
237        self.ensure_cursor_visible();
238    }
239
240    /// Insert a tab (spaces or literal depending on config).
241    pub fn insert_tab(&mut self) {
242        if !self.enabled {
243            return;
244        }
245        match self.tab_config {
246            TabConfig::Spaces(n) => {
247                for _ in 0..n {
248                    self.insert_char(' ');
249                }
250            }
251            TabConfig::Literal => {
252                self.insert_char('\t');
253            }
254        }
255    }
256
257    // ========================================================================
258    // Deletion operations
259    // ========================================================================
260
261    /// Delete character before cursor (backspace).
262    ///
263    /// At the start of a line, merges with previous line.
264    /// Returns `true` if any change was made.
265    pub fn delete_char_backward(&mut self) -> bool {
266        if !self.enabled {
267            return false;
268        }
269
270        if self.cursor_col > 0 {
271            // Delete character within line
272            self.cursor_col -= 1;
273            let byte_pos = char_to_byte_index(&self.lines[self.cursor_line], self.cursor_col);
274            if let Some(c) = self.lines[self.cursor_line][byte_pos..].chars().next() {
275                self.lines[self.cursor_line].replace_range(byte_pos..byte_pos + c.len_utf8(), "");
276                return true;
277            }
278        } else if self.cursor_line > 0 {
279            // Merge with previous line
280            let current_line = self.lines.remove(self.cursor_line);
281            self.cursor_line -= 1;
282            self.cursor_col = self.lines[self.cursor_line].chars().count();
283            self.lines[self.cursor_line].push_str(&current_line);
284            self.ensure_cursor_visible();
285            return true;
286        }
287        false
288    }
289
290    /// Delete character at cursor (delete key).
291    ///
292    /// At the end of a line, merges with next line.
293    /// Returns `true` if any change was made.
294    pub fn delete_char_forward(&mut self) -> bool {
295        if !self.enabled {
296            return false;
297        }
298
299        let line_len = self.lines[self.cursor_line].chars().count();
300
301        if self.cursor_col < line_len {
302            // Delete character within line
303            let byte_pos = char_to_byte_index(&self.lines[self.cursor_line], self.cursor_col);
304            if let Some(c) = self.lines[self.cursor_line][byte_pos..].chars().next() {
305                self.lines[self.cursor_line].replace_range(byte_pos..byte_pos + c.len_utf8(), "");
306                return true;
307            }
308        } else if self.cursor_line + 1 < self.lines.len() {
309            // Merge with next line
310            let next_line = self.lines.remove(self.cursor_line + 1);
311            self.lines[self.cursor_line].push_str(&next_line);
312            return true;
313        }
314        false
315    }
316
317    /// Delete word before cursor.
318    ///
319    /// Returns `true` if any characters were deleted.
320    pub fn delete_word_backward(&mut self) -> bool {
321        if !self.enabled || (self.cursor_col == 0 && self.cursor_line == 0) {
322            return false;
323        }
324
325        // If at start of line, just merge with previous line
326        if self.cursor_col == 0 {
327            return self.delete_char_backward();
328        }
329
330        let start_col = self.cursor_col;
331        let line = &self.lines[self.cursor_line];
332
333        // Skip trailing whitespace
334        while self.cursor_col > 0 {
335            if let Some(c) = char_at(line, self.cursor_col - 1) {
336                if c.is_whitespace() {
337                    self.cursor_col -= 1;
338                } else {
339                    break;
340                }
341            } else {
342                break;
343            }
344        }
345
346        // Delete word characters
347        while self.cursor_col > 0 {
348            if let Some(c) = char_at(&self.lines[self.cursor_line], self.cursor_col - 1) {
349                if !c.is_whitespace() {
350                    self.delete_char_backward();
351                } else {
352                    break;
353                }
354            } else {
355                break;
356            }
357        }
358
359        start_col != self.cursor_col
360    }
361
362    /// Delete word after cursor.
363    ///
364    /// Returns `true` if any characters were deleted.
365    pub fn delete_word_forward(&mut self) -> bool {
366        if !self.enabled {
367            return false;
368        }
369
370        let line_len = self.lines[self.cursor_line].chars().count();
371
372        // If at end of line, just merge with next line
373        if self.cursor_col >= line_len {
374            if self.cursor_line + 1 < self.lines.len() {
375                return self.delete_char_forward();
376            }
377            return false;
378        }
379
380        let start_col = self.cursor_col;
381
382        // Skip word characters forward
383        while self.cursor_col < self.lines[self.cursor_line].chars().count() {
384            if let Some(c) = char_at(&self.lines[self.cursor_line], self.cursor_col) {
385                if !c.is_whitespace() {
386                    self.delete_char_forward();
387                } else {
388                    break;
389                }
390            } else {
391                break;
392            }
393        }
394
395        // Skip whitespace forward
396        while self.cursor_col < self.lines[self.cursor_line].chars().count() {
397            if let Some(c) = char_at(&self.lines[self.cursor_line], self.cursor_col) {
398                if c.is_whitespace() {
399                    self.delete_char_forward();
400                } else {
401                    break;
402                }
403            } else {
404                break;
405            }
406        }
407
408        start_col != self.cursor_col || self.lines[self.cursor_line].chars().count() < line_len
409    }
410
411    /// Delete entire current line.
412    ///
413    /// If there's only one line, clears it instead.
414    pub fn delete_line(&mut self) {
415        if !self.enabled {
416            return;
417        }
418
419        if self.lines.len() == 1 {
420            self.lines[0].clear();
421            self.cursor_col = 0;
422        } else {
423            self.lines.remove(self.cursor_line);
424            if self.cursor_line >= self.lines.len() {
425                self.cursor_line = self.lines.len().saturating_sub(1);
426            }
427            // Adjust cursor column to fit new line
428            let new_line_len = self.lines[self.cursor_line].chars().count();
429            self.cursor_col = self.cursor_col.min(new_line_len);
430        }
431        self.ensure_cursor_visible();
432    }
433
434    /// Delete from cursor to line start (Ctrl+U).
435    pub fn delete_to_line_start(&mut self) {
436        if !self.enabled || self.cursor_col == 0 {
437            return;
438        }
439
440        let line = &self.lines[self.cursor_line];
441        let byte_pos = char_to_byte_index(line, self.cursor_col);
442        self.lines[self.cursor_line] = line[byte_pos..].to_string();
443        self.cursor_col = 0;
444    }
445
446    /// Delete from cursor to line end (Ctrl+K).
447    pub fn delete_to_line_end(&mut self) {
448        if !self.enabled {
449            return;
450        }
451
452        let line = &self.lines[self.cursor_line];
453        let byte_pos = char_to_byte_index(line, self.cursor_col);
454        self.lines[self.cursor_line] = line[..byte_pos].to_string();
455    }
456
457    // ========================================================================
458    // Cursor movement - Horizontal
459    // ========================================================================
460
461    /// Move cursor left by one character.
462    ///
463    /// At the start of a line, moves to end of previous line.
464    pub fn move_left(&mut self) {
465        if self.cursor_col > 0 {
466            self.cursor_col -= 1;
467        } else if self.cursor_line > 0 {
468            self.cursor_line -= 1;
469            self.cursor_col = self.lines[self.cursor_line].chars().count();
470            self.ensure_cursor_visible();
471        }
472    }
473
474    /// Move cursor right by one character.
475    ///
476    /// At the end of a line, moves to start of next line.
477    pub fn move_right(&mut self) {
478        let line_len = self.lines[self.cursor_line].chars().count();
479        if self.cursor_col < line_len {
480            self.cursor_col += 1;
481        } else if self.cursor_line + 1 < self.lines.len() {
482            self.cursor_line += 1;
483            self.cursor_col = 0;
484            self.ensure_cursor_visible();
485        }
486    }
487
488    /// Move cursor to start of line (Home).
489    pub fn move_line_start(&mut self) {
490        self.cursor_col = 0;
491    }
492
493    /// Move cursor to end of line (End).
494    pub fn move_line_end(&mut self) {
495        self.cursor_col = self.lines[self.cursor_line].chars().count();
496    }
497
498    /// Move cursor left by one word.
499    pub fn move_word_left(&mut self) {
500        if self.cursor_col == 0 {
501            if self.cursor_line > 0 {
502                self.cursor_line -= 1;
503                self.cursor_col = self.lines[self.cursor_line].chars().count();
504                self.ensure_cursor_visible();
505            }
506            return;
507        }
508
509        let line = &self.lines[self.cursor_line];
510
511        // Skip whitespace
512        while self.cursor_col > 0 {
513            if let Some(c) = char_at(line, self.cursor_col - 1) {
514                if c.is_whitespace() {
515                    self.cursor_col -= 1;
516                } else {
517                    break;
518                }
519            } else {
520                break;
521            }
522        }
523
524        // Skip word characters
525        while self.cursor_col > 0 {
526            if let Some(c) = char_at(line, self.cursor_col - 1) {
527                if !c.is_whitespace() {
528                    self.cursor_col -= 1;
529                } else {
530                    break;
531                }
532            } else {
533                break;
534            }
535        }
536    }
537
538    /// Move cursor right by one word.
539    pub fn move_word_right(&mut self) {
540        let line = &self.lines[self.cursor_line];
541        let line_len = line.chars().count();
542
543        if self.cursor_col >= line_len {
544            if self.cursor_line + 1 < self.lines.len() {
545                self.cursor_line += 1;
546                self.cursor_col = 0;
547                self.ensure_cursor_visible();
548            }
549            return;
550        }
551
552        // Skip current word
553        while self.cursor_col < line_len {
554            if let Some(c) = char_at(&self.lines[self.cursor_line], self.cursor_col) {
555                if !c.is_whitespace() {
556                    self.cursor_col += 1;
557                } else {
558                    break;
559                }
560            } else {
561                break;
562            }
563        }
564
565        // Skip whitespace
566        let line_len = self.lines[self.cursor_line].chars().count();
567        while self.cursor_col < line_len {
568            if let Some(c) = char_at(&self.lines[self.cursor_line], self.cursor_col) {
569                if c.is_whitespace() {
570                    self.cursor_col += 1;
571                } else {
572                    break;
573                }
574            } else {
575                break;
576            }
577        }
578    }
579
580    // ========================================================================
581    // Cursor movement - Vertical
582    // ========================================================================
583
584    /// Move cursor up by one line.
585    pub fn move_up(&mut self) {
586        if self.cursor_line > 0 {
587            self.cursor_line -= 1;
588            // Clamp column to new line length
589            let new_line_len = self.lines[self.cursor_line].chars().count();
590            self.cursor_col = self.cursor_col.min(new_line_len);
591            self.ensure_cursor_visible();
592        }
593    }
594
595    /// Move cursor down by one line.
596    pub fn move_down(&mut self) {
597        if self.cursor_line + 1 < self.lines.len() {
598            self.cursor_line += 1;
599            // Clamp column to new line length
600            let new_line_len = self.lines[self.cursor_line].chars().count();
601            self.cursor_col = self.cursor_col.min(new_line_len);
602            self.ensure_cursor_visible();
603        }
604    }
605
606    /// Move cursor up by one page.
607    pub fn move_page_up(&mut self) {
608        let page_size = self.visible_height.max(1);
609        if self.cursor_line >= page_size {
610            self.cursor_line -= page_size;
611        } else {
612            self.cursor_line = 0;
613        }
614        // Clamp column to new line length
615        let new_line_len = self.lines[self.cursor_line].chars().count();
616        self.cursor_col = self.cursor_col.min(new_line_len);
617        self.ensure_cursor_visible();
618    }
619
620    /// Move cursor down by one page.
621    pub fn move_page_down(&mut self) {
622        let page_size = self.visible_height.max(1);
623        let max_line = self.lines.len().saturating_sub(1);
624        self.cursor_line = (self.cursor_line + page_size).min(max_line);
625        // Clamp column to new line length
626        let new_line_len = self.lines[self.cursor_line].chars().count();
627        self.cursor_col = self.cursor_col.min(new_line_len);
628        self.ensure_cursor_visible();
629    }
630
631    /// Move cursor to start of document (Ctrl+Home).
632    pub fn move_to_start(&mut self) {
633        self.cursor_line = 0;
634        self.cursor_col = 0;
635        self.ensure_cursor_visible();
636    }
637
638    /// Move cursor to end of document (Ctrl+End).
639    pub fn move_to_end(&mut self) {
640        self.cursor_line = self.lines.len().saturating_sub(1);
641        self.cursor_col = self.lines[self.cursor_line].chars().count();
642        self.ensure_cursor_visible();
643    }
644
645    // ========================================================================
646    // Scroll management
647    // ========================================================================
648
649    /// Scroll to make cursor visible.
650    pub fn scroll_to_cursor(&mut self) {
651        // Vertical scroll
652        if self.cursor_line < self.scroll_y {
653            self.scroll_y = self.cursor_line;
654        } else if self.visible_height > 0 && self.cursor_line >= self.scroll_y + self.visible_height
655        {
656            self.scroll_y = self.cursor_line - self.visible_height + 1;
657        }
658    }
659
660    /// Ensure cursor is visible (alias for scroll_to_cursor).
661    pub fn ensure_cursor_visible(&mut self) {
662        self.scroll_to_cursor();
663    }
664
665    /// Scroll up by one line.
666    pub fn scroll_up(&mut self) {
667        self.scroll_y = self.scroll_y.saturating_sub(1);
668    }
669
670    /// Scroll down by one line.
671    pub fn scroll_down(&mut self) {
672        let max_scroll = self.lines.len().saturating_sub(self.visible_height.max(1));
673        if self.scroll_y < max_scroll {
674            self.scroll_y += 1;
675        }
676    }
677
678    /// Scroll left (for no-wrap mode).
679    pub fn scroll_left(&mut self) {
680        self.scroll_x = self.scroll_x.saturating_sub(4);
681    }
682
683    /// Scroll right (for no-wrap mode).
684    pub fn scroll_right(&mut self) {
685        self.scroll_x += 4;
686    }
687
688    // ========================================================================
689    // Content helpers
690    // ========================================================================
691
692    /// Get full text content (all lines joined with newlines).
693    pub fn text(&self) -> String {
694        self.lines.join("\n")
695    }
696
697    /// Set text content.
698    ///
699    /// Cursor moves to the end.
700    pub fn set_text(&mut self, text: impl Into<String>) {
701        let text = text.into();
702        self.lines = if text.is_empty() {
703            vec![String::new()]
704        } else {
705            text.lines().map(|s| s.to_string()).collect()
706        };
707        if self.lines.is_empty() {
708            self.lines.push(String::new());
709        }
710        self.cursor_line = self.lines.len().saturating_sub(1);
711        self.cursor_col = self.lines[self.cursor_line].chars().count();
712        self.scroll_y = 0;
713        self.scroll_x = 0;
714    }
715
716    /// Clear all text.
717    pub fn clear(&mut self) {
718        self.lines = vec![String::new()];
719        self.cursor_line = 0;
720        self.cursor_col = 0;
721        self.scroll_y = 0;
722        self.scroll_x = 0;
723    }
724
725    /// Get number of lines.
726    pub fn line_count(&self) -> usize {
727        self.lines.len()
728    }
729
730    /// Count visual lines when soft-wrapped at `content_width` characters.
731    ///
732    /// Each logical line takes `ceil(char_count / content_width)` visual rows (minimum 1).
733    /// Use this to size a container that renders with `WrapMode::Soft`.
734    /// If `content_width` is 0, falls back to logical line count.
735    pub fn visual_line_count(&self, content_width: usize) -> usize {
736        if content_width == 0 {
737            return self.lines.len();
738        }
739        self.lines
740            .iter()
741            .map(|line| {
742                let char_count = line.chars().count();
743                if char_count == 0 {
744                    1
745                } else {
746                    (char_count + content_width - 1) / content_width
747                }
748            })
749            .sum::<usize>()
750            .max(1)
751    }
752
753    /// Get current line content.
754    pub fn current_line(&self) -> &str {
755        &self.lines[self.cursor_line]
756    }
757
758    /// Check if textarea is empty.
759    pub fn is_empty(&self) -> bool {
760        self.lines.len() == 1 && self.lines[0].is_empty()
761    }
762
763    /// Get total character count (including newlines).
764    pub fn len(&self) -> usize {
765        let line_chars: usize = self.lines.iter().map(|l| l.chars().count()).sum();
766        let newlines = self.lines.len().saturating_sub(1);
767        line_chars + newlines
768    }
769
770    /// Get text before cursor on current line.
771    pub fn text_before_cursor(&self) -> &str {
772        let line = &self.lines[self.cursor_line];
773        let byte_pos = char_to_byte_index(line, self.cursor_col);
774        &line[..byte_pos]
775    }
776
777    /// Get text after cursor on current line.
778    pub fn text_after_cursor(&self) -> &str {
779        let line = &self.lines[self.cursor_line];
780        let byte_pos = char_to_byte_index(line, self.cursor_col);
781        &line[byte_pos..]
782    }
783}
784
785/// Configuration for textarea appearance.
786#[derive(Debug, Clone)]
787pub struct TextAreaStyle {
788    /// Border color when focused.
789    pub focused_border: Color,
790    /// Border color when unfocused.
791    pub unfocused_border: Color,
792    /// Border color when disabled.
793    pub disabled_border: Color,
794    /// Text foreground color.
795    pub text_fg: Color,
796    /// Cursor color.
797    pub cursor_fg: Color,
798    /// Placeholder text color.
799    pub placeholder_fg: Color,
800    /// Line number foreground color.
801    pub line_number_fg: Color,
802    /// Current line background highlight (optional).
803    pub current_line_bg: Option<Color>,
804    /// Whether to show line numbers.
805    pub show_line_numbers: bool,
806    /// Cursor rendering mode.
807    pub cursor_mode: CursorMode,
808    /// Scroll tracking mode.
809    pub scroll_mode: ScrollMode,
810}
811
812impl Default for TextAreaStyle {
813    fn default() -> Self {
814        Self {
815            focused_border: Color::Yellow,
816            unfocused_border: Color::Gray,
817            disabled_border: Color::DarkGray,
818            text_fg: Color::White,
819            cursor_fg: Color::Yellow,
820            placeholder_fg: Color::DarkGray,
821            line_number_fg: Color::DarkGray,
822            current_line_bg: None,
823            show_line_numbers: false,
824            cursor_mode: CursorMode::default(),
825            scroll_mode: ScrollMode::default(),
826        }
827    }
828}
829
830impl From<&crate::theme::Theme> for TextAreaStyle {
831    fn from(theme: &crate::theme::Theme) -> Self {
832        let p = &theme.palette;
833        Self {
834            focused_border: p.border_focused,
835            unfocused_border: p.border,
836            disabled_border: p.border_disabled,
837            text_fg: p.text,
838            cursor_fg: p.primary,
839            placeholder_fg: p.text_placeholder,
840            line_number_fg: p.text_disabled,
841            current_line_bg: None,
842            show_line_numbers: false,
843            cursor_mode: CursorMode::default(),
844            scroll_mode: ScrollMode::default(),
845        }
846    }
847}
848
849impl TextAreaStyle {
850    /// Set the focused border color.
851    pub fn focused_border(mut self, color: Color) -> Self {
852        self.focused_border = color;
853        self
854    }
855
856    /// Set the unfocused border color.
857    pub fn unfocused_border(mut self, color: Color) -> Self {
858        self.unfocused_border = color;
859        self
860    }
861
862    /// Set the disabled border color.
863    pub fn disabled_border(mut self, color: Color) -> Self {
864        self.disabled_border = color;
865        self
866    }
867
868    /// Set the text color.
869    pub fn text_fg(mut self, color: Color) -> Self {
870        self.text_fg = color;
871        self
872    }
873
874    /// Set the cursor color.
875    pub fn cursor_fg(mut self, color: Color) -> Self {
876        self.cursor_fg = color;
877        self
878    }
879
880    /// Set the placeholder color.
881    pub fn placeholder_fg(mut self, color: Color) -> Self {
882        self.placeholder_fg = color;
883        self
884    }
885
886    /// Set the line number color.
887    pub fn line_number_fg(mut self, color: Color) -> Self {
888        self.line_number_fg = color;
889        self
890    }
891
892    /// Set the current line background highlight.
893    pub fn current_line_bg(mut self, color: Option<Color>) -> Self {
894        self.current_line_bg = color;
895        self
896    }
897
898    /// Enable or disable line numbers.
899    pub fn show_line_numbers(mut self, show: bool) -> Self {
900        self.show_line_numbers = show;
901        self
902    }
903
904    /// Set the cursor rendering mode.
905    pub fn cursor_mode(mut self, mode: CursorMode) -> Self {
906        self.cursor_mode = mode;
907        self
908    }
909
910    /// Set the scroll tracking mode.
911    pub fn scroll_mode(mut self, mode: ScrollMode) -> Self {
912        self.scroll_mode = mode;
913        self
914    }
915}
916
917/// TextArea widget.
918///
919/// A multi-line text input field with cursor, label, and placeholder support.
920pub struct TextArea<'a> {
921    label: Option<&'a str>,
922    placeholder: Option<&'a str>,
923    style: TextAreaStyle,
924    focus_id: FocusId,
925    with_border: bool,
926    wrap_mode: WrapMode,
927    /// Rich title (takes precedence over `label`).
928    title: Option<Line<'a>>,
929    /// Pre-styled content lines (for custom highlighting).
930    content_lines: Option<Vec<Line<'a>>>,
931    /// Border color override (bypasses focus-based color logic).
932    border_color_override: Option<Color>,
933}
934
935impl TextArea<'_> {
936    /// Create a new textarea widget.
937    pub fn new() -> Self {
938        Self {
939            label: None,
940            placeholder: None,
941            style: TextAreaStyle::default(),
942            focus_id: FocusId::default(),
943            with_border: true,
944            wrap_mode: WrapMode::default(),
945            title: None,
946            content_lines: None,
947            border_color_override: None,
948        }
949    }
950}
951
952impl Default for TextArea<'_> {
953    fn default() -> Self {
954        Self::new()
955    }
956}
957
958impl<'a> TextArea<'a> {
959    /// Set the label (displayed in the border title).
960    pub fn label(mut self, label: &'a str) -> Self {
961        self.label = Some(label);
962        self
963    }
964
965    /// Set the placeholder text (shown when empty).
966    pub fn placeholder(mut self, placeholder: &'a str) -> Self {
967        self.placeholder = Some(placeholder);
968        self
969    }
970
971    /// Set the textarea style.
972    pub fn style(mut self, style: TextAreaStyle) -> Self {
973        self.style = style;
974        self
975    }
976
977    /// Apply a theme to this textarea.
978    pub fn theme(self, theme: &crate::theme::Theme) -> Self {
979        self.style(TextAreaStyle::from(theme))
980    }
981
982    /// Set the focus ID.
983    pub fn focus_id(mut self, id: FocusId) -> Self {
984        self.focus_id = id;
985        self
986    }
987
988    /// Enable or disable the border.
989    pub fn with_border(mut self, with_border: bool) -> Self {
990        self.with_border = with_border;
991        self
992    }
993
994    /// Set the wrap mode.
995    pub fn wrap_mode(mut self, mode: WrapMode) -> Self {
996        self.wrap_mode = mode;
997        self
998    }
999
1000    /// Set a rich title (takes precedence over `label`).
1001    pub fn title(mut self, title: Line<'a>) -> Self {
1002        self.title = Some(title);
1003        self
1004    }
1005
1006    /// Set pre-styled content lines for custom highlighting.
1007    ///
1008    /// When set, these lines are used instead of building lines from state.
1009    /// The caller is responsible for providing lines that match `state.lines` in count.
1010    pub fn content_lines(mut self, lines: Vec<Line<'a>>) -> Self {
1011        self.content_lines = Some(lines);
1012        self
1013    }
1014
1015    /// Override the border color (bypasses focus-based color logic).
1016    pub fn border_color(mut self, color: Color) -> Self {
1017        self.border_color_override = Some(color);
1018        self
1019    }
1020
1021    /// Render the textarea and return render result with click region and optional cursor position.
1022    pub fn render_stateful(
1023        self,
1024        frame: &mut Frame,
1025        area: Rect,
1026        state: &mut TextAreaState,
1027    ) -> TextAreaRender {
1028        let border_color = if let Some(override_color) = self.border_color_override {
1029            override_color
1030        } else if !state.enabled {
1031            self.style.disabled_border
1032        } else if state.focused {
1033            self.style.focused_border
1034        } else {
1035            self.style.unfocused_border
1036        };
1037
1038        let block = if self.with_border {
1039            let mut block = Block::default()
1040                .borders(Borders::ALL)
1041                .border_style(Style::default().fg(border_color));
1042            if let Some(title) = self.title {
1043                block = block.title(title);
1044            } else if let Some(label) = self.label {
1045                block = block.title(format!(" {} ", label));
1046            }
1047            Some(block)
1048        } else {
1049            None
1050        };
1051
1052        let inner_area = if let Some(ref b) = block {
1053            b.inner(area)
1054        } else {
1055            area
1056        };
1057
1058        // Update visible height in state
1059        state.visible_height = inner_area.height as usize;
1060
1061        // Calculate line number gutter width
1062        let line_num_width = if self.style.show_line_numbers {
1063            let max_line = state.lines.len();
1064            let digits = max_line.to_string().len();
1065            digits + 2 // digits + space + separator
1066        } else {
1067            0
1068        };
1069
1070        // Calculate content width
1071        let content_width = (inner_area.width as usize).saturating_sub(line_num_width);
1072
1073        let use_terminal_cursor = self.style.cursor_mode == CursorMode::Terminal;
1074
1075        // Handle empty state with placeholder
1076        if state.is_empty() && !state.focused {
1077            if let Some(placeholder) = self.placeholder {
1078                let display_line = Line::from(Span::styled(
1079                    placeholder,
1080                    Style::default().fg(self.style.placeholder_fg),
1081                ));
1082                let paragraph = Paragraph::new(display_line);
1083
1084                if let Some(block) = block {
1085                    frame.render_widget(block, area);
1086                }
1087                frame.render_widget(paragraph, inner_area);
1088                return TextAreaRender {
1089                    click_region: ClickRegion::new(area, TextAreaAction::Focus),
1090                    cursor_position: None,
1091                };
1092            }
1093        }
1094
1095        let mut display_lines: Vec<Line> = Vec::new();
1096        let mut cursor_screen_pos: Option<(u16, u16)> = None;
1097
1098        if self.wrap_mode == WrapMode::Soft && content_width > 0 {
1099            // Build visual rows: (logical_line_idx, start_col_in_line)
1100            let mut visual_rows: Vec<(usize, usize)> = Vec::new();
1101            for (li, line) in state.lines.iter().enumerate() {
1102                let char_count = line.chars().count();
1103                if char_count == 0 {
1104                    visual_rows.push((li, 0));
1105                } else {
1106                    let mut col = 0;
1107                    loop {
1108                        visual_rows.push((li, col));
1109                        col += content_width;
1110                        if col >= char_count {
1111                            break;
1112                        }
1113                    }
1114                }
1115            }
1116
1117            let total_visual_rows = visual_rows.len();
1118
1119            // Find which visual row the cursor is on
1120            let cursor_visual_row = visual_rows
1121                .iter()
1122                .enumerate()
1123                .rev()
1124                .find(|(_, (li, vc))| {
1125                    *li == state.cursor_line && state.cursor_col >= *vc
1126                })
1127                .map(|(i, _)| i)
1128                .unwrap_or(0);
1129
1130            // Effective scroll in visual rows
1131            let effective_scroll_vr =
1132                if self.style.scroll_mode == ScrollMode::CenterTracking && state.visible_height > 0
1133                {
1134                    let half_height = state.visible_height / 2;
1135                    if total_visual_rows <= state.visible_height
1136                        || cursor_visual_row <= half_height
1137                    {
1138                        0
1139                    } else if cursor_visual_row + half_height >= total_visual_rows {
1140                        total_visual_rows.saturating_sub(state.visible_height)
1141                    } else {
1142                        cursor_visual_row.saturating_sub(half_height)
1143                    }
1144                } else {
1145                    // Convert logical scroll_y to visual row offset
1146                    visual_rows
1147                        .iter()
1148                        .position(|(li, _)| *li >= state.scroll_y)
1149                        .unwrap_or(0)
1150                };
1151
1152            let start_vr = effective_scroll_vr;
1153            let end_vr = (start_vr + state.visible_height).min(total_visual_rows);
1154
1155            for (vr_offset, vr_idx) in (start_vr..end_vr).enumerate() {
1156                let (line_idx, start_col) = visual_rows[vr_idx];
1157                let is_cursor_line = line_idx == state.cursor_line;
1158                let display_row = vr_offset as u16;
1159
1160                let line = &state.lines[line_idx];
1161                let chars: Vec<char> = line.chars().collect();
1162                let visible_chars: String =
1163                    chars.iter().skip(start_col).take(content_width).collect();
1164
1165                let mut spans = Vec::new();
1166
1167                // Line number gutter (only on first visual row of a logical line)
1168                if self.style.show_line_numbers {
1169                    if start_col == 0 {
1170                        let line_num = format!(
1171                            "{:>width$} ",
1172                            line_idx + 1,
1173                            width = line_num_width.saturating_sub(2)
1174                        );
1175                        spans.push(Span::styled(
1176                            line_num,
1177                            Style::default().fg(self.style.line_number_fg),
1178                        ));
1179                    } else {
1180                        spans.push(Span::raw(" ".repeat(line_num_width)));
1181                    }
1182                }
1183
1184                let line_style = if is_cursor_line {
1185                    if let Some(bg) = self.style.current_line_bg {
1186                        Style::default().fg(self.style.text_fg).bg(bg)
1187                    } else {
1188                        Style::default().fg(self.style.text_fg)
1189                    }
1190                } else {
1191                    Style::default().fg(self.style.text_fg)
1192                };
1193
1194                // Cursor is on this visual row if cursor_col falls in [start_col, next_start_col)
1195                // or this is the last visual row for this logical line
1196                let is_last_vr_for_line =
1197                    vr_idx + 1 >= visual_rows.len() || visual_rows[vr_idx + 1].0 != line_idx;
1198                let cursor_on_this_vr = is_cursor_line
1199                    && state.cursor_col >= start_col
1200                    && (is_last_vr_for_line || state.cursor_col < start_col + content_width);
1201
1202                if cursor_on_this_vr && state.focused {
1203                    let cursor_visible_col = state.cursor_col - start_col;
1204                    let visible_char_count = visible_chars.chars().count();
1205
1206                    if use_terminal_cursor {
1207                        spans.push(Span::styled(visible_chars, line_style));
1208                        let cx =
1209                            inner_area.x + line_num_width as u16 + cursor_visible_col as u16;
1210                        let cy = inner_area.y + display_row;
1211                        if cx < inner_area.x + inner_area.width
1212                            && cy < inner_area.y + inner_area.height
1213                        {
1214                            cursor_screen_pos = Some((cx, cy));
1215                        }
1216                    } else if cursor_visible_col <= visible_char_count {
1217                        let before: String =
1218                            visible_chars.chars().take(cursor_visible_col).collect();
1219                        let cursor_char: String = visible_chars
1220                            .chars()
1221                            .skip(cursor_visible_col)
1222                            .take(1)
1223                            .collect();
1224                        let after: String =
1225                            visible_chars.chars().skip(cursor_visible_col + 1).collect();
1226
1227                        if !before.is_empty() {
1228                            spans.push(Span::styled(before, line_style));
1229                        }
1230                        let cursor_style = Style::default()
1231                            .fg(self.style.cursor_fg)
1232                            .bg(self.style.text_fg);
1233                        let cursor_display =
1234                            if cursor_char.is_empty() { " " } else { &cursor_char };
1235                        spans.push(Span::styled(cursor_display.to_string(), cursor_style));
1236                        if !after.is_empty() {
1237                            spans.push(Span::styled(after, line_style));
1238                        }
1239                    } else {
1240                        spans.push(Span::styled(visible_chars, line_style));
1241                    }
1242                } else {
1243                    spans.push(Span::styled(visible_chars, line_style));
1244                }
1245
1246                display_lines.push(Line::from(spans));
1247            }
1248        } else {
1249        // Calculate effective scroll offset
1250        let effective_scroll_y =
1251            if self.style.scroll_mode == ScrollMode::CenterTracking && state.visible_height > 0 {
1252                // Center-tracking: keep cursor near vertical midpoint
1253                let total_lines = state.lines.len();
1254                let half_height = state.visible_height / 2;
1255                if total_lines <= state.visible_height || state.cursor_line <= half_height {
1256                    0
1257                } else if state.cursor_line + half_height >= total_lines {
1258                    total_lines.saturating_sub(state.visible_height)
1259                } else {
1260                    state.cursor_line.saturating_sub(half_height)
1261                }
1262            } else {
1263                state.scroll_y
1264            };
1265
1266        // Build visible lines
1267        let start_line = effective_scroll_y;
1268        let end_line = (start_line + state.visible_height).min(state.lines.len());
1269
1270        for line_idx in start_line..end_line {
1271            let is_cursor_line = line_idx == state.cursor_line;
1272            let display_row = (line_idx - start_line) as u16;
1273
1274            // Check if we have pre-styled content lines
1275            if let Some(ref content) = self.content_lines {
1276                if line_idx < content.len() {
1277                    let mut spans = Vec::new();
1278
1279                    // Line number
1280                    if self.style.show_line_numbers {
1281                        let line_num = format!(
1282                            "{:>width$} ",
1283                            line_idx + 1,
1284                            width = line_num_width.saturating_sub(2)
1285                        );
1286                        spans.push(Span::styled(
1287                            line_num,
1288                            Style::default().fg(self.style.line_number_fg),
1289                        ));
1290                    }
1291
1292                    // Use pre-styled content
1293                    spans.extend(content[line_idx].spans.iter().cloned());
1294                    display_lines.push(Line::from(spans));
1295
1296                    // Calculate cursor position for terminal mode
1297                    if is_cursor_line && state.focused && use_terminal_cursor {
1298                        let cursor_visible_col = state.cursor_col.saturating_sub(state.scroll_x);
1299                        let cx = inner_area.x + line_num_width as u16 + cursor_visible_col as u16;
1300                        let cy = inner_area.y + display_row;
1301                        if cx < inner_area.x + inner_area.width
1302                            && cy < inner_area.y + inner_area.height
1303                        {
1304                            cursor_screen_pos = Some((cx, cy));
1305                        }
1306                    }
1307                    continue;
1308                }
1309            }
1310
1311            let line = &state.lines[line_idx];
1312
1313            // Apply horizontal scroll
1314            let chars: Vec<char> = line.chars().collect();
1315            let visible_chars: String = chars
1316                .iter()
1317                .skip(state.scroll_x)
1318                .take(content_width)
1319                .collect();
1320
1321            let mut spans = Vec::new();
1322
1323            // Line number
1324            if self.style.show_line_numbers {
1325                let line_num = format!(
1326                    "{:>width$} ",
1327                    line_idx + 1,
1328                    width = line_num_width.saturating_sub(2)
1329                );
1330                spans.push(Span::styled(
1331                    line_num,
1332                    Style::default().fg(self.style.line_number_fg),
1333                ));
1334            }
1335
1336            // Determine line style
1337            let line_style = if is_cursor_line {
1338                if let Some(bg) = self.style.current_line_bg {
1339                    Style::default().fg(self.style.text_fg).bg(bg)
1340                } else {
1341                    Style::default().fg(self.style.text_fg)
1342                }
1343            } else {
1344                Style::default().fg(self.style.text_fg)
1345            };
1346
1347            // Build content with cursor
1348            if is_cursor_line && state.focused {
1349                let cursor_visible_col = state.cursor_col.saturating_sub(state.scroll_x);
1350                let visible_char_count = visible_chars.chars().count();
1351
1352                if use_terminal_cursor {
1353                    // Terminal cursor mode: just render text, return screen position
1354                    spans.push(Span::styled(visible_chars, line_style));
1355                    let cx = inner_area.x + line_num_width as u16 + cursor_visible_col as u16;
1356                    let cy = inner_area.y + display_row;
1357                    if cx < inner_area.x + inner_area.width && cy < inner_area.y + inner_area.height
1358                    {
1359                        cursor_screen_pos = Some((cx, cy));
1360                    }
1361                } else if cursor_visible_col <= visible_char_count {
1362                    // Block cursor mode: render inverted span
1363                    let before: String = visible_chars.chars().take(cursor_visible_col).collect();
1364                    let cursor_char: String = visible_chars
1365                        .chars()
1366                        .skip(cursor_visible_col)
1367                        .take(1)
1368                        .collect();
1369                    let after: String =
1370                        visible_chars.chars().skip(cursor_visible_col + 1).collect();
1371
1372                    if !before.is_empty() {
1373                        spans.push(Span::styled(before, line_style));
1374                    }
1375
1376                    let cursor_style = Style::default()
1377                        .fg(self.style.cursor_fg)
1378                        .bg(self.style.text_fg);
1379                    let cursor_display = if cursor_char.is_empty() {
1380                        " "
1381                    } else {
1382                        &cursor_char
1383                    };
1384                    spans.push(Span::styled(cursor_display.to_string(), cursor_style));
1385
1386                    if !after.is_empty() {
1387                        spans.push(Span::styled(after, line_style));
1388                    }
1389                } else {
1390                    spans.push(Span::styled(visible_chars, line_style));
1391                }
1392            } else {
1393                spans.push(Span::styled(visible_chars, line_style));
1394            }
1395
1396            display_lines.push(Line::from(spans));
1397        }
1398        } // end else (WrapMode::None)
1399
1400        // Handle case when there are no lines to display (but cursor is active)
1401        if display_lines.is_empty() && state.focused {
1402            let mut spans = Vec::new();
1403            if self.style.show_line_numbers {
1404                let line_num = format!("{:>width$} ", 1, width = line_num_width.saturating_sub(2));
1405                spans.push(Span::styled(
1406                    line_num,
1407                    Style::default().fg(self.style.line_number_fg),
1408                ));
1409            }
1410            if use_terminal_cursor {
1411                spans.push(Span::styled(" ", Style::default().fg(self.style.text_fg)));
1412                let cx = inner_area.x + line_num_width as u16;
1413                let cy = inner_area.y;
1414                cursor_screen_pos = Some((cx, cy));
1415            } else {
1416                let cursor_style = Style::default()
1417                    .fg(self.style.cursor_fg)
1418                    .bg(self.style.text_fg);
1419                spans.push(Span::styled(" ", cursor_style));
1420            }
1421            display_lines.push(Line::from(spans));
1422        }
1423
1424        let paragraph = Paragraph::new(display_lines);
1425
1426        if let Some(block) = block {
1427            frame.render_widget(block, area);
1428        }
1429        frame.render_widget(paragraph, inner_area);
1430
1431        TextAreaRender {
1432            click_region: ClickRegion::new(area, TextAreaAction::Focus),
1433            cursor_position: cursor_screen_pos,
1434        }
1435    }
1436}
1437
1438#[cfg(test)]
1439mod tests {
1440    use super::*;
1441
1442    // ========================================================================
1443    // State construction tests
1444    // ========================================================================
1445
1446    #[test]
1447    fn test_state_default() {
1448        let state = TextAreaState::default();
1449        assert_eq!(state.lines.len(), 1);
1450        assert!(state.lines[0].is_empty());
1451        assert_eq!(state.cursor_line, 0);
1452        assert_eq!(state.cursor_col, 0);
1453        assert!(!state.focused);
1454        assert!(state.enabled);
1455    }
1456
1457    #[test]
1458    fn test_state_new_single_line() {
1459        let state = TextAreaState::new("Hello");
1460        assert_eq!(state.lines.len(), 1);
1461        assert_eq!(state.lines[0], "Hello");
1462        assert_eq!(state.cursor_line, 0);
1463        assert_eq!(state.cursor_col, 0);
1464    }
1465
1466    #[test]
1467    fn test_state_new_multi_line() {
1468        let state = TextAreaState::new("Hello\nWorld");
1469        assert_eq!(state.lines.len(), 2);
1470        assert_eq!(state.lines[0], "Hello");
1471        assert_eq!(state.lines[1], "World");
1472        assert_eq!(state.cursor_line, 0);
1473        assert_eq!(state.cursor_col, 0);
1474    }
1475
1476    #[test]
1477    fn test_state_new_empty() {
1478        let state = TextAreaState::new("");
1479        assert_eq!(state.lines.len(), 1);
1480        assert!(state.lines[0].is_empty());
1481        assert_eq!(state.cursor_line, 0);
1482        assert_eq!(state.cursor_col, 0);
1483    }
1484
1485    #[test]
1486    fn test_state_empty() {
1487        let state = TextAreaState::empty();
1488        assert!(state.is_empty());
1489    }
1490
1491    // ========================================================================
1492    // Character operations tests
1493    // ========================================================================
1494
1495    #[test]
1496    fn test_insert_char() {
1497        let mut state = TextAreaState::new("Hello");
1498        state.move_to_end();
1499        state.insert_char('!');
1500        assert_eq!(state.lines[0], "Hello!");
1501        assert_eq!(state.cursor_col, 6);
1502    }
1503
1504    #[test]
1505    fn test_insert_char_middle() {
1506        let mut state = TextAreaState::new("Hllo");
1507        state.cursor_col = 1;
1508        state.insert_char('e');
1509        assert_eq!(state.lines[0], "Hello");
1510        assert_eq!(state.cursor_col, 2);
1511    }
1512
1513    #[test]
1514    fn test_insert_str_single_line() {
1515        let mut state = TextAreaState::new("Hello");
1516        state.move_to_end();
1517        state.insert_str(" World");
1518        assert_eq!(state.lines[0], "Hello World");
1519    }
1520
1521    #[test]
1522    fn test_insert_str_multi_line() {
1523        let mut state = TextAreaState::new("Hello");
1524        state.move_to_end();
1525        state.insert_str(" World\nNew Line");
1526        assert_eq!(state.lines.len(), 2);
1527        assert_eq!(state.lines[0], "Hello World");
1528        assert_eq!(state.lines[1], "New Line");
1529    }
1530
1531    #[test]
1532    fn test_insert_newline() {
1533        let mut state = TextAreaState::new("HelloWorld");
1534        state.cursor_col = 5;
1535        state.insert_newline();
1536        assert_eq!(state.lines.len(), 2);
1537        assert_eq!(state.lines[0], "Hello");
1538        assert_eq!(state.lines[1], "World");
1539        assert_eq!(state.cursor_line, 1);
1540        assert_eq!(state.cursor_col, 0);
1541    }
1542
1543    #[test]
1544    fn test_insert_newline_at_start() {
1545        let mut state = TextAreaState::new("Hello");
1546        state.insert_newline();
1547        assert_eq!(state.lines.len(), 2);
1548        assert_eq!(state.lines[0], "");
1549        assert_eq!(state.lines[1], "Hello");
1550    }
1551
1552    #[test]
1553    fn test_insert_newline_at_end() {
1554        let mut state = TextAreaState::new("Hello");
1555        state.move_to_end();
1556        state.insert_newline();
1557        assert_eq!(state.lines.len(), 2);
1558        assert_eq!(state.lines[0], "Hello");
1559        assert_eq!(state.lines[1], "");
1560    }
1561
1562    #[test]
1563    fn test_insert_tab_spaces() {
1564        let mut state = TextAreaState::empty();
1565        state.tab_config = TabConfig::Spaces(4);
1566        state.insert_tab();
1567        assert_eq!(state.lines[0], "    ");
1568    }
1569
1570    #[test]
1571    fn test_insert_tab_literal() {
1572        let mut state = TextAreaState::empty();
1573        state.tab_config = TabConfig::Literal;
1574        state.insert_tab();
1575        assert_eq!(state.lines[0], "\t");
1576    }
1577
1578    // ========================================================================
1579    // Deletion tests
1580    // ========================================================================
1581
1582    #[test]
1583    fn test_delete_char_backward() {
1584        let mut state = TextAreaState::new("Hello");
1585        state.move_to_end();
1586        assert!(state.delete_char_backward());
1587        assert_eq!(state.lines[0], "Hell");
1588        assert_eq!(state.cursor_col, 4);
1589    }
1590
1591    #[test]
1592    fn test_delete_char_backward_at_start() {
1593        let mut state = TextAreaState::new("Hello");
1594        // Cursor starts at 0, so no need to set it
1595        assert!(!state.delete_char_backward());
1596        assert_eq!(state.lines[0], "Hello");
1597    }
1598
1599    #[test]
1600    fn test_delete_char_backward_merges_lines() {
1601        let mut state = TextAreaState::new("Hello\nWorld");
1602        state.cursor_line = 1;
1603        state.cursor_col = 0;
1604        assert!(state.delete_char_backward());
1605        assert_eq!(state.lines.len(), 1);
1606        assert_eq!(state.lines[0], "HelloWorld");
1607        assert_eq!(state.cursor_col, 5);
1608    }
1609
1610    #[test]
1611    fn test_delete_char_forward() {
1612        let mut state = TextAreaState::new("Hello");
1613        state.cursor_col = 0;
1614        assert!(state.delete_char_forward());
1615        assert_eq!(state.lines[0], "ello");
1616    }
1617
1618    #[test]
1619    fn test_delete_char_forward_at_end() {
1620        let mut state = TextAreaState::new("Hello");
1621        state.move_to_end();
1622        assert!(!state.delete_char_forward());
1623        assert_eq!(state.lines[0], "Hello");
1624    }
1625
1626    #[test]
1627    fn test_delete_char_forward_merges_lines() {
1628        let mut state = TextAreaState::new("Hello\nWorld");
1629        state.cursor_line = 0;
1630        state.cursor_col = 5;
1631        assert!(state.delete_char_forward());
1632        assert_eq!(state.lines.len(), 1);
1633        assert_eq!(state.lines[0], "HelloWorld");
1634    }
1635
1636    #[test]
1637    fn test_delete_word_backward() {
1638        let mut state = TextAreaState::new("Hello World");
1639        state.move_to_end();
1640        assert!(state.delete_word_backward());
1641        assert_eq!(state.lines[0], "Hello ");
1642    }
1643
1644    #[test]
1645    fn test_delete_word_backward_from_start() {
1646        let mut state = TextAreaState::new("Hello");
1647        // Cursor starts at 0
1648        assert!(!state.delete_word_backward());
1649    }
1650
1651    #[test]
1652    fn test_delete_line() {
1653        let mut state = TextAreaState::new("Line 1\nLine 2\nLine 3");
1654        state.cursor_line = 1;
1655        state.cursor_col = 0;
1656        state.delete_line();
1657        assert_eq!(state.lines.len(), 2);
1658        assert_eq!(state.lines[0], "Line 1");
1659        assert_eq!(state.lines[1], "Line 3");
1660    }
1661
1662    #[test]
1663    fn test_delete_line_single() {
1664        let mut state = TextAreaState::new("Hello");
1665        state.delete_line();
1666        assert_eq!(state.lines.len(), 1);
1667        assert!(state.lines[0].is_empty());
1668    }
1669
1670    #[test]
1671    fn test_delete_to_line_start() {
1672        let mut state = TextAreaState::new("Hello World");
1673        state.cursor_col = 6;
1674        state.delete_to_line_start();
1675        assert_eq!(state.lines[0], "World");
1676        assert_eq!(state.cursor_col, 0);
1677    }
1678
1679    #[test]
1680    fn test_delete_to_line_end() {
1681        let mut state = TextAreaState::new("Hello World");
1682        state.cursor_col = 5;
1683        state.delete_to_line_end();
1684        assert_eq!(state.lines[0], "Hello");
1685    }
1686
1687    // ========================================================================
1688    // Cursor movement tests
1689    // ========================================================================
1690
1691    #[test]
1692    fn test_move_left() {
1693        let mut state = TextAreaState::new("Hello");
1694        state.move_to_end();
1695        state.move_left();
1696        assert_eq!(state.cursor_col, 4);
1697    }
1698
1699    #[test]
1700    fn test_move_left_wraps_line() {
1701        let mut state = TextAreaState::new("Hello\nWorld");
1702        state.cursor_line = 1;
1703        state.cursor_col = 0;
1704        state.move_left();
1705        assert_eq!(state.cursor_line, 0);
1706        assert_eq!(state.cursor_col, 5);
1707    }
1708
1709    #[test]
1710    fn test_move_left_at_start() {
1711        let mut state = TextAreaState::new("Hello");
1712        state.cursor_col = 0;
1713        state.move_left();
1714        assert_eq!(state.cursor_col, 0);
1715    }
1716
1717    #[test]
1718    fn test_move_right() {
1719        let mut state = TextAreaState::new("Hello");
1720        state.cursor_col = 0;
1721        state.move_right();
1722        assert_eq!(state.cursor_col, 1);
1723    }
1724
1725    #[test]
1726    fn test_move_right_wraps_line() {
1727        let mut state = TextAreaState::new("Hello\nWorld");
1728        state.cursor_line = 0;
1729        state.cursor_col = 5;
1730        state.move_right();
1731        assert_eq!(state.cursor_line, 1);
1732        assert_eq!(state.cursor_col, 0);
1733    }
1734
1735    #[test]
1736    fn test_move_right_at_end() {
1737        let mut state = TextAreaState::new("Hello");
1738        state.move_to_end();
1739        state.move_right();
1740        assert_eq!(state.cursor_col, 5); // Should stay at end
1741    }
1742
1743    #[test]
1744    fn test_move_line_start() {
1745        let mut state = TextAreaState::new("Hello");
1746        state.move_line_start();
1747        assert_eq!(state.cursor_col, 0);
1748    }
1749
1750    #[test]
1751    fn test_move_line_end() {
1752        let mut state = TextAreaState::new("Hello");
1753        state.cursor_col = 0;
1754        state.move_line_end();
1755        assert_eq!(state.cursor_col, 5);
1756    }
1757
1758    #[test]
1759    fn test_move_up() {
1760        let mut state = TextAreaState::new("Line 1\nLine 2\nLine 3");
1761        state.cursor_line = 2; // Start at last line
1762        state.move_up();
1763        assert_eq!(state.cursor_line, 1);
1764    }
1765
1766    #[test]
1767    fn test_move_up_clamps_column() {
1768        let mut state = TextAreaState::new("AB\nCDEFG");
1769        state.cursor_line = 1; // Start on second line (CDEFG)
1770        state.cursor_col = 5;
1771        state.move_up();
1772        assert_eq!(state.cursor_line, 0);
1773        assert_eq!(state.cursor_col, 2); // Clamped to line length
1774    }
1775
1776    #[test]
1777    fn test_move_down() {
1778        let mut state = TextAreaState::new("Line 1\nLine 2\nLine 3");
1779        state.cursor_line = 0;
1780        state.move_down();
1781        assert_eq!(state.cursor_line, 1);
1782    }
1783
1784    #[test]
1785    fn test_move_down_at_last_line() {
1786        let mut state = TextAreaState::new("Hello");
1787        state.move_down();
1788        assert_eq!(state.cursor_line, 0);
1789    }
1790
1791    #[test]
1792    fn test_move_word_left() {
1793        let mut state = TextAreaState::new("Hello World Test");
1794        state.move_to_end(); // Start at end of text
1795        state.move_word_left();
1796        assert_eq!(state.cursor_col, 12); // Start of "Test"
1797    }
1798
1799    #[test]
1800    fn test_move_word_right() {
1801        let mut state = TextAreaState::new("Hello World Test");
1802        state.cursor_col = 0;
1803        state.move_word_right();
1804        assert_eq!(state.cursor_col, 6); // After "Hello "
1805    }
1806
1807    #[test]
1808    fn test_move_page_up() {
1809        let mut state = TextAreaState::new("1\n2\n3\n4\n5\n6\n7\n8\n9\n10");
1810        state.visible_height = 3;
1811        state.cursor_line = 9; // Start at last line
1812        state.move_page_up();
1813        assert_eq!(state.cursor_line, 6);
1814    }
1815
1816    #[test]
1817    fn test_move_page_down() {
1818        let mut state = TextAreaState::new("1\n2\n3\n4\n5\n6\n7\n8\n9\n10");
1819        state.cursor_line = 0;
1820        state.visible_height = 3;
1821        state.move_page_down();
1822        assert_eq!(state.cursor_line, 3);
1823    }
1824
1825    #[test]
1826    fn test_move_to_start() {
1827        let mut state = TextAreaState::new("Hello\nWorld");
1828        state.move_to_start();
1829        assert_eq!(state.cursor_line, 0);
1830        assert_eq!(state.cursor_col, 0);
1831    }
1832
1833    #[test]
1834    fn test_move_to_end() {
1835        let mut state = TextAreaState::new("Hello\nWorld");
1836        state.cursor_line = 0;
1837        state.cursor_col = 0;
1838        state.move_to_end();
1839        assert_eq!(state.cursor_line, 1);
1840        assert_eq!(state.cursor_col, 5);
1841    }
1842
1843    // ========================================================================
1844    // Content helpers tests
1845    // ========================================================================
1846
1847    #[test]
1848    fn test_text() {
1849        let state = TextAreaState::new("Hello\nWorld");
1850        assert_eq!(state.text(), "Hello\nWorld");
1851    }
1852
1853    #[test]
1854    fn test_set_text() {
1855        let mut state = TextAreaState::new("Old");
1856        state.set_text("New\nContent");
1857        assert_eq!(state.lines.len(), 2);
1858        assert_eq!(state.lines[0], "New");
1859        assert_eq!(state.lines[1], "Content");
1860        assert_eq!(state.cursor_line, 1);
1861        assert_eq!(state.cursor_col, 7);
1862    }
1863
1864    #[test]
1865    fn test_clear() {
1866        let mut state = TextAreaState::new("Hello\nWorld");
1867        state.clear();
1868        assert!(state.is_empty());
1869        assert_eq!(state.cursor_line, 0);
1870        assert_eq!(state.cursor_col, 0);
1871    }
1872
1873    #[test]
1874    fn test_line_count() {
1875        let state = TextAreaState::new("A\nB\nC");
1876        assert_eq!(state.line_count(), 3);
1877    }
1878
1879    #[test]
1880    fn test_current_line() {
1881        let mut state = TextAreaState::new("Hello\nWorld");
1882        state.cursor_line = 0;
1883        assert_eq!(state.current_line(), "Hello");
1884    }
1885
1886    #[test]
1887    fn test_is_empty() {
1888        let state = TextAreaState::empty();
1889        assert!(state.is_empty());
1890
1891        let state = TextAreaState::new("Hi");
1892        assert!(!state.is_empty());
1893    }
1894
1895    #[test]
1896    fn test_len() {
1897        let state = TextAreaState::new("Hi\nWorld");
1898        // "Hi" (2) + "\n" (1) + "World" (5) = 8
1899        assert_eq!(state.len(), 8);
1900    }
1901
1902    #[test]
1903    fn test_text_before_after_cursor() {
1904        let mut state = TextAreaState::new("Hello World");
1905        state.cursor_col = 5;
1906        assert_eq!(state.text_before_cursor(), "Hello");
1907        assert_eq!(state.text_after_cursor(), " World");
1908    }
1909
1910    // ========================================================================
1911    // Unicode handling tests
1912    // ========================================================================
1913
1914    #[test]
1915    fn test_unicode_handling() {
1916        let mut state = TextAreaState::new("你好");
1917        state.move_to_end();
1918        assert_eq!(state.cursor_col, 2);
1919
1920        state.move_left();
1921        assert_eq!(state.cursor_col, 1);
1922
1923        state.insert_char('世');
1924        assert_eq!(state.lines[0], "你世好");
1925    }
1926
1927    #[test]
1928    fn test_emoji_handling() {
1929        let mut state = TextAreaState::new("Hi 👋");
1930        assert_eq!(state.len(), 4);
1931
1932        state.move_to_end();
1933        state.delete_char_backward();
1934        assert_eq!(state.lines[0], "Hi ");
1935    }
1936
1937    // ========================================================================
1938    // Disabled state tests
1939    // ========================================================================
1940
1941    #[test]
1942    fn test_disabled_no_insert() {
1943        let mut state = TextAreaState::new("Hello");
1944        state.enabled = false;
1945        state.insert_char('!');
1946        assert_eq!(state.lines[0], "Hello");
1947    }
1948
1949    #[test]
1950    fn test_disabled_no_delete() {
1951        let mut state = TextAreaState::new("Hello");
1952        state.enabled = false;
1953        assert!(!state.delete_char_backward());
1954        assert_eq!(state.lines[0], "Hello");
1955    }
1956
1957    #[test]
1958    fn test_disabled_no_newline() {
1959        let mut state = TextAreaState::new("Hello");
1960        state.enabled = false;
1961        state.insert_newline();
1962        assert_eq!(state.lines.len(), 1);
1963    }
1964
1965    // ========================================================================
1966    // Scroll tests
1967    // ========================================================================
1968
1969    #[test]
1970    fn test_scroll_to_cursor_down() {
1971        let mut state = TextAreaState::new("1\n2\n3\n4\n5\n6\n7\n8\n9\n10");
1972        state.visible_height = 3;
1973        state.cursor_line = 8;
1974        state.scroll_to_cursor();
1975        assert_eq!(state.scroll_y, 6);
1976    }
1977
1978    #[test]
1979    fn test_scroll_to_cursor_up() {
1980        let mut state = TextAreaState::new("1\n2\n3\n4\n5\n6\n7\n8\n9\n10");
1981        state.visible_height = 3;
1982        state.scroll_y = 5;
1983        state.cursor_line = 2;
1984        state.scroll_to_cursor();
1985        assert_eq!(state.scroll_y, 2);
1986    }
1987
1988    #[test]
1989    fn test_scroll_up() {
1990        let mut state = TextAreaState::new("1\n2\n3");
1991        state.scroll_y = 2;
1992        state.scroll_up();
1993        assert_eq!(state.scroll_y, 1);
1994    }
1995
1996    #[test]
1997    fn test_scroll_down() {
1998        let mut state = TextAreaState::new("1\n2\n3\n4\n5");
1999        state.visible_height = 2;
2000        state.scroll_down();
2001        assert_eq!(state.scroll_y, 1);
2002    }
2003
2004    // ========================================================================
2005    // Style tests
2006    // ========================================================================
2007
2008    #[test]
2009    fn test_style_default() {
2010        let style = TextAreaStyle::default();
2011        assert_eq!(style.focused_border, Color::Yellow);
2012        assert_eq!(style.text_fg, Color::White);
2013        assert!(!style.show_line_numbers);
2014    }
2015
2016    #[test]
2017    fn test_style_builder() {
2018        let style = TextAreaStyle::default()
2019            .focused_border(Color::Cyan)
2020            .text_fg(Color::Green)
2021            .show_line_numbers(true);
2022
2023        assert_eq!(style.focused_border, Color::Cyan);
2024        assert_eq!(style.text_fg, Color::Green);
2025        assert!(style.show_line_numbers);
2026    }
2027
2028    // ========================================================================
2029    // Tab config tests
2030    // ========================================================================
2031
2032    #[test]
2033    fn test_tab_config_default() {
2034        let config = TabConfig::default();
2035        assert_eq!(config, TabConfig::Spaces(4));
2036    }
2037
2038    #[test]
2039    fn test_with_tab_config() {
2040        let state = TextAreaState::empty().with_tab_config(TabConfig::Spaces(2));
2041        assert_eq!(state.tab_config, TabConfig::Spaces(2));
2042    }
2043
2044    // ========================================================================
2045    // New feature tests
2046    // ========================================================================
2047
2048    #[test]
2049    fn test_delete_word_forward() {
2050        let mut state = TextAreaState::new("Hello World Test");
2051        state.cursor_col = 0;
2052        assert!(state.delete_word_forward());
2053        assert_eq!(state.lines[0], "World Test");
2054        assert_eq!(state.cursor_col, 0);
2055    }
2056
2057    #[test]
2058    fn test_delete_word_forward_mid_word() {
2059        let mut state = TextAreaState::new("Hello World");
2060        state.cursor_col = 3; // mid "Hello"
2061        assert!(state.delete_word_forward());
2062        assert_eq!(state.lines[0], "HelWorld");
2063    }
2064
2065    #[test]
2066    fn test_delete_word_forward_at_end() {
2067        let mut state = TextAreaState::new("Hello");
2068        state.move_to_end();
2069        assert!(!state.delete_word_forward());
2070        assert_eq!(state.lines[0], "Hello");
2071    }
2072
2073    #[test]
2074    fn test_delete_word_forward_merges_lines() {
2075        let mut state = TextAreaState::new("Hello\nWorld");
2076        state.cursor_col = 5; // end of "Hello"
2077        assert!(state.delete_word_forward());
2078        assert_eq!(state.lines.len(), 1);
2079        assert_eq!(state.lines[0], "HelloWorld");
2080    }
2081
2082    #[test]
2083    fn test_cursor_mode_default() {
2084        assert_eq!(CursorMode::default(), CursorMode::Block);
2085    }
2086
2087    #[test]
2088    fn test_scroll_mode_default() {
2089        assert_eq!(ScrollMode::default(), ScrollMode::Minimal);
2090    }
2091
2092    #[test]
2093    fn test_style_cursor_mode() {
2094        let style = TextAreaStyle::default().cursor_mode(CursorMode::Terminal);
2095        assert_eq!(style.cursor_mode, CursorMode::Terminal);
2096    }
2097
2098    #[test]
2099    fn test_style_scroll_mode() {
2100        let style = TextAreaStyle::default().scroll_mode(ScrollMode::CenterTracking);
2101        assert_eq!(style.scroll_mode, ScrollMode::CenterTracking);
2102    }
2103
2104    #[test]
2105    fn test_textarea_title_builder() {
2106        let textarea = TextArea::new().title(Line::from("My Title"));
2107        assert!(textarea.title.is_some());
2108    }
2109
2110    #[test]
2111    fn test_textarea_border_color_builder() {
2112        let textarea = TextArea::new().border_color(Color::Red);
2113        assert_eq!(textarea.border_color_override, Some(Color::Red));
2114    }
2115
2116    #[test]
2117    fn test_textarea_content_lines_builder() {
2118        let lines = vec![Line::from("test")];
2119        let textarea = TextArea::new().content_lines(lines);
2120        assert!(textarea.content_lines.is_some());
2121    }
2122}