Skip to main content

saorsa_core/widget/
text_area.rs

1//! Multi-line text editing widget with cursor, selection, soft wrap,
2//! line numbers, and syntax highlighting.
3
4use crate::buffer::ScreenBuffer;
5use crate::cell::Cell;
6use crate::cursor::{CursorPosition, CursorState, Selection};
7use crate::event::{Event, KeyCode, KeyEvent, Modifiers};
8use crate::geometry::Rect;
9use crate::highlight::{Highlighter, NoHighlighter};
10use crate::style::Style;
11use crate::text_buffer::TextBuffer;
12use crate::undo::{EditOperation, UndoStack};
13use crate::wrap::wrap_line;
14use unicode_width::UnicodeWidthChar;
15
16use super::{EventResult, InteractiveWidget, Widget};
17
18/// A multi-line text editing widget.
19///
20/// Supports cursor movement, text selection, undo/redo, soft wrapping,
21/// optional line numbers, and pluggable syntax highlighting.
22pub struct TextArea {
23    /// The text content.
24    pub buffer: TextBuffer,
25    /// Cursor and selection state.
26    pub cursor: CursorState,
27    /// Undo/redo history.
28    pub undo_stack: UndoStack,
29    highlighter: Box<dyn Highlighter>,
30    /// Index of the first visible logical line.
31    pub scroll_offset: usize,
32    /// Whether to show line numbers in the left gutter.
33    pub show_line_numbers: bool,
34    /// Base text style.
35    pub style: Style,
36    /// Style for the cursor cell.
37    pub cursor_style: Style,
38    /// Style for selected text.
39    pub selection_style: Style,
40    /// Style for line numbers.
41    pub line_number_style: Style,
42}
43
44impl TextArea {
45    /// Create a new empty text area.
46    pub fn new() -> Self {
47        Self {
48            buffer: TextBuffer::new(),
49            cursor: CursorState::new(0, 0),
50            undo_stack: UndoStack::new(1000),
51            highlighter: Box::new(NoHighlighter),
52            scroll_offset: 0,
53            show_line_numbers: false,
54            style: Style::default(),
55            cursor_style: Style::new().reverse(true),
56            selection_style: Style::new().reverse(true),
57            line_number_style: Style::new().dim(true),
58        }
59    }
60
61    /// Create a text area pre-filled with text.
62    pub fn from_text(text: &str) -> Self {
63        let mut ta = Self::new();
64        ta.buffer = TextBuffer::from_text(text);
65        ta
66    }
67
68    /// Set a custom syntax highlighter.
69    #[must_use]
70    pub fn with_highlighter(mut self, h: Box<dyn Highlighter>) -> Self {
71        self.highlighter = h;
72        self
73    }
74
75    /// Set the base text style.
76    #[must_use]
77    pub fn with_style(mut self, s: Style) -> Self {
78        self.style = s;
79        self
80    }
81
82    /// Enable or disable line numbers.
83    #[must_use]
84    pub fn with_line_numbers(mut self, show: bool) -> Self {
85        self.show_line_numbers = show;
86        self
87    }
88
89    /// Set the cursor display style.
90    #[must_use]
91    pub fn with_cursor_style(mut self, s: Style) -> Self {
92        self.cursor_style = s;
93        self
94    }
95
96    /// Set the selection display style.
97    #[must_use]
98    pub fn with_selection_style(mut self, s: Style) -> Self {
99        self.selection_style = s;
100        self
101    }
102
103    /// Get the current text content as a string.
104    pub fn text(&self) -> String {
105        self.buffer.to_string()
106    }
107
108    // --- Editing operations ---
109
110    /// Insert a character at the cursor position.
111    pub fn insert_char(&mut self, ch: char) {
112        self.delete_selection_if_active();
113        let pos = self.cursor.position;
114        self.buffer.insert_char(pos.line, pos.col, ch);
115        self.undo_stack.push(EditOperation::Insert {
116            pos,
117            text: ch.to_string(),
118        });
119        self.highlighter.on_edit(pos.line);
120        // Advance cursor
121        if ch == '\n' {
122            self.cursor.position.line += 1;
123            self.cursor.position.col = 0;
124        } else {
125            self.cursor.position.col += 1;
126        }
127        self.cursor.preferred_col = None;
128    }
129
130    /// Insert a string at the cursor position.
131    pub fn insert_str(&mut self, text: &str) {
132        self.delete_selection_if_active();
133        let pos = self.cursor.position;
134        self.buffer.insert_str(pos.line, pos.col, text);
135        self.undo_stack.push(EditOperation::Insert {
136            pos,
137            text: text.to_string(),
138        });
139        self.highlighter.on_edit(pos.line);
140
141        // Advance cursor past inserted text
142        for ch in text.chars() {
143            if ch == '\n' {
144                self.cursor.position.line += 1;
145                self.cursor.position.col = 0;
146            } else {
147                self.cursor.position.col += 1;
148            }
149        }
150        self.cursor.preferred_col = None;
151    }
152
153    /// Delete the character before the cursor (backspace).
154    pub fn delete_backward(&mut self) {
155        if self.delete_selection_if_active() {
156            return;
157        }
158        let pos = self.cursor.position;
159        if pos.col > 0 {
160            // Delete within line
161            let del_col = pos.col - 1;
162            if let Some(line_text) = self.buffer.line(pos.line) {
163                let deleted: String = line_text
164                    .chars()
165                    .nth(del_col)
166                    .map(String::from)
167                    .unwrap_or_default();
168                self.buffer.delete_char(pos.line, del_col);
169                self.undo_stack.push(EditOperation::Delete {
170                    pos: CursorPosition::new(pos.line, del_col),
171                    text: deleted,
172                });
173                self.highlighter.on_edit(pos.line);
174                self.cursor.position.col -= 1;
175            }
176        } else if pos.line > 0 {
177            // Join with previous line
178            let prev_line_len = self.buffer.line_len(pos.line - 1).unwrap_or(0);
179            self.buffer.delete_char(pos.line - 1, prev_line_len);
180            self.undo_stack.push(EditOperation::Delete {
181                pos: CursorPosition::new(pos.line - 1, prev_line_len),
182                text: "\n".to_string(),
183            });
184            self.highlighter.on_edit(pos.line - 1);
185            self.cursor.position.line -= 1;
186            self.cursor.position.col = prev_line_len;
187        }
188        self.cursor.preferred_col = None;
189    }
190
191    /// Delete the character at the cursor position (delete key).
192    pub fn delete_forward(&mut self) {
193        if self.delete_selection_if_active() {
194            return;
195        }
196        let pos = self.cursor.position;
197        let line_len = self.buffer.line_len(pos.line).unwrap_or(0);
198        if pos.col < line_len {
199            if let Some(line_text) = self.buffer.line(pos.line) {
200                let deleted: String = line_text
201                    .chars()
202                    .nth(pos.col)
203                    .map(String::from)
204                    .unwrap_or_default();
205                self.buffer.delete_char(pos.line, pos.col);
206                self.undo_stack
207                    .push(EditOperation::Delete { pos, text: deleted });
208                self.highlighter.on_edit(pos.line);
209            }
210        } else if pos.line + 1 < self.buffer.line_count() {
211            // Join with next line
212            self.buffer.delete_char(pos.line, pos.col);
213            self.undo_stack.push(EditOperation::Delete {
214                pos,
215                text: "\n".to_string(),
216            });
217            self.highlighter.on_edit(pos.line);
218        }
219    }
220
221    /// Delete the currently selected text, if any.
222    ///
223    /// Returns `true` if a selection was deleted.
224    pub fn delete_selection(&mut self) -> bool {
225        self.delete_selection_if_active()
226    }
227
228    /// Insert a newline at the cursor position.
229    pub fn new_line(&mut self) {
230        self.insert_char('\n');
231    }
232
233    /// Undo the last operation.
234    pub fn undo(&mut self) {
235        if let Some(op) = self.undo_stack.undo() {
236            self.apply_operation(&op);
237        }
238    }
239
240    /// Redo the last undone operation.
241    pub fn redo(&mut self) {
242        if let Some(op) = self.undo_stack.redo() {
243            self.apply_operation(&op);
244        }
245    }
246
247    /// Ensure the cursor is within the visible area, adjusting scroll.
248    pub fn ensure_cursor_visible(&mut self, area_height: u16) {
249        let height = area_height as usize;
250        if height == 0 {
251            return;
252        }
253        let line = self.cursor.position.line;
254        if line < self.scroll_offset {
255            self.scroll_offset = line;
256        } else if line >= self.scroll_offset + height {
257            self.scroll_offset = line.saturating_sub(height - 1);
258        }
259    }
260
261    // --- Private helpers ---
262
263    /// Delete the active selection text and place cursor at start.
264    fn delete_selection_if_active(&mut self) -> bool {
265        let sel = match self.cursor.selection.take() {
266            Some(s) if !s.is_empty() => s,
267            other => {
268                self.cursor.selection = other;
269                return false;
270            }
271        };
272
273        let (start, end) = sel.ordered();
274        if let Some(selected) = self.selected_text_for(&sel) {
275            self.buffer
276                .delete_range(start.line, start.col, end.line, end.col);
277            self.undo_stack.push(EditOperation::Delete {
278                pos: start,
279                text: selected,
280            });
281            self.highlighter.on_edit(start.line);
282            self.cursor.position = start;
283            self.cursor.preferred_col = None;
284        }
285        true
286    }
287
288    /// Get text for a selection (without clearing it).
289    fn selected_text_for(&self, sel: &Selection) -> Option<String> {
290        if sel.is_empty() {
291            return None;
292        }
293        let (start, end) = sel.ordered();
294        let mut result = String::new();
295        for line_idx in start.line..=end.line {
296            if let Some(line_text) = self.buffer.line(line_idx) {
297                let ls = if line_idx == start.line { start.col } else { 0 };
298                let le = if line_idx == end.line {
299                    end.col.min(line_text.chars().count())
300                } else {
301                    line_text.chars().count()
302                };
303                let chars: String = line_text
304                    .chars()
305                    .skip(ls)
306                    .take(le.saturating_sub(ls))
307                    .collect();
308                result.push_str(&chars);
309                if line_idx < end.line {
310                    result.push('\n');
311                }
312            }
313        }
314        if result.is_empty() {
315            None
316        } else {
317            Some(result)
318        }
319    }
320
321    /// Apply an edit operation (for undo/redo).
322    fn apply_operation(&mut self, op: &EditOperation) {
323        match op {
324            EditOperation::Insert { pos, text } => {
325                self.buffer.insert_str(pos.line, pos.col, text);
326                // Move cursor to end of inserted text
327                let mut line = pos.line;
328                let mut col = pos.col;
329                for ch in text.chars() {
330                    if ch == '\n' {
331                        line += 1;
332                        col = 0;
333                    } else {
334                        col += 1;
335                    }
336                }
337                self.cursor.position = CursorPosition::new(line, col);
338            }
339            EditOperation::Delete { pos, text } => {
340                // Calculate end position
341                let mut end_line = pos.line;
342                let mut end_col = pos.col;
343                for ch in text.chars() {
344                    if ch == '\n' {
345                        end_line += 1;
346                        end_col = 0;
347                    } else {
348                        end_col += 1;
349                    }
350                }
351                self.buffer
352                    .delete_range(pos.line, pos.col, end_line, end_col);
353                self.cursor.position = *pos;
354            }
355            EditOperation::Replace {
356                pos,
357                old_text,
358                new_text,
359            } => {
360                // Delete old text, insert new
361                let mut end_line = pos.line;
362                let mut end_col = pos.col;
363                for ch in old_text.chars() {
364                    if ch == '\n' {
365                        end_line += 1;
366                        end_col = 0;
367                    } else {
368                        end_col += 1;
369                    }
370                }
371                self.buffer
372                    .delete_range(pos.line, pos.col, end_line, end_col);
373                self.buffer.insert_str(pos.line, pos.col, new_text);
374                self.cursor.position = *pos;
375            }
376        }
377        self.cursor.preferred_col = None;
378    }
379}
380
381impl Default for TextArea {
382    fn default() -> Self {
383        Self::new()
384    }
385}
386
387impl Widget for TextArea {
388    fn render(&self, area: Rect, buf: &mut ScreenBuffer) {
389        if area.size.width == 0 || area.size.height == 0 {
390            return;
391        }
392
393        let height = area.size.height as usize;
394        let total_width = area.size.width as usize;
395
396        // Calculate gutter width for line numbers
397        let gutter_width = if self.show_line_numbers {
398            let digits = crate::wrap::line_number_width(self.buffer.line_count()) as usize;
399            digits + 1 // extra space after number
400        } else {
401            0
402        };
403
404        let text_width = total_width.saturating_sub(gutter_width);
405        if text_width == 0 {
406            return;
407        }
408
409        // Render visible lines
410        let mut row: usize = 0;
411        let mut logical_line = self.scroll_offset;
412
413        while row < height && logical_line < self.buffer.line_count() {
414            let line_text = self.buffer.line(logical_line).unwrap_or_default();
415
416            // Get highlight spans for this line
417            let spans = self.highlighter.highlight_line(logical_line, &line_text);
418
419            // Soft wrap the line
420            let wrapped = wrap_line(&line_text, text_width);
421
422            for (wrap_idx, (visual_text, start_col)) in wrapped.iter().enumerate() {
423                if row >= height {
424                    break;
425                }
426
427                let y = area.position.y + row as u16;
428
429                // Render line number (only for first visual line of each logical line)
430                if self.show_line_numbers {
431                    if wrap_idx == 0 {
432                        let num_str = format!("{}", logical_line + 1);
433                        let padded = format!("{:>width$} ", num_str, width = gutter_width - 1);
434                        for (i, ch) in padded.chars().enumerate() {
435                            let x = area.position.x + i as u16;
436                            if x < area.position.x + area.size.width {
437                                buf.set(
438                                    x,
439                                    y,
440                                    Cell::new(ch.to_string(), self.line_number_style.clone()),
441                                );
442                            }
443                        }
444                    } else {
445                        // Blank gutter for continuation lines
446                        for i in 0..gutter_width {
447                            let x = area.position.x + i as u16;
448                            if x < area.position.x + area.size.width {
449                                buf.set(
450                                    x,
451                                    y,
452                                    Cell::new(" ".to_string(), self.line_number_style.clone()),
453                                );
454                            }
455                        }
456                    }
457                }
458
459                // Render text content
460                let gutter_x = area.position.x + gutter_width as u16;
461                let mut col_offset: usize = 0;
462
463                for (char_idx, ch) in visual_text.chars().enumerate() {
464                    let buffer_col = start_col + char_idx;
465                    let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
466                    let x = gutter_x + col_offset as u16;
467
468                    if x >= area.position.x + area.size.width {
469                        break;
470                    }
471
472                    // Determine style: selection > highlight > base
473                    let char_style = self.resolve_style(logical_line, buffer_col, &spans);
474
475                    buf.set(x, y, Cell::new(ch.to_string(), char_style));
476                    col_offset += ch_width;
477                }
478
479                // Render cursor
480                if logical_line == self.cursor.position.line {
481                    let col = self.cursor.position.col;
482                    let end_col = start_col + visual_text.chars().count();
483                    let is_last_wrap = wrap_idx == wrapped.len() - 1;
484                    let cursor_in_wrap = col >= *start_col && (col < end_col || is_last_wrap);
485
486                    if cursor_in_wrap {
487                        let cursor_visual_col = self.cursor.position.col - start_col;
488                        // Calculate display offset for cursor
489                        let cursor_x_offset: usize = visual_text
490                            .chars()
491                            .take(cursor_visual_col)
492                            .map(|c| UnicodeWidthChar::width(c).unwrap_or(0))
493                            .sum();
494                        let cursor_x = gutter_x + cursor_x_offset as u16;
495                        if cursor_x < area.position.x + area.size.width {
496                            let cursor_ch = visual_text
497                                .chars()
498                                .nth(cursor_visual_col)
499                                .map(|c| c.to_string())
500                                .unwrap_or_else(|| " ".to_string());
501                            buf.set(cursor_x, y, Cell::new(cursor_ch, self.cursor_style.clone()));
502                        }
503                    }
504                }
505
506                row += 1;
507            }
508
509            logical_line += 1;
510        }
511    }
512}
513
514impl TextArea {
515    /// Resolve the style for a character at (line, col).
516    fn resolve_style(
517        &self,
518        line: usize,
519        col: usize,
520        spans: &[crate::highlight::HighlightSpan],
521    ) -> Style {
522        let pos = CursorPosition::new(line, col);
523
524        // Selection takes priority
525        if let Some(ref sel) = self.cursor.selection
526            && sel.contains(pos)
527        {
528            return self.selection_style.clone();
529        }
530
531        // Check highlight spans
532        for span in spans {
533            if col >= span.start_col && col < span.end_col {
534                return span.style.clone();
535            }
536        }
537
538        // Base style
539        self.style.clone()
540    }
541}
542
543impl InteractiveWidget for TextArea {
544    fn handle_event(&mut self, event: &Event) -> EventResult {
545        match event {
546            Event::Key(key_event) => self.handle_key(key_event),
547            _ => EventResult::Ignored,
548        }
549    }
550}
551
552impl TextArea {
553    /// Handle a key event.
554    fn handle_key(&mut self, key: &KeyEvent) -> EventResult {
555        let shift = key.modifiers.contains(Modifiers::SHIFT);
556        let ctrl = key.modifiers.contains(Modifiers::CTRL);
557
558        match key.code {
559            KeyCode::Left => {
560                if shift {
561                    if self.cursor.selection.is_none() {
562                        self.cursor.start_selection();
563                    }
564                    self.cursor.position = self.move_left_pos();
565                    self.cursor.extend_selection();
566                } else {
567                    self.cursor.move_left(&self.buffer);
568                }
569                EventResult::Consumed
570            }
571            KeyCode::Right => {
572                if shift {
573                    if self.cursor.selection.is_none() {
574                        self.cursor.start_selection();
575                    }
576                    self.cursor.position = self.move_right_pos();
577                    self.cursor.extend_selection();
578                } else {
579                    self.cursor.move_right(&self.buffer);
580                }
581                EventResult::Consumed
582            }
583            KeyCode::Up => {
584                if shift {
585                    if self.cursor.selection.is_none() {
586                        self.cursor.start_selection();
587                    }
588                    self.move_up_no_clear();
589                    self.cursor.extend_selection();
590                } else {
591                    self.cursor.move_up(&self.buffer);
592                }
593                EventResult::Consumed
594            }
595            KeyCode::Down => {
596                if shift {
597                    if self.cursor.selection.is_none() {
598                        self.cursor.start_selection();
599                    }
600                    self.move_down_no_clear();
601                    self.cursor.extend_selection();
602                } else {
603                    self.cursor.move_down(&self.buffer);
604                }
605                EventResult::Consumed
606            }
607            KeyCode::Home => {
608                if ctrl {
609                    self.cursor.move_to_buffer_start();
610                } else {
611                    self.cursor.move_to_line_start();
612                }
613                EventResult::Consumed
614            }
615            KeyCode::End => {
616                if ctrl {
617                    self.cursor.move_to_buffer_end(&self.buffer);
618                } else {
619                    self.cursor.move_to_line_end(&self.buffer);
620                }
621                EventResult::Consumed
622            }
623            KeyCode::Backspace => {
624                self.delete_backward();
625                EventResult::Consumed
626            }
627            KeyCode::Delete => {
628                self.delete_forward();
629                EventResult::Consumed
630            }
631            KeyCode::Enter => {
632                self.new_line();
633                EventResult::Consumed
634            }
635            KeyCode::Char(ch) => {
636                if ctrl && ch == 'z' {
637                    self.undo();
638                } else if ctrl && ch == 'y' {
639                    self.redo();
640                } else if !ctrl {
641                    self.insert_char(ch);
642                } else {
643                    return EventResult::Ignored;
644                }
645                EventResult::Consumed
646            }
647            _ => EventResult::Ignored,
648        }
649    }
650
651    /// Move cursor left without clearing selection.
652    fn move_left_pos(&self) -> CursorPosition {
653        let mut pos = self.cursor.position;
654        if pos.col > 0 {
655            pos.col -= 1;
656        } else if pos.line > 0 {
657            pos.line -= 1;
658            pos.col = self.buffer.line_len(pos.line).unwrap_or(0);
659        }
660        pos
661    }
662
663    /// Move cursor right without clearing selection.
664    fn move_right_pos(&self) -> CursorPosition {
665        let mut pos = self.cursor.position;
666        let line_len = self.buffer.line_len(pos.line).unwrap_or(0);
667        if pos.col < line_len {
668            pos.col += 1;
669        } else if pos.line + 1 < self.buffer.line_count() {
670            pos.line += 1;
671            pos.col = 0;
672        }
673        pos
674    }
675
676    /// Move cursor up without clearing selection.
677    fn move_up_no_clear(&mut self) {
678        if self.cursor.position.line > 0 {
679            let target_col = self
680                .cursor
681                .preferred_col
682                .unwrap_or(self.cursor.position.col);
683            self.cursor.preferred_col = Some(target_col);
684            self.cursor.position.line -= 1;
685            let line_len = self.buffer.line_len(self.cursor.position.line).unwrap_or(0);
686            self.cursor.position.col = target_col.min(line_len);
687        }
688    }
689
690    /// Move cursor down without clearing selection.
691    fn move_down_no_clear(&mut self) {
692        if self.cursor.position.line + 1 < self.buffer.line_count() {
693            let target_col = self
694                .cursor
695                .preferred_col
696                .unwrap_or(self.cursor.position.col);
697            self.cursor.preferred_col = Some(target_col);
698            self.cursor.position.line += 1;
699            let line_len = self.buffer.line_len(self.cursor.position.line).unwrap_or(0);
700            self.cursor.position.col = target_col.min(line_len);
701        }
702    }
703}
704
705#[cfg(test)]
706mod tests {
707    use super::*;
708    use crate::geometry::Size;
709
710    // --- Task 6: Rendering ---
711
712    #[test]
713    fn empty_textarea_renders() {
714        let ta = TextArea::new();
715        let mut buf = ScreenBuffer::new(Size::new(20, 5));
716        ta.render(Rect::new(0, 0, 20, 5), &mut buf);
717        // Should not crash
718    }
719
720    #[test]
721    fn text_renders_correctly() {
722        let ta = TextArea::from_text("hello");
723        let mut buf = ScreenBuffer::new(Size::new(20, 5));
724        ta.render(Rect::new(0, 0, 20, 5), &mut buf);
725        assert!(buf.get(0, 0).map(|c| c.grapheme.as_str()) == Some("h"));
726        assert!(buf.get(4, 0).map(|c| c.grapheme.as_str()) == Some("o"));
727    }
728
729    #[test]
730    fn line_numbers_displayed() {
731        let ta = TextArea::from_text("line1\nline2\nline3").with_line_numbers(true);
732        let mut buf = ScreenBuffer::new(Size::new(20, 5));
733        ta.render(Rect::new(0, 0, 20, 5), &mut buf);
734        // Line number "1" should be in the gutter
735        assert!(buf.get(0, 0).map(|c| c.grapheme.as_str()) == Some("1"));
736    }
737
738    #[test]
739    fn soft_wrap_splits_long_line() {
740        let ta = TextArea::from_text("abcdefghij");
741        let mut buf = ScreenBuffer::new(Size::new(5, 5));
742        ta.render(Rect::new(0, 0, 5, 5), &mut buf);
743        // First row: "abcde"
744        assert!(buf.get(0, 0).map(|c| c.grapheme.as_str()) == Some("a"));
745        assert!(buf.get(4, 0).map(|c| c.grapheme.as_str()) == Some("e"));
746        // Second row: "fghij"
747        assert!(buf.get(0, 1).map(|c| c.grapheme.as_str()) == Some("f"));
748    }
749
750    #[test]
751    fn cursor_visible_at_position() {
752        let ta = TextArea::from_text("hello");
753        let mut buf = ScreenBuffer::new(Size::new(20, 5));
754        ta.render(Rect::new(0, 0, 20, 5), &mut buf);
755        // Cursor at (0,0) should have cursor_style (reverse)
756        let cell = buf.get(0, 0);
757        assert!(cell.is_some());
758        assert!(cell.map(|c| c.style.reverse) == Some(true));
759    }
760
761    #[test]
762    fn scroll_offset_hides_top_lines() {
763        let mut ta = TextArea::from_text("line1\nline2\nline3\nline4");
764        ta.scroll_offset = 2;
765        let mut buf = ScreenBuffer::new(Size::new(20, 2));
766        ta.render(Rect::new(0, 0, 20, 2), &mut buf);
767        // Should show line3 and line4
768        assert!(buf.get(0, 0).map(|c| c.grapheme.as_str()) == Some("l"));
769        assert!(buf.get(4, 0).map(|c| c.grapheme.as_str()) == Some("3"));
770    }
771
772    // --- Task 7: Editing ---
773
774    #[test]
775    fn insert_char_updates_buffer_and_cursor() {
776        let mut ta = TextArea::new();
777        ta.insert_char('a');
778        assert!(ta.text() == "a");
779        assert!(ta.cursor.position.col == 1);
780    }
781
782    #[test]
783    fn insert_at_middle_of_line() {
784        let mut ta = TextArea::from_text("ac");
785        ta.cursor.position.col = 1;
786        ta.insert_char('b');
787        assert!(ta.text() == "abc");
788    }
789
790    #[test]
791    fn backspace_at_start_joins_lines() {
792        let mut ta = TextArea::from_text("ab\ncd");
793        ta.cursor.position = CursorPosition::new(1, 0);
794        ta.delete_backward();
795        assert!(ta.text() == "abcd");
796        assert!(ta.cursor.position.line == 0);
797        assert!(ta.cursor.position.col == 2);
798    }
799
800    #[test]
801    fn delete_at_end_joins_lines() {
802        let mut ta = TextArea::from_text("ab\ncd");
803        ta.cursor.position = CursorPosition::new(0, 2);
804        ta.delete_forward();
805        assert!(ta.text() == "abcd");
806    }
807
808    #[test]
809    fn undo_reverses_insert() {
810        let mut ta = TextArea::new();
811        ta.insert_char('x');
812        assert!(ta.text() == "x");
813        ta.undo();
814        assert!(ta.text().is_empty());
815    }
816
817    #[test]
818    fn redo_reapplies() {
819        let mut ta = TextArea::new();
820        ta.insert_char('x');
821        ta.undo();
822        ta.redo();
823        assert!(ta.text() == "x");
824    }
825
826    #[test]
827    fn selection_delete_removes_text() {
828        let mut ta = TextArea::from_text("hello world");
829        ta.cursor.selection = Some(Selection::new(
830            CursorPosition::new(0, 5),
831            CursorPosition::new(0, 11),
832        ));
833        ta.cursor.position = CursorPosition::new(0, 11);
834        let deleted = ta.delete_selection();
835        assert!(deleted);
836        assert!(ta.text() == "hello");
837    }
838
839    #[test]
840    fn enter_splits_line() {
841        let mut ta = TextArea::from_text("helloworld");
842        ta.cursor.position.col = 5;
843        ta.new_line();
844        assert!(ta.buffer.line_count() == 2);
845        match ta.buffer.line(0) {
846            Some(ref s) if s == "hello" => {}
847            other => unreachable!("expected 'hello', got {other:?}"),
848        }
849    }
850
851    #[test]
852    fn ensure_cursor_visible_scrolls_down() {
853        let mut ta = TextArea::from_text("a\nb\nc\nd\ne\nf");
854        ta.cursor.position = CursorPosition::new(5, 0);
855        ta.ensure_cursor_visible(3);
856        assert!(ta.scroll_offset == 3);
857    }
858
859    #[test]
860    fn ensure_cursor_visible_scrolls_up() {
861        let mut ta = TextArea::from_text("a\nb\nc\nd\ne\nf");
862        ta.scroll_offset = 4;
863        ta.cursor.position = CursorPosition::new(1, 0);
864        ta.ensure_cursor_visible(3);
865        assert!(ta.scroll_offset == 1);
866    }
867
868    #[test]
869    fn handle_event_char_input() {
870        let mut ta = TextArea::new();
871        let event = Event::Key(KeyEvent {
872            code: KeyCode::Char('a'),
873            modifiers: Modifiers::NONE,
874        });
875        let result = ta.handle_event(&event);
876        assert!(result == EventResult::Consumed);
877        assert!(ta.text() == "a");
878    }
879
880    #[test]
881    fn handle_event_ctrl_z_undoes() {
882        let mut ta = TextArea::new();
883        ta.insert_char('x');
884        let event = Event::Key(KeyEvent {
885            code: KeyCode::Char('z'),
886            modifiers: Modifiers::CTRL,
887        });
888        ta.handle_event(&event);
889        assert!(ta.text().is_empty());
890    }
891}