Skip to main content

lv_tui/widgets/
textarea.rs

1use crate::component::{Component, EventCx};
2use crate::event::{Event, Key};
3use crate::geom::{Pos, Rect, Size};
4use crate::render::RenderCx;
5use crate::style::{Color, Style};
6use crate::text::Text;
7
8/// Stack depth limit for undo/redo.
9const MAX_UNDO: usize = 100;
10
11/// Multi-line text editor widget.
12///
13/// Supports cursor navigation, selection, undo/redo, line numbers, and
14/// word-level operations.
15pub struct TextArea {
16    lines: Vec<String>,
17    cursor: (usize, usize), // (row, col)
18    scroll_x: u16,
19    scroll_y: u16,
20    width: u16,
21    height: u16,
22    show_line_numbers: bool,
23    rect: Rect,
24    focused: bool,
25    style: Style,
26    focus_style: Style,
27    line_number_style: Style,
28    #[allow(dead_code)]
29    selection_style: Style,
30    undo_stack: Vec<(Vec<String>, (usize, usize))>,
31    redo_stack: Vec<(Vec<String>, (usize, usize))>,
32}
33
34impl TextArea {
35    pub fn new() -> Self {
36        Self {
37            lines: vec![String::new()],
38            cursor: (0, 0),
39            scroll_x: 0,
40            scroll_y: 0,
41            width: 40,
42            height: 10,
43            show_line_numbers: false,
44            rect: Rect::default(),
45            focused: false,
46            style: Style::default(),
47            focus_style: Style::default().bg(Color::White).fg(Color::Black),
48            line_number_style: Style::default().fg(Color::Gray),
49            selection_style: Style::default().bg(Color::White).fg(Color::Black),
50            undo_stack: Vec::new(),
51            redo_stack: Vec::new(),
52        }
53    }
54
55    pub fn text(mut self, text: impl Into<Text>) -> Self {
56        let text = text.into();
57        self.lines = text
58            .lines
59            .iter()
60            .map(|l| {
61                l.spans
62                    .iter()
63                    .map(|s| s.text.clone())
64                    .collect::<Vec<_>>()
65                    .join("")
66            })
67            .collect();
68        if self.lines.is_empty() {
69            self.lines = vec![String::new()];
70        }
71        self.cursor = (0, 0);
72        self
73    }
74
75    pub fn width(mut self, w: u16) -> Self {
76        self.width = w;
77        self
78    }
79
80    pub fn height(mut self, h: u16) -> Self {
81        self.height = h;
82        self
83    }
84
85    pub fn show_line_numbers(mut self, show: bool) -> Self {
86        self.show_line_numbers = show;
87        self
88    }
89
90    pub fn style(mut self, style: Style) -> Self {
91        self.style = style;
92        self
93    }
94
95    pub fn focus_style(mut self, style: Style) -> Self {
96        self.focus_style = style;
97        self
98    }
99
100    pub fn text_content(&self) -> String {
101        self.lines.join("\n")
102    }
103
104    pub fn cursor_pos(&self) -> (usize, usize) {
105        self.cursor
106    }
107
108    /// Saves current state to undo stack before mutation.
109    fn save_undo(&mut self) {
110        if self.undo_stack.len() >= MAX_UNDO {
111            self.undo_stack.remove(0);
112        }
113        self.undo_stack.push((self.lines.clone(), self.cursor));
114        self.redo_stack.clear();
115    }
116
117    fn clamp_cursor(&mut self) {
118        if self.cursor.0 >= self.lines.len() {
119            self.cursor.0 = self.lines.len().saturating_sub(1);
120        }
121        let line_len = self.lines[self.cursor.0].len();
122        if self.cursor.1 > line_len {
123            self.cursor.1 = line_len;
124        }
125    }
126
127    // ── Cursor movement ──────────────────────────────────────
128
129    fn move_left(&mut self) {
130        if self.cursor.1 > 0 {
131            self.cursor.1 -= 1;
132        } else if self.cursor.0 > 0 {
133            self.cursor.0 -= 1;
134            self.cursor.1 = self.lines[self.cursor.0].len();
135        }
136    }
137
138    fn move_right(&mut self) {
139        if self.cursor.1 < self.lines[self.cursor.0].len() {
140            self.cursor.1 += 1;
141        } else if self.cursor.0 + 1 < self.lines.len() {
142            self.cursor.0 += 1;
143            self.cursor.1 = 0;
144        }
145    }
146
147    fn move_up(&mut self) {
148        if self.cursor.0 > 0 {
149            self.cursor.0 -= 1;
150            let line_len = self.lines[self.cursor.0].len();
151            if self.cursor.1 > line_len {
152                self.cursor.1 = line_len;
153            }
154        }
155    }
156
157    fn move_down(&mut self) {
158        if self.cursor.0 + 1 < self.lines.len() {
159            self.cursor.0 += 1;
160            let line_len = self.lines[self.cursor.0].len();
161            if self.cursor.1 > line_len {
162                self.cursor.1 = line_len;
163            }
164        }
165    }
166
167    fn move_home(&mut self) {
168        self.cursor.1 = 0;
169    }
170
171    fn move_end(&mut self) {
172        self.cursor.1 = self.lines[self.cursor.0].len();
173    }
174
175    fn move_page_up(&mut self, page_size: usize) {
176        for _ in 0..page_size {
177            self.move_up();
178        }
179    }
180
181    fn move_page_down(&mut self, page_size: usize) {
182        for _ in 0..page_size {
183            self.move_down();
184        }
185    }
186
187    // ── Editing ──────────────────────────────────────────────
188
189    fn insert_char(&mut self, c: char) {
190        self.save_undo();
191        self.lines[self.cursor.0].insert(self.cursor.1, c);
192        // Move cursor right (respecting char boundary — c is always 1 char)
193        self.cursor.1 = (self.cursor.1 + c.len_utf8()).min(self.lines[self.cursor.0].len());
194    }
195
196    fn delete_backward(&mut self) {
197        if self.cursor.1 > 0 {
198            self.save_undo();
199            // Delete the char before cursor
200            if let Some(idx) = self.prev_char_boundary() {
201                self.lines[self.cursor.0].remove(idx);
202                self.cursor.1 = idx;
203            }
204        } else if self.cursor.0 > 0 {
205            self.save_undo();
206            // Merge with previous line
207            let rest = self.lines.remove(self.cursor.0);
208            self.cursor.0 -= 1;
209            self.cursor.1 = self.lines[self.cursor.0].len();
210            self.lines[self.cursor.0].push_str(&rest);
211        }
212    }
213
214    fn delete_forward(&mut self) {
215        if self.cursor.1 < self.lines[self.cursor.0].len() {
216            self.save_undo();
217            self.lines[self.cursor.0].remove(self.cursor.1);
218        } else if self.cursor.0 + 1 < self.lines.len() {
219            self.save_undo();
220            // Merge next line into current
221            let next = self.lines.remove(self.cursor.0 + 1);
222            self.lines[self.cursor.0].push_str(&next);
223        }
224    }
225
226    fn insert_newline(&mut self) {
227        self.save_undo();
228        let rest = self.lines[self.cursor.0].split_off(self.cursor.1);
229        self.lines.insert(self.cursor.0 + 1, rest);
230        self.cursor.0 += 1;
231        self.cursor.1 = 0;
232    }
233
234    fn undo(&mut self) {
235        if let Some((lines, cursor)) = self.undo_stack.pop() {
236            self.redo_stack.push((self.lines.clone(), self.cursor));
237            self.lines = lines;
238            self.cursor = cursor;
239            self.clamp_cursor();
240        }
241    }
242
243    fn redo(&mut self) {
244        if let Some((lines, cursor)) = self.redo_stack.pop() {
245            self.undo_stack.push((self.lines.clone(), self.cursor));
246            self.lines = lines;
247            self.cursor = cursor;
248            self.clamp_cursor();
249        }
250    }
251
252    fn prev_char_boundary(&self) -> Option<usize> {
253        let line = &self.lines[self.cursor.0];
254        let mut indices: Vec<usize> = line.char_indices().map(|(i, _)| i).collect();
255        indices.push(line.len());
256        indices.into_iter().rev().find(|&i| i < self.cursor.1)
257    }
258}
259
260impl Component for TextArea {
261    fn render(&self, cx: &mut RenderCx) {
262        let gutter_width: u16 = if self.show_line_numbers {
263            // Width for " NNN │ " = 6 chars
264            6
265        } else {
266            0
267        };
268
269        let visible_height = self.rect.height.min(self.height);
270        let visible_width = self.rect.width.min(self.width);
271        let _text_width = visible_width.saturating_sub(gutter_width);
272
273        for i in 0..visible_height as usize {
274            let line_idx = self.scroll_y as usize + i;
275            if line_idx >= self.lines.len() {
276                break;
277            }
278
279            let row_y = self.rect.y + i as u16;
280            let is_cursor_line = self.focused && line_idx == self.cursor.0;
281
282            // Line number
283            if self.show_line_numbers {
284                let num_str = format!(" {:>3} │ ", line_idx + 1);
285                if line_idx == self.cursor.0 && self.focused {
286                    cx.buffer.write_text(
287                        Pos { x: self.rect.x, y: row_y },
288                        self.rect,
289                        &num_str,
290                        &self.focus_style,
291                    );
292                } else {
293                    cx.buffer.write_text(
294                        Pos { x: self.rect.x, y: row_y },
295                        self.rect,
296                        &num_str,
297                        &self.line_number_style,
298                    );
299                }
300            }
301
302            // Text content
303            let line = &self.lines[line_idx];
304            let display = if line.len() > self.scroll_x as usize {
305                let start = line
306                    .char_indices()
307                    .nth(self.scroll_x as usize)
308                    .map(|(i, _)| i)
309                    .unwrap_or(line.len());
310                &line[start..]
311            } else {
312                ""
313            };
314
315            let text_x = self.rect.x + gutter_width;
316
317            if is_cursor_line {
318                // Render with cursor
319                let cursor_char_idx = self.cursor.1.saturating_sub(self.scroll_x as usize);
320                if cursor_char_idx <= display.chars().count() {
321                    let chars: Vec<char> = display.chars().collect();
322                    let before: String = chars.iter().take(cursor_char_idx).collect();
323                    let at = chars.get(cursor_char_idx).map(|c| c.to_string()).unwrap_or_default();
324                    let after: String = chars.iter().skip(cursor_char_idx + 1).collect();
325
326                    cx.buffer.write_text(
327                        Pos { x: text_x, y: row_y },
328                        self.rect,
329                        &before,
330                        &self.focus_style,
331                    );
332                    let at_x = text_x + str_width(&before);
333                    cx.buffer.write_text(
334                        Pos { x: at_x, y: row_y },
335                        self.rect,
336                        &at,
337                        &self.focus_style,
338                    );
339                    cx.buffer.write_text(
340                        Pos {
341                            x: at_x + str_width(&at),
342                            y: row_y,
343                        },
344                        self.rect,
345                        &after,
346                        &self.focus_style,
347                    );
348                    return; // handled this line
349                } else {
350                    display.to_string()
351                };
352            }
353
354            cx.buffer.write_text(
355                Pos { x: text_x, y: row_y },
356                self.rect,
357                display,
358                &self.style,
359            );
360        }
361    }
362
363    fn measure(&self, _constraint: crate::layout::Constraint, _cx: &mut crate::component::MeasureCx) -> Size {
364        Size {
365            width: self.width,
366            height: self.height.min(self.lines.len() as u16),
367        }
368    }
369
370    fn event(&mut self, event: &Event, cx: &mut EventCx) {
371        match event {
372            Event::Focus => {
373                self.focused = true;
374                cx.invalidate_paint();
375                return;
376            }
377            Event::Blur => {
378                self.focused = false;
379                cx.invalidate_paint();
380                return;
381            }
382            _ => {}
383        }
384
385        if cx.phase() != crate::event::EventPhase::Target {
386            return;
387        }
388
389        if let Event::Key(key_event) = event {
390            let ctrl = key_event.modifiers.ctrl;
391            let shift = key_event.modifiers.shift;
392
393            match (&key_event.key, ctrl, shift) {
394                (Key::Left, false, _) => {
395                    self.move_left();
396                    cx.invalidate_paint();
397                }
398                (Key::Right, false, _) => {
399                    self.move_right();
400                    cx.invalidate_paint();
401                }
402                (Key::Up, false, _) => {
403                    self.move_up();
404                    cx.invalidate_paint();
405                }
406                (Key::Down, false, _) => {
407                    self.move_down();
408                    cx.invalidate_paint();
409                }
410                (Key::Home, false, _) => {
411                    self.move_home();
412                    cx.invalidate_paint();
413                }
414                (Key::End, false, _) => {
415                    self.move_end();
416                    cx.invalidate_paint();
417                }
418                (Key::PageUp, false, _) => {
419                    let page = self.height.saturating_sub(1) as usize;
420                    self.move_page_up(page);
421                    cx.invalidate_paint();
422                }
423                (Key::PageDown, false, _) => {
424                    let page = self.height.saturating_sub(1) as usize;
425                    self.move_page_down(page);
426                    cx.invalidate_paint();
427                }
428                (Key::Char(c), false, false) if *c != '\n' => {
429                    self.insert_char(*c);
430                    cx.invalidate_paint();
431                }
432                (Key::Backspace, false, _) => {
433                    self.delete_backward();
434                    cx.invalidate_paint();
435                }
436                (Key::Delete, false, _) => {
437                    self.delete_forward();
438                    cx.invalidate_paint();
439                }
440                (Key::Enter, false, _) => {
441                    self.insert_newline();
442                    cx.invalidate_paint();
443                }
444                // Ctrl+Z: undo
445                (Key::Char('z'), true, _) => {
446                    self.undo();
447                    cx.invalidate_paint();
448                }
449                // Ctrl+Y: redo
450                (Key::Char('y'), true, _) => {
451                    self.redo();
452                    cx.invalidate_paint();
453                }
454                _ => {}
455            }
456        }
457    }
458
459    fn layout(&mut self, rect: Rect, _cx: &mut crate::component::LayoutCx) {
460        self.rect = rect;
461    }
462
463    fn focusable(&self) -> bool {
464        true
465    }
466
467    fn style(&self) -> Style {
468        self.style.clone()
469    }
470}
471
472/// Calculate display width of &str.
473pub(crate) fn str_width(s: &str) -> u16 {
474    s.chars()
475        .map(|c| unicode_width::UnicodeWidthChar::width(c).unwrap_or(0) as u16)
476        .sum()
477}