bubbletea_widgets/textarea/
mod.rs

1//! Textarea component for Bubble Tea applications.
2//!
3//! This module provides a multi-line text input component with feature parity
4//! to the Go `bubbles/textarea` package. It supports soft-wrapping, line
5//! numbers, customizable prompts, clipboard integration, and rich theming via
6//! Lip Gloss styles.
7//!
8//! The component implements the crate's `Component` trait so it can be focused
9//! and blurred, and it exposes a `Model` with idiomatic methods for editing and
10//! navigation (insert, delete, move by character/word/line, etc.).
11//!
12//! ### Features
13//! - Soft-wrapped lines with correct column/character accounting for double-width runes
14//! - Optional line numbers and per-line prompts (static or via a prompt function)
15//! - Cursor movement by character, word, and line with deletion/edit helpers
16//! - Viewport-driven rendering for large inputs
17//! - Clipboard paste integration (platform dependent)
18//! - Theming via `TextareaStyle` for focused and blurred states
19//!
20//! ### Example
21//! ```rust
22//! use bubbletea_widgets::{textarea, Component};
23//!
24//! // Create a textarea with defaults
25//! let mut ta = textarea::new();
26//! ta.set_width(40);
27//! ta.set_height(6);
28//! ta.placeholder = "Type here…".into();
29//!
30//! // Focus to start receiving input
31//! let _ = ta.focus();
32//!
33//! // Programmatic edits
34//! ta.insert_string("Hello\nworld!");
35//! ta.word_left();
36//! ta.uppercase_right();
37//!
38//! // Render view (string with ANSI styling)
39//! let view = ta.view();
40//! println!("{}", view);
41//! ```
42//!
43//! See the `helpers` module for key bindings and styling utilities, and
44//! `memoization` for the internal soft-wrap cache.
45
46pub mod helpers;
47pub mod memoization;
48
49#[cfg(test)]
50mod tests;
51
52use helpers::*;
53use memoization::MemoizedWrap;
54
55use crate::{cursor, viewport, Component};
56use bubbletea_rs::{Cmd, Model as BubbleTeaModel};
57use lipgloss_extras::lipgloss;
58use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
59
60// Constants matching Go implementation
61const MIN_HEIGHT: usize = 1;
62const DEFAULT_HEIGHT: usize = 6;
63const DEFAULT_WIDTH: usize = 40;
64const DEFAULT_CHAR_LIMIT: usize = 0; // no limit
65const DEFAULT_MAX_HEIGHT: usize = 99;
66const DEFAULT_MAX_WIDTH: usize = 500;
67const MAX_LINES: usize = 10000;
68
69/// Internal messages for clipboard operations
70#[derive(Debug, Clone)]
71pub struct PasteMsg(pub String);
72
73/// Error message produced when a paste operation fails.
74#[derive(Debug, Clone)]
75pub struct PasteErrMsg(pub String);
76
77/// LineInfo helper for tracking line information regarding soft-wrapped lines
78/// Direct port from Go's LineInfo struct
79#[derive(Debug, Clone, Default)]
80pub struct LineInfo {
81    /// Width is the number of columns in the line
82    pub width: usize,
83    /// CharWidth is the number of characters in the line to account for double-width runes
84    pub char_width: usize,
85    /// Height is the number of rows in the line  
86    pub height: usize,
87    /// StartColumn is the index of the first column of the line
88    pub start_column: usize,
89    /// ColumnOffset is the number of columns that the cursor is offset from the start of the line
90    pub column_offset: usize,
91    /// RowOffset is the number of rows that the cursor is offset from the start of the line
92    pub row_offset: usize,
93    /// CharOffset is the number of characters that the cursor is offset from the start of the line
94    pub char_offset: usize,
95}
96
97/// Model is the Bubble Tea model for this text area element.
98/// Direct port from Go's Model struct with all fields preserved
99#[derive(Debug)]
100pub struct Model {
101    // Error state
102    /// Optional error string surfaced by the component.
103    pub err: Option<String>,
104
105    // General settings - memoization cache
106    cache: MemoizedWrap,
107
108    // Display settings
109    /// Prompt is printed at the beginning of each line
110    pub prompt: String,
111    /// Placeholder is the text displayed when the user hasn't entered anything yet
112    pub placeholder: String,
113    /// ShowLineNumbers, if enabled, causes line numbers to be printed after the prompt
114    pub show_line_numbers: bool,
115    /// EndOfBufferCharacter is displayed at the end of the input
116    pub end_of_buffer_character: char,
117
118    // KeyMap encodes the keybindings recognized by the widget
119    /// Key bindings recognized by the widget.
120    pub key_map: TextareaKeyMap,
121
122    // Styling. FocusedStyle and BlurredStyle are used to style the textarea in focused and blurred states
123    /// Style used when the textarea is focused.
124    pub focused_style: TextareaStyle,
125    /// Style used when the textarea is blurred.
126    pub blurred_style: TextareaStyle,
127    // style is the current styling to use - pointer equivalent in Rust
128    current_style: TextareaStyle,
129
130    // Cursor is the text area cursor
131    /// Embedded cursor model for caret rendering and blinking.
132    pub cursor: cursor::Model,
133
134    // Limits
135    /// CharLimit is the maximum number of characters this input element will accept
136    pub char_limit: usize,
137    /// MaxHeight is the maximum height of the text area in rows
138    pub max_height: usize,
139    /// MaxWidth is the maximum width of the text area in columns
140    pub max_width: usize,
141
142    // Dynamic prompt function - Option to handle nullable function pointer
143    prompt_func: Option<fn(usize) -> String>,
144    /// promptWidth is the width of the prompt
145    prompt_width: usize,
146
147    // Dimensions
148    /// width is the maximum number of characters that can be displayed at once
149    width: usize,
150    /// height is the maximum number of lines that can be displayed at once
151    height: usize,
152
153    // Content - using Vec<Vec<char>> to match Go's [][]rune
154    /// Underlying text value as runes (characters)
155    value: Vec<Vec<char>>,
156
157    // State
158    /// focus indicates whether user input focus should be on this input component
159    focus: bool,
160    /// Cursor column
161    col: usize,
162    /// Cursor row  
163    row: usize,
164    /// Last character offset, used to maintain state when cursor is moved vertically
165    last_char_offset: usize,
166
167    // Viewport is the vertically-scrollable viewport of the multi-line text input
168    viewport: viewport::Model,
169}
170
171impl Model {
172    /// Create a new textarea model with default settings - port of Go's New()
173    pub fn new() -> Self {
174        let vp = viewport::Model::new(0, 0);
175        // Disable viewport key handling to let textarea handle keys (no keymap field in viewport)
176
177        let cur = cursor::Model::new();
178
179        let (focused_style, blurred_style) = default_styles();
180
181        let mut model = Self {
182            err: None,
183            cache: MemoizedWrap::new(),
184            prompt: format!("{} ", lipgloss::thick_border().left),
185            placeholder: String::new(),
186            show_line_numbers: true,
187            end_of_buffer_character: ' ',
188            key_map: TextareaKeyMap::default(),
189            focused_style: focused_style.clone(),
190            blurred_style: blurred_style.clone(),
191            current_style: blurred_style, // Start blurred
192            cursor: cur,
193            char_limit: DEFAULT_CHAR_LIMIT,
194            max_height: DEFAULT_MAX_HEIGHT,
195            max_width: DEFAULT_MAX_WIDTH,
196            prompt_func: None,
197            prompt_width: 0,
198            width: DEFAULT_WIDTH,
199            height: DEFAULT_HEIGHT,
200            value: vec![vec![]; MIN_HEIGHT],
201            focus: false,
202            col: 0,
203            row: 0,
204            last_char_offset: 0,
205            viewport: vp,
206        };
207
208        // Ensure value has minimum height and maxLines capacity
209        model.value.reserve(MAX_LINES);
210        model.set_height(DEFAULT_HEIGHT);
211        model.set_width(DEFAULT_WIDTH);
212
213        model
214    }
215
216    /// Set the value of the text input - port of Go's SetValue
217    pub fn set_value(&mut self, s: impl Into<String>) {
218        self.reset();
219        self.insert_string(s.into());
220        // After setting full value, position cursor at end of input (last line)
221        self.row = self.value.len().saturating_sub(1);
222        if let Some(line) = self.value.get(self.row) {
223            self.set_cursor(line.len());
224        }
225    }
226
227    /// Insert a string at the cursor position - port of Go's InsertString
228    pub fn insert_string(&mut self, s: impl Into<String>) {
229        let s = s.into();
230        let runes: Vec<char> = s.chars().collect();
231        self.insert_runes_from_user_input(runes);
232    }
233
234    /// Insert a rune at the cursor position - port of Go's InsertRune
235    pub fn insert_rune(&mut self, r: char) {
236        self.insert_runes_from_user_input(vec![r]);
237    }
238
239    /// Get the current value as a string - port of Go's Value()
240    pub fn value(&self) -> String {
241        if self.value.is_empty() {
242            return String::new();
243        }
244
245        let mut result = String::new();
246        for (i, line) in self.value.iter().enumerate() {
247            if i > 0 {
248                result.push('\n');
249            }
250            result.extend(line.iter());
251        }
252        result
253    }
254
255    /// Length returns the number of characters currently in the text input - port of Go's Length()
256    pub fn length(&self) -> usize {
257        let mut l = 0;
258        for row in &self.value {
259            l += row
260                .iter()
261                .map(|&ch| UnicodeWidthChar::width(ch).unwrap_or(0))
262                .sum::<usize>();
263        }
264        // Add newline characters count
265        l + self.value.len().saturating_sub(1)
266    }
267
268    /// LineCount returns the number of lines currently in the text input - port of Go's LineCount()
269    pub fn line_count(&self) -> usize {
270        self.value.len()
271    }
272
273    /// Line returns the line position - port of Go's Line()
274    pub fn line(&self) -> usize {
275        self.row
276    }
277
278    /// Focused returns the focus state on the model - port of Go's Focused()
279    pub fn focused(&self) -> bool {
280        self.focus
281    }
282
283    /// Reset sets the input to its default state with no input - port of Go's Reset()
284    pub fn reset(&mut self) {
285        self.value = vec![vec![]; MIN_HEIGHT];
286        self.value.reserve(MAX_LINES);
287        self.col = 0;
288        self.row = 0;
289        self.viewport.goto_top();
290        self.set_cursor(0);
291    }
292
293    /// Width returns the width of the textarea - port of Go's Width()
294    pub fn width(&self) -> usize {
295        self.width
296    }
297
298    /// Height returns the current height of the textarea - port of Go's Height()
299    pub fn height(&self) -> usize {
300        self.height
301    }
302
303    /// SetWidth sets the width of the textarea - port of Go's SetWidth()
304    pub fn set_width(&mut self, w: usize) {
305        // Update prompt width only if there is no prompt function
306        if self.prompt_func.is_none() {
307            self.prompt_width = self.prompt.width();
308        }
309
310        // Add base style borders and padding to reserved outer width
311        let reserved_outer = 0; // Simplified for now, lipgloss API differs
312
313        // Add prompt width to reserved inner width
314        let mut reserved_inner = self.prompt_width;
315
316        // Add line number width to reserved inner width
317        if self.show_line_numbers {
318            let ln_width = 4; // Up to 3 digits for line number plus 1 margin
319            reserved_inner += ln_width;
320        }
321
322        // Input width must be at least one more than the reserved inner and outer width
323        let min_width = reserved_inner + reserved_outer + 1;
324        let mut input_width = w.max(min_width);
325
326        // Input width must be no more than maximum width
327        if self.max_width > 0 {
328            input_width = input_width.min(self.max_width);
329        }
330
331        self.viewport.width = input_width.saturating_sub(reserved_outer);
332        self.width = input_width
333            .saturating_sub(reserved_outer)
334            .saturating_sub(reserved_inner);
335    }
336
337    /// SetHeight sets the height of the textarea - port of Go's SetHeight()
338    pub fn set_height(&mut self, h: usize) {
339        if self.max_height > 0 {
340            self.height = clamp(h, MIN_HEIGHT, self.max_height);
341            self.viewport.height = clamp(h, MIN_HEIGHT, self.max_height);
342        } else {
343            self.height = h.max(MIN_HEIGHT);
344            self.viewport.height = h.max(MIN_HEIGHT);
345        }
346    }
347
348    /// SetPromptFunc supersedes the Prompt field and sets a dynamic prompt instead
349    /// Port of Go's SetPromptFunc
350    pub fn set_prompt_func(&mut self, prompt_width: usize, func: fn(usize) -> String) {
351        self.prompt_func = Some(func);
352        self.prompt_width = prompt_width;
353    }
354
355    /// SetCursor moves the cursor to the given position - port of Go's SetCursor()
356    pub fn set_cursor(&mut self, col: usize) {
357        self.col = clamp(
358            col,
359            0,
360            self.value.get(self.row).map_or(0, |line| line.len()),
361        );
362        // Reset last char offset when moving cursor horizontally
363        self.last_char_offset = 0;
364    }
365
366    /// CursorStart moves the cursor to the start of the input field - port of Go's CursorStart()
367    pub fn cursor_start(&mut self) {
368        self.set_cursor(0);
369    }
370
371    /// CursorEnd moves the cursor to the end of the input field - port of Go's CursorEnd()
372    pub fn cursor_end(&mut self) {
373        if let Some(line) = self.value.get(self.row) {
374            self.set_cursor(line.len());
375        }
376    }
377
378    /// CursorDown moves the cursor down by one line - port of Go's CursorDown()
379    pub fn cursor_down(&mut self) {
380        let li = self.line_info();
381        let char_offset = self.last_char_offset.max(li.char_offset);
382        self.last_char_offset = char_offset;
383
384        if li.row_offset + 1 >= li.height && self.row < self.value.len().saturating_sub(1) {
385            self.row += 1;
386            self.col = 0;
387        } else {
388            // Move the cursor to the start of the next line
389            const TRAILING_SPACE: usize = 2;
390            if let Some(line) = self.value.get(self.row) {
391                self.col =
392                    (li.start_column + li.width + TRAILING_SPACE).min(line.len().saturating_sub(1));
393            }
394        }
395
396        let nli = self.line_info();
397        self.col = nli.start_column;
398
399        if nli.width == 0 {
400            return;
401        }
402
403        let mut offset = 0;
404        while offset < char_offset {
405            if self.row >= self.value.len()
406                || self.col >= self.value.get(self.row).map_or(0, |line| line.len())
407                || offset >= nli.char_width.saturating_sub(1)
408            {
409                break;
410            }
411            if let Some(line) = self.value.get(self.row) {
412                if let Some(&ch) = line.get(self.col) {
413                    offset += UnicodeWidthChar::width(ch).unwrap_or(0);
414                }
415            }
416            self.col += 1;
417        }
418    }
419
420    /// CursorUp moves the cursor up by one line - port of Go's CursorUp()
421    pub fn cursor_up(&mut self) {
422        let li = self.line_info();
423        let char_offset = self.last_char_offset.max(li.char_offset);
424        self.last_char_offset = char_offset;
425
426        if li.row_offset == 0 && self.row > 0 {
427            self.row -= 1;
428            if let Some(line) = self.value.get(self.row) {
429                self.col = line.len();
430            }
431        } else {
432            // Move the cursor to the end of the previous line
433            const TRAILING_SPACE: usize = 2;
434            self.col = li.start_column.saturating_sub(TRAILING_SPACE);
435        }
436
437        let nli = self.line_info();
438        self.col = nli.start_column;
439
440        if nli.width == 0 {
441            return;
442        }
443
444        let mut offset = 0;
445        while offset < char_offset {
446            if let Some(line) = self.value.get(self.row) {
447                if self.col >= line.len() || offset >= nli.char_width.saturating_sub(1) {
448                    break;
449                }
450                if let Some(&ch) = line.get(self.col) {
451                    offset += UnicodeWidthChar::width(ch).unwrap_or(0);
452                }
453                self.col += 1;
454            } else {
455                break;
456            }
457        }
458    }
459
460    /// Move to the beginning of input - port of Go's moveToBegin()
461    pub fn move_to_begin(&mut self) {
462        self.row = 0;
463        self.set_cursor(0);
464    }
465
466    /// Move to the end of input - port of Go's moveToEnd()
467    pub fn move_to_end(&mut self) {
468        self.row = self.value.len().saturating_sub(1);
469        if let Some(line) = self.value.get(self.row) {
470            self.set_cursor(line.len());
471        }
472    }
473
474    // Internal helper functions matching Go implementation structure
475
476    /// Port of Go's insertRunesFromUserInput
477    fn insert_runes_from_user_input(&mut self, mut runes: Vec<char>) {
478        // Clean up any special characters in the input
479        runes = self.sanitize_runes(runes);
480
481        if self.char_limit > 0 {
482            let avail_space = self.char_limit.saturating_sub(self.length());
483            if avail_space == 0 {
484                return;
485            }
486            if avail_space < runes.len() {
487                runes.truncate(avail_space);
488            }
489        }
490
491        // Split the input into lines
492        let mut lines = Vec::new();
493        let mut lstart = 0;
494
495        for (i, &r) in runes.iter().enumerate() {
496            if r == '\n' {
497                lines.push(runes[lstart..i].to_vec());
498                lstart = i + 1;
499            }
500        }
501
502        if lstart <= runes.len() {
503            lines.push(runes[lstart..].to_vec());
504        }
505
506        // Obey the maximum line limit
507        if MAX_LINES > 0 && self.value.len() + lines.len() - 1 > MAX_LINES {
508            let allowed_height = (MAX_LINES - self.value.len() + 1).max(0);
509            lines.truncate(allowed_height);
510        }
511
512        if lines.is_empty() {
513            return;
514        }
515
516        // Ensure current row exists
517        while self.row >= self.value.len() {
518            self.value.push(Vec::new());
519        }
520
521        // Save the remainder of the original line at the current cursor position
522        let tail = if self.col < self.value[self.row].len() {
523            self.value[self.row][self.col..].to_vec()
524        } else {
525            Vec::new()
526        };
527
528        // Paste the first line at the current cursor position
529        if self.col <= self.value[self.row].len() {
530            self.value[self.row].truncate(self.col);
531        }
532        self.value[self.row].extend_from_slice(&lines[0]);
533        self.col += lines[0].len();
534
535        if lines.len() > 1 {
536            // Add the new lines maintaining cursor on the first line's end
537            for (i, line) in lines[1..].iter().enumerate() {
538                self.value.insert(self.row + 1 + i, line.clone());
539            }
540            // Move cursor to end of the last inserted line (Go behavior on SetValue)
541            self.row += lines.len() - 1;
542            self.col = lines.last().map(|l| l.len()).unwrap_or(0);
543            // Append tail to current line
544            self.value[self.row].extend_from_slice(&tail);
545        } else {
546            // No newlines: append tail back to current line
547            self.value[self.row].extend_from_slice(&tail);
548        }
549
550        self.set_cursor(self.col);
551    }
552
553    /// Sanitize runes for input - simple version
554    fn sanitize_runes(&self, runes: Vec<char>) -> Vec<char> {
555        // For now, just return as-is. In Go this handles special characters
556        runes
557    }
558
559    /// LineInfo returns line information for the current cursor position
560    /// Port of Go's LineInfo()
561    pub fn line_info(&mut self) -> LineInfo {
562        if self.row >= self.value.len() {
563            return LineInfo::default();
564        }
565
566        // Clone the line to avoid borrowing issues
567        let current_line = self.value[self.row].clone();
568        let width = self.width;
569        let grid = self.cache.wrap(&current_line, width);
570
571        // Find out which visual wrap line we are currently on
572        let mut counter = 0;
573        for (i, line) in grid.iter().enumerate() {
574            // We've found the line that we are on
575            if counter + line.len() == self.col && i + 1 < grid.len() {
576                // Wrap around to the next line
577                return LineInfo {
578                    char_offset: 0,
579                    column_offset: 0,
580                    height: grid.len(),
581                    row_offset: i + 1,
582                    start_column: self.col,
583                    width: grid.get(i + 1).map_or(0, |l| l.len()),
584                    char_width: line
585                        .iter()
586                        .map(|&ch| UnicodeWidthChar::width(ch).unwrap_or(0))
587                        .sum(),
588                };
589            }
590
591            if counter + line.len() >= self.col {
592                let col_in_line = self.col.saturating_sub(counter);
593                let char_off: usize = line[..col_in_line.min(line.len())]
594                    .iter()
595                    .map(|&ch| UnicodeWidthChar::width(ch).unwrap_or(0))
596                    .sum();
597                return LineInfo {
598                    char_offset: char_off,
599                    column_offset: col_in_line, // column within current wrap line
600                    height: grid.len(),
601                    row_offset: i,
602                    start_column: counter,
603                    width: line.len(),
604                    char_width: line
605                        .iter()
606                        .map(|&ch| UnicodeWidthChar::width(ch).unwrap_or(0))
607                        .sum(),
608                };
609            }
610
611            counter += line.len();
612        }
613
614        LineInfo::default()
615    }
616
617    /// Delete before cursor - port of Go's deleteBeforeCursor()
618    pub fn delete_before_cursor(&mut self) {
619        if let Some(line) = self.value.get_mut(self.row) {
620            let tail = if self.col <= line.len() {
621                line[self.col..].to_vec()
622            } else {
623                Vec::new()
624            };
625            *line = tail;
626        }
627        self.set_cursor(0);
628    }
629
630    /// Delete after cursor - port of Go's deleteAfterCursor()
631    pub fn delete_after_cursor(&mut self) {
632        if let Some(line) = self.value.get_mut(self.row) {
633            line.truncate(self.col);
634            let line_len = line.len();
635            self.set_cursor(line_len);
636        }
637    }
638
639    /// Delete character backward - port of Go's deleteCharacterBackward()
640    pub fn delete_character_backward(&mut self) {
641        self.col = clamp(
642            self.col,
643            0,
644            self.value.get(self.row).map_or(0, |line| line.len()),
645        );
646        if self.col == 0 {
647            self.merge_line_above(self.row);
648            return;
649        }
650
651        if let Some(line) = self.value.get_mut(self.row) {
652            if !line.is_empty() && self.col > 0 {
653                line.remove(self.col - 1);
654                self.set_cursor(self.col - 1);
655            }
656        }
657    }
658
659    /// Delete character forward - port of Go's deleteCharacterForward()
660    pub fn delete_character_forward(&mut self) {
661        if let Some(line) = self.value.get_mut(self.row) {
662            if !line.is_empty() && self.col < line.len() {
663                line.remove(self.col);
664            }
665        }
666
667        if self.col >= self.value.get(self.row).map_or(0, |line| line.len()) {
668            self.merge_line_below(self.row);
669        }
670    }
671
672    /// Delete word backward - port of Go's deleteWordLeft()
673    pub fn delete_word_backward(&mut self) {
674        if self.col == 0 {
675            self.merge_line_above(self.row);
676            return;
677        }
678
679        let line = if let Some(line) = self.value.get(self.row) {
680            line.clone()
681        } else {
682            return;
683        };
684
685        if line.is_empty() {
686            return;
687        }
688
689        // Find word boundaries - Go bubbles deleteWordLeft behavior
690        let mut start = self.col;
691        let mut end = self.col;
692
693        // If we're not at the end of a word, find the end first
694        while end < line.len() && line.get(end).is_some_and(|&c| !c.is_whitespace()) {
695            end += 1;
696        }
697
698        // Find start of the word we're in or before
699        while start > 0 && line.get(start - 1).is_some_and(|&c| !c.is_whitespace()) {
700            start -= 1;
701        }
702
703        // Only include preceding space if cursor is not at end of word
704        if self.col < line.len() && line.get(self.col).is_some_and(|&c| !c.is_whitespace()) {
705            // Cursor is inside word, include preceding space
706            if start > 0 && line.get(start - 1).is_some_and(|&c| c.is_whitespace()) {
707                start -= 1;
708            }
709        }
710
711        if let Some(line_mut) = self.value.get_mut(self.row) {
712            let end_clamped = end.min(line_mut.len());
713            let start_clamped = start.min(end_clamped);
714            line_mut.drain(start_clamped..end_clamped);
715        }
716
717        self.set_cursor(start);
718    }
719
720    /// Delete word forward - port of Go's deleteWordRight()
721    pub fn delete_word_forward(&mut self) {
722        let line = if let Some(line) = self.value.get(self.row) {
723            line.clone()
724        } else {
725            return;
726        };
727
728        if self.col >= line.len() || line.is_empty() {
729            self.merge_line_below(self.row);
730            return;
731        }
732
733        let old_col = self.col;
734        let mut new_col = self.col;
735
736        // Skip whitespace
737        while new_col < line.len() {
738            if let Some(&ch) = line.get(new_col) {
739                if ch.is_whitespace() {
740                    new_col += 1;
741                } else {
742                    break;
743                }
744            } else {
745                break;
746            }
747        }
748
749        // Skip word characters
750        while new_col < line.len() {
751            if let Some(&ch) = line.get(new_col) {
752                if !ch.is_whitespace() {
753                    new_col += 1;
754                } else {
755                    break;
756                }
757            } else {
758                break;
759            }
760        }
761
762        // Delete the selected text
763        if let Some(line) = self.value.get_mut(self.row) {
764            if new_col > line.len() {
765                line.truncate(old_col);
766            } else {
767                line.drain(old_col..new_col);
768            }
769        }
770
771        self.set_cursor(old_col);
772    }
773
774    /// Merge line below - port of Go's mergeLineBelow()
775    fn merge_line_below(&mut self, row: usize) {
776        if row >= self.value.len().saturating_sub(1) {
777            return;
778        }
779
780        // Combine the two lines
781        if let Some(next_line) = self.value.get(row + 1).cloned() {
782            if let Some(current_line) = self.value.get_mut(row) {
783                current_line.extend_from_slice(&next_line);
784            }
785        }
786
787        // Remove the next line by shifting all lines up
788        self.value.remove(row + 1);
789    }
790
791    /// Merge line above - port of Go's mergeLineAbove()
792    fn merge_line_above(&mut self, row: usize) {
793        if row == 0 {
794            return;
795        }
796
797        if let Some(prev_line) = self.value.get(row - 1) {
798            self.col = prev_line.len();
799        }
800        self.row = row - 1;
801
802        // Combine the two lines
803        if let Some(current_line) = self.value.get(row).cloned() {
804            if let Some(prev_line) = self.value.get_mut(row - 1) {
805                prev_line.extend_from_slice(&current_line);
806            }
807        }
808
809        // Remove the current line
810        self.value.remove(row);
811    }
812
813    /// Split line - port of Go's splitLine()
814    fn split_line(&mut self, row: usize, col: usize) {
815        if let Some(line) = self.value.get(row) {
816            let head = line[..col].to_vec();
817            let tail = line[col..].to_vec();
818
819            // Replace current line with head
820            self.value[row] = head;
821
822            // Insert tail as new line
823            self.value.insert(row + 1, tail);
824
825            self.col = 0;
826            self.row += 1;
827        }
828    }
829
830    /// Insert newline - port of Go's InsertNewline()
831    pub fn insert_newline(&mut self) {
832        if self.max_height > 0 && self.value.len() >= self.max_height {
833            return;
834        }
835
836        self.col = clamp(
837            self.col,
838            0,
839            self.value.get(self.row).map_or(0, |line| line.len()),
840        );
841        self.split_line(self.row, self.col);
842    }
843
844    /// Move cursor one character left - port of Go's characterLeft()
845    pub fn character_left(&mut self, inside_line: bool) {
846        if self.col == 0 && self.row != 0 {
847            self.row -= 1;
848            if let Some(line) = self.value.get(self.row) {
849                self.col = line.len();
850                if !inside_line {
851                    return;
852                }
853            }
854        }
855        if self.col > 0 {
856            self.set_cursor(self.col - 1);
857        }
858    }
859
860    /// Move cursor one character right - port of Go's characterRight()
861    pub fn character_right(&mut self) {
862        if let Some(line) = self.value.get(self.row) {
863            if self.col < line.len() {
864                self.set_cursor(self.col + 1);
865            } else if self.row < self.value.len() - 1 {
866                self.row += 1;
867                self.cursor_start();
868            }
869        }
870    }
871
872    /// Move cursor one word left - port of Go's wordLeft()
873    pub fn word_left(&mut self) {
874        // Move left over any spaces
875        while self.col > 0 {
876            if let Some(line) = self.value.get(self.row) {
877                if line.get(self.col - 1).is_some_and(|c| c.is_whitespace()) {
878                    self.set_cursor(self.col - 1);
879                } else {
880                    break;
881                }
882            } else {
883                break;
884            }
885        }
886        // Then move left over the previous word
887        while self.col > 0 {
888            if let Some(line) = self.value.get(self.row) {
889                if line.get(self.col - 1).is_some_and(|c| !c.is_whitespace()) {
890                    self.set_cursor(self.col - 1);
891                } else {
892                    break;
893                }
894            } else {
895                break;
896            }
897        }
898    }
899
900    /// Move cursor one word right - port of Go's wordRight()
901    pub fn word_right(&mut self) {
902        self.do_word_right(|_, _| {});
903    }
904
905    /// Internal word right with callback - port of Go's doWordRight()  
906    fn do_word_right<F>(&mut self, mut func: F)
907    where
908        F: FnMut(usize, usize),
909    {
910        if self.row >= self.value.len() {
911            return;
912        }
913
914        let line = match self.value.get(self.row) {
915            Some(line) => line.clone(),
916            None => return,
917        };
918
919        if self.col >= line.len() {
920            return;
921        }
922
923        let mut pos = self.col;
924        let mut char_idx = 0;
925
926        // Skip any spaces at current position first
927        while pos < line.len() && line[pos].is_whitespace() {
928            pos += 1;
929        }
930
931        // Move through word characters until we reach whitespace or end
932        while pos < line.len() && !line[pos].is_whitespace() {
933            func(char_idx, pos);
934            pos += 1;
935            char_idx += 1;
936        }
937
938        // Update cursor position
939        self.set_cursor(pos);
940    }
941
942    /// Transform word to uppercase - port of Go's uppercaseRight()
943    pub fn uppercase_right(&mut self) {
944        let start_col = self.col;
945        let start_row = self.row;
946
947        // Find the word boundaries
948        self.word_right(); // Move to end of word
949        let end_col = self.col;
950
951        // Transform characters
952        if let Some(line) = self.value.get_mut(start_row) {
953            let end_idx = end_col.min(line.len());
954            if let Some(slice) = line.get_mut(start_col..end_idx) {
955                for ch in slice.iter_mut() {
956                    *ch = ch.to_uppercase().next().unwrap_or(*ch);
957                }
958            }
959        }
960    }
961
962    /// Transform word to lowercase - port of Go's lowercaseRight()  
963    pub fn lowercase_right(&mut self) {
964        let start_col = self.col;
965        let start_row = self.row;
966
967        // Find the word boundaries
968        self.word_right(); // Move to end of word
969        let end_col = self.col;
970
971        // Transform characters
972        if let Some(line) = self.value.get_mut(start_row) {
973            let end_idx = end_col.min(line.len());
974            if let Some(slice) = line.get_mut(start_col..end_idx) {
975                for ch in slice.iter_mut() {
976                    *ch = ch.to_lowercase().next().unwrap_or(*ch);
977                }
978            }
979        }
980    }
981
982    /// Transform word to title case - port of Go's capitalizeRight()
983    pub fn capitalize_right(&mut self) {
984        let start_col = self.col;
985        let start_row = self.row;
986
987        // Find the word boundaries
988        self.word_right(); // Move to end of word
989        let end_col = self.col;
990
991        // Transform characters
992        if let Some(line) = self.value.get_mut(start_row) {
993            let end_idx = end_col.min(line.len());
994            if let Some(slice) = line.get_mut(start_col..end_idx) {
995                for (i, ch) in slice.iter_mut().enumerate() {
996                    if i == 0 {
997                        *ch = ch.to_uppercase().next().unwrap_or(*ch);
998                    }
999                }
1000            }
1001        }
1002    }
1003
1004    /// Transpose characters - port of Go's transposeLeft()
1005    pub fn transpose_left(&mut self) {
1006        let row = self.row;
1007        let mut col = self.col;
1008
1009        if let Some(line) = self.value.get_mut(row) {
1010            if col == 0 || line.len() < 2 {
1011                return;
1012            }
1013
1014            if col >= line.len() {
1015                col -= 1;
1016                self.col = col;
1017            }
1018
1019            if col > 0 && col < line.len() {
1020                line.swap(col - 1, col);
1021                if col < line.len() {
1022                    self.col = col + 1;
1023                }
1024            }
1025        }
1026    }
1027
1028    /// View renders the text area - port of Go's View()
1029    pub fn view(&self) -> String {
1030        // Early return for empty placeholder case
1031        if self.value.is_empty() || (self.value.len() == 1 && self.value[0].is_empty()) {
1032            return self.placeholder_view();
1033        }
1034
1035        let mut lines = Vec::new();
1036        let style = &self.current_style;
1037
1038        // Calculate visible lines based on viewport
1039        let start_line = self.viewport.y_offset;
1040        let end_line = (start_line + self.height).min(self.value.len());
1041
1042        for (line_idx, line) in self
1043            .value
1044            .iter()
1045            .enumerate()
1046            .skip(start_line)
1047            .take(end_line - start_line)
1048        {
1049            let mut line_str = String::new();
1050
1051            // Add prompt
1052            if let Some(prompt_func) = self.prompt_func {
1053                line_str.push_str(&style.computed_prompt().render(&prompt_func(line_idx + 1)));
1054            } else {
1055                line_str.push_str(&style.computed_prompt().render(&self.prompt));
1056            }
1057
1058            // Add line number
1059            if self.show_line_numbers {
1060                let line_num = format!("{:>3} ", line_idx + 1);
1061                line_str.push_str(&style.computed_line_number().render(&line_num));
1062            }
1063
1064            // Add line content with soft wrapping
1065            let mut cache = self.cache.clone();
1066            let wrapped_lines = cache.wrap(line, self.width);
1067
1068            for (wrap_idx, wrapped_line) in wrapped_lines.iter().enumerate() {
1069                let mut display_line = line_str.clone();
1070
1071                if wrap_idx > 0 {
1072                    // Continuation line - adjust prompt/line number spacing
1073                    if self.show_line_numbers {
1074                        display_line =
1075                            format!("{}    ", style.computed_prompt().render(&self.prompt));
1076                    } else {
1077                        display_line = style.computed_prompt().render(&self.prompt);
1078                    }
1079                }
1080
1081                let wrapped_content: String = wrapped_line.iter().collect();
1082
1083                // Apply cursor line highlighting if this is the current line
1084                if line_idx == self.row {
1085                    display_line.push_str(&style.computed_cursor_line().render(&wrapped_content));
1086                } else {
1087                    display_line.push_str(&style.computed_text().render(&wrapped_content));
1088                }
1089
1090                lines.push(display_line);
1091            }
1092        }
1093
1094        // Fill remaining height with empty lines or end-of-buffer characters
1095        while lines.len() < self.height {
1096            let mut empty_line = String::new();
1097
1098            // Add prompt
1099            empty_line.push_str(&style.computed_prompt().render(&self.prompt));
1100
1101            // Add end-of-buffer character or space
1102            if self.end_of_buffer_character != ' ' {
1103                empty_line.push_str(
1104                    &style
1105                        .computed_end_of_buffer()
1106                        .render(&self.end_of_buffer_character.to_string()),
1107                );
1108            }
1109
1110            lines.push(empty_line);
1111        }
1112
1113        // Apply base style to entire view, but strip ANSI for tests
1114        let content = lines.join("\n");
1115        let styled = style.base.render(&content);
1116        lipgloss::strip_ansi(&styled)
1117    }
1118
1119    /// Render placeholder text - port of Go's placeholder view logic
1120    fn placeholder_view(&self) -> String {
1121        if self.placeholder.is_empty() {
1122            return String::new();
1123        }
1124
1125        let mut lines = Vec::new();
1126        let style = &self.current_style;
1127
1128        // Split placeholder into lines
1129        let placeholder_lines: Vec<&str> = self.placeholder.lines().collect();
1130
1131        for (line_idx, &placeholder_line) in placeholder_lines.iter().enumerate() {
1132            let mut line_str = String::new();
1133
1134            // Add prompt
1135            if let Some(prompt_func) = self.prompt_func {
1136                line_str.push_str(&style.computed_prompt().render(&prompt_func(line_idx + 1)));
1137            } else {
1138                line_str.push_str(&style.computed_prompt().render(&self.prompt));
1139            }
1140
1141            // Add line number for first line only
1142            if self.show_line_numbers {
1143                if line_idx == 0 {
1144                    line_str.push_str(&style.computed_line_number().render("  1 "));
1145                } else {
1146                    line_str.push_str(&style.computed_line_number().render("    "));
1147                }
1148            }
1149
1150            // Add placeholder content with wrapping
1151            let mut cache = self.cache.clone();
1152            let wrapped = cache.wrap(&placeholder_line.chars().collect::<Vec<_>>(), self.width);
1153
1154            for (wrap_idx, wrapped_line) in wrapped.iter().enumerate() {
1155                let mut display_line = line_str.clone();
1156
1157                if wrap_idx > 0 {
1158                    // Continuation line
1159                    if self.show_line_numbers {
1160                        display_line =
1161                            format!("{}    ", style.computed_prompt().render(&self.prompt));
1162                    } else {
1163                        display_line = style.computed_prompt().render(&self.prompt);
1164                    }
1165                }
1166
1167                let wrapped_content: String = wrapped_line.iter().collect();
1168                display_line.push_str(&style.computed_placeholder().render(&wrapped_content));
1169
1170                lines.push(display_line);
1171
1172                if lines.len() >= self.height {
1173                    break;
1174                }
1175            }
1176
1177            if lines.len() >= self.height {
1178                break;
1179            }
1180        }
1181
1182        // Fill remaining height with empty lines
1183        while lines.len() < self.height {
1184            let mut empty_line = String::new();
1185
1186            // Add prompt
1187            empty_line.push_str(&style.computed_prompt().render(&self.prompt));
1188
1189            // Add end-of-buffer character or space
1190            if self.end_of_buffer_character != ' ' {
1191                empty_line.push_str(
1192                    &style
1193                        .computed_end_of_buffer()
1194                        .render(&self.end_of_buffer_character.to_string()),
1195                );
1196            }
1197
1198            lines.push(empty_line);
1199        }
1200
1201        // Apply base style to entire view, but strip ANSI for tests
1202        let content = lines.join("\n");
1203        let styled = style.base.render(&content);
1204        lipgloss::strip_ansi(&styled)
1205    }
1206
1207    /// Scroll viewport down by n lines - for testing viewport functionality
1208    pub fn scroll_down(&mut self, lines: usize) {
1209        self.viewport.set_y_offset(self.viewport.y_offset + lines);
1210    }
1211
1212    /// Scroll viewport up by n lines - for testing viewport functionality  
1213    pub fn scroll_up(&mut self, lines: usize) {
1214        self.viewport
1215            .set_y_offset(self.viewport.y_offset.saturating_sub(lines));
1216    }
1217
1218    /// Get cursor line number for display - port of Go's cursorLineNumber()
1219    pub fn cursor_line_number(&mut self) -> usize {
1220        if self.row >= self.value.len() {
1221            return 0;
1222        }
1223
1224        // Count visual lines from all preceding document lines
1225        let mut line_count = 0;
1226        for i in 0..self.row {
1227            if let Some(line) = self.value.get(i).cloned() {
1228                let wrapped_lines = self.cache.wrap(&line, self.width);
1229                line_count += wrapped_lines.len();
1230            }
1231        }
1232
1233        // Add the row offset within the current document line
1234        line_count += self.line_info().row_offset;
1235        line_count
1236    }
1237
1238    /// Update handles incoming messages and updates the textarea state - port of Go's Update()
1239    pub fn update(&mut self, msg: Option<bubbletea_rs::Msg>) -> Option<bubbletea_rs::Cmd> {
1240        if !self.focus {
1241            return None;
1242        }
1243
1244        if let Some(msg) = msg {
1245            // Handle clipboard messages first
1246            if let Some(paste_msg) = msg.downcast_ref::<PasteMsg>() {
1247                self.insert_string(paste_msg.0.clone());
1248                return None;
1249            }
1250
1251            if let Some(_paste_err) = msg.downcast_ref::<PasteErrMsg>() {
1252                // Handle paste error - could be logged or shown to user
1253                return None;
1254            }
1255
1256            // Handle key messages
1257            if let Some(key_msg) = msg.downcast_ref::<bubbletea_rs::KeyMsg>() {
1258                return self.handle_key_msg(key_msg);
1259            }
1260
1261            // Pass other messages to cursor and viewport
1262            let cursor_cmd = self.cursor.update(&msg);
1263            let viewport_cmd = self.viewport.update(msg);
1264
1265            // Return the first available command
1266            cursor_cmd.or(viewport_cmd)
1267        } else {
1268            None
1269        }
1270    }
1271
1272    /// Handle key messages - port of Go's key handling logic
1273    fn handle_key_msg(&mut self, key_msg: &bubbletea_rs::KeyMsg) -> Option<bubbletea_rs::Cmd> {
1274        use crate::key::matches_binding;
1275
1276        // Character movement
1277        if matches_binding(key_msg, &self.key_map.character_forward) {
1278            self.character_right();
1279        } else if matches_binding(key_msg, &self.key_map.character_backward) {
1280            self.character_left(false);
1281
1282        // Word movement
1283        } else if matches_binding(key_msg, &self.key_map.word_forward) {
1284            self.word_right();
1285        } else if matches_binding(key_msg, &self.key_map.word_backward) {
1286            self.word_left();
1287
1288        // Line movement
1289        } else if matches_binding(key_msg, &self.key_map.line_next) {
1290            self.cursor_down();
1291        } else if matches_binding(key_msg, &self.key_map.line_previous) {
1292            self.cursor_up();
1293        } else if matches_binding(key_msg, &self.key_map.line_start) {
1294            self.cursor_start();
1295        } else if matches_binding(key_msg, &self.key_map.line_end) {
1296            self.cursor_end();
1297
1298        // Input navigation
1299        } else if matches_binding(key_msg, &self.key_map.input_begin) {
1300            self.move_to_begin();
1301        } else if matches_binding(key_msg, &self.key_map.input_end) {
1302            self.move_to_end();
1303
1304        // Deletion
1305        } else if matches_binding(key_msg, &self.key_map.delete_character_backward) {
1306            self.delete_character_backward();
1307        } else if matches_binding(key_msg, &self.key_map.delete_character_forward) {
1308            self.delete_character_forward();
1309        } else if matches_binding(key_msg, &self.key_map.delete_word_backward) {
1310            self.delete_word_backward();
1311        } else if matches_binding(key_msg, &self.key_map.delete_word_forward) {
1312            self.delete_word_forward();
1313        } else if matches_binding(key_msg, &self.key_map.delete_after_cursor) {
1314            self.delete_after_cursor();
1315        } else if matches_binding(key_msg, &self.key_map.delete_before_cursor) {
1316            self.delete_before_cursor();
1317
1318        // Text insertion
1319        } else if matches_binding(key_msg, &self.key_map.insert_newline) {
1320            self.insert_newline();
1321
1322        // Clipboard operations
1323        } else if matches_binding(key_msg, &self.key_map.paste) {
1324            return Some(self.paste_command());
1325
1326        // Advanced text operations
1327        } else if matches_binding(key_msg, &self.key_map.uppercase_word_forward) {
1328            self.uppercase_right();
1329        } else if matches_binding(key_msg, &self.key_map.lowercase_word_forward) {
1330            self.lowercase_right();
1331        } else if matches_binding(key_msg, &self.key_map.capitalize_word_forward) {
1332            self.capitalize_right();
1333        } else if matches_binding(key_msg, &self.key_map.transpose_character_backward) {
1334            self.transpose_left();
1335        } else {
1336            // Handle regular character input
1337            if let Some(ch) = self.extract_character_from_key_msg(key_msg) {
1338                if ch.is_control() {
1339                    // Ignore control characters that aren't handled above
1340                    return None;
1341                }
1342                self.insert_rune(ch);
1343            }
1344        }
1345
1346        None
1347    }
1348
1349    /// Extract character from key message for regular text input
1350    fn extract_character_from_key_msg(&self, _key_msg: &bubbletea_rs::KeyMsg) -> Option<char> {
1351        // This would depend on the actual KeyMsg structure in bubbletea_rs
1352        // For now, we'll return None as a placeholder
1353        // In a real implementation, this would extract the character from the key event
1354        None
1355    }
1356
1357    /// Create paste command for clipboard integration
1358    fn paste_command(&self) -> bubbletea_rs::Cmd {
1359        bubbletea_rs::tick(
1360            std::time::Duration::from_nanos(1),
1361            |_| match Self::read_clipboard() {
1362                Ok(content) => Box::new(PasteMsg(content)) as bubbletea_rs::Msg,
1363                Err(err) => Box::new(PasteErrMsg(err)) as bubbletea_rs::Msg,
1364            },
1365        )
1366    }
1367
1368    /// Read from system clipboard
1369    fn read_clipboard() -> Result<String, String> {
1370        #[cfg(feature = "clipboard-support")]
1371        {
1372            use clipboard::{ClipboardContext, ClipboardProvider};
1373
1374            let mut ctx: ClipboardContext = ClipboardProvider::new()
1375                .map_err(|e| format!("Failed to create clipboard context: {}", e))?;
1376
1377            ctx.get_contents()
1378                .map_err(|e| format!("Failed to read clipboard: {}", e))
1379        }
1380        #[cfg(not(feature = "clipboard-support"))]
1381        {
1382            Err("Clipboard support not enabled".to_string())
1383        }
1384    }
1385
1386    /// Copy text to system clipboard
1387    pub fn copy_to_clipboard(&self, text: &str) -> Result<(), String> {
1388        #[cfg(feature = "clipboard-support")]
1389        {
1390            use clipboard::{ClipboardContext, ClipboardProvider};
1391
1392            let mut ctx: ClipboardContext = ClipboardProvider::new()
1393                .map_err(|e| format!("Failed to create clipboard context: {}", e))?;
1394
1395            ctx.set_contents(text.to_string())
1396                .map_err(|e| format!("Failed to write to clipboard: {}", e))
1397        }
1398        #[cfg(not(feature = "clipboard-support"))]
1399        {
1400            let _ = text; // Suppress unused parameter warning
1401            Err("Clipboard support not enabled".to_string())
1402        }
1403    }
1404
1405    /// Copy current selection to clipboard (if selection is implemented)
1406    pub fn copy_selection(&self) -> Result<(), String> {
1407        // For now, copy entire content
1408        // In a full implementation, this would copy only selected text
1409        let content = self.value();
1410        self.copy_to_clipboard(&content)
1411    }
1412
1413    /// Cut current selection to clipboard (if selection is implemented)
1414    pub fn cut_selection(&mut self) -> Result<(), String> {
1415        // For now, cut entire content
1416        // In a full implementation, this would cut only selected text
1417        let content = self.value();
1418        self.copy_to_clipboard(&content)?;
1419        self.reset();
1420        Ok(())
1421    }
1422}
1423
1424impl Default for Model {
1425    fn default() -> Self {
1426        Self::new()
1427    }
1428}
1429
1430/// Focus sets the focus state on the model - port of Go's Focus()
1431impl Component for Model {
1432    fn focus(&mut self) -> Option<Cmd> {
1433        self.focus = true;
1434        self.current_style = self.focused_style.clone();
1435        self.cursor.focus()
1436    }
1437
1438    fn blur(&mut self) {
1439        self.focus = false;
1440        self.current_style = self.blurred_style.clone();
1441        self.cursor.blur();
1442    }
1443
1444    fn focused(&self) -> bool {
1445        self.focus
1446    }
1447}
1448
1449/// Default styles matching Go's DefaultStyles() function
1450pub fn default_styles() -> (TextareaStyle, TextareaStyle) {
1451    let focused = default_focused_style();
1452    let blurred = default_blurred_style();
1453    (focused, blurred)
1454}
1455
1456/// Create a new textarea model - convenience function
1457pub fn new() -> Model {
1458    Model::new()
1459}