Skip to main content

rich_interact/components/
textarea.rs

1//! A multi-line editor (#493, `rich write`).
2//!
3//! Enter starts a new line; Ctrl+D (or the key the caller chooses) submits
4//! and Escape cancels. Lines longer than the space wrap, and the text
5//! scrolls to keep the caret in view. The arrows, Home/End, Ctrl+A/E,
6//! Ctrl+U/K/W, Backspace and Delete edit as in [`Input`](crate::Input), the
7//! caret moving by grapheme cluster; Up and Down move between lines. A
8//! character limit counts grapheme clusters, line breaks too. Pasted text
9//! keeps its line breaks and tabs (a tab shows as [`TAB`] spaces); other
10//! terminal controls are dropped.
11
12use std::cell::Cell;
13
14use rich::Segment;
15
16use crate::component::{Component, Context, Flow, View};
17use crate::components::{Theme, fit, plain, question, text};
18use crate::edit::{Buffer, Pos, TAB, cells, clean, graphemes, length};
19use crate::event::{Event, Key, KeyCode};
20use crate::keymap::{Keymap, keys};
21use crate::policy::{LineIo, NotInteractive};
22
23/// One row on screen: a piece of a line.
24#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25struct Row {
26    line: usize,
27    /// Byte range of the line's text.
28    start: usize,
29    end: usize,
30    /// Whether it is the line's first row (the one numbered).
31    first: bool,
32}
33
34/// Multi-line text. Returns the text, lines joined with `\n`.
35#[must_use = "a component does nothing until it is run or mounted"]
36pub struct TextArea {
37    prompt: String,
38    /// The text, the caret and the limit: the editing shared with
39    /// `rs-rich-intuituive`'s `text_area`.
40    buffer: Buffer,
41    placeholder: Option<String>,
42    submit: Key,
43    height: usize,
44    line_numbers: bool,
45    theme: Theme,
46    /// The first row shown. Moved in `handle`, which knows the width, and
47    /// read in `render`.
48    top: Cell<usize>,
49    answer: Option<Option<String>>,
50    /// Keys rebound on this area ([`TextArea::rebind`]).
51    rebound: Keymap,
52}
53
54impl TextArea {
55    pub fn new(prompt: impl Into<String>) -> TextArea {
56        TextArea {
57            prompt: prompt.into(),
58            buffer: Buffer::new(""),
59            placeholder: None,
60            submit: Key::ctrl('d'),
61            rebound: Keymap::new("textarea"),
62            height: 5,
63            line_numbers: false,
64            theme: Theme::default(),
65            top: Cell::new(0),
66            answer: None,
67        }
68    }
69
70    /// Start with this text, the caret at its end.
71    pub fn value(mut self, value: impl AsRef<str>) -> Self {
72        self.buffer.set_text(value.as_ref());
73        self.buffer.bottom(false);
74        self
75    }
76
77    /// Dim text shown while the area is empty.
78    pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
79        self.placeholder = Some(placeholder.into());
80        self
81    }
82
83    /// At most `chars` characters (grapheme clusters, so `๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘ง` or `e`
84    /// and a combining accent is one), line breaks included. Text already
85    /// there beyond it is cut.
86    pub fn char_limit(mut self, chars: usize) -> Self {
87        self.buffer.set_limit(Some(chars));
88        self.buffer.bottom(false);
89        self
90    }
91
92    /// The first `chars` characters of `text` as the character limit
93    /// counts them: grapheme clusters, a line break one. Never cuts a
94    /// cluster.
95    pub fn truncate(text: &str, chars: usize) -> &str {
96        crate::edit::truncate(text, chars)
97    }
98
99    /// Make `keys` do `action` (in context `textarea`; see
100    /// [`keymap`](Component::keymap)) on this area only; no keys unbinds
101    /// it.
102    pub fn rebind(mut self, action: &str, keys: impl IntoIterator<Item = Key>) -> Self {
103        self.rebound.rebind(action, keys);
104        self
105    }
106
107    /// Emacs's keys on top of the defaults: Ctrl+F/B/N/P and Alt+F/B
108    /// move, Ctrl+K, Ctrl+U, Ctrl+W, Alt+D and Alt+Backspace kill onto the
109    /// buffer's kill ring, Ctrl+Y yanks the last kill back and Alt+Y
110    /// swaps it for an older one, Alt+{ and Alt+} move by paragraph.
111    /// (Ctrl+D still submits unless [`submit_key`](Self::submit_key)
112    /// moves it.)
113    ///
114    /// ```
115    /// use rich_interact::headless::{run, Script};
116    /// use rich_interact::TextArea;
117    ///
118    /// let area = TextArea::new("Note").value("one two").emacs_keys();
119    /// let script = Script::new().keys("ctrl+a alt+d ctrl+e ctrl+y ctrl+d");
120    /// let (outcome, _) = run(area, script, 30, 6);
121    /// assert_eq!(outcome.unwrap().value().as_deref(), Some(" twoone"));
122    /// ```
123    pub fn emacs_keys(mut self) -> Self {
124        for (action, names) in [
125            ("left", "left ctrl+b"),
126            ("right", "right ctrl+f"),
127            ("up", "up ctrl+p"),
128            ("down", "down ctrl+n"),
129            ("word-left", "ctrl+left alt+b"),
130            ("word-right", "ctrl+right alt+f"),
131            ("kill-to-end", "ctrl+k"),
132            ("kill-to-start", "ctrl+u"),
133            ("kill-word", "ctrl+w"),
134            ("kill-next-word", "alt+d"),
135            ("kill-previous-word", "alt+backspace"),
136            ("yank", "ctrl+y"),
137            ("yank-pop", "alt+y"),
138            ("paragraph-up", "alt+{"),
139            ("paragraph-down", "alt+}"),
140            ("delete-to-start", ""),
141            ("delete-to-end", ""),
142            ("delete-word", ""),
143        ] {
144            self.rebound.rebind(action, keys(names));
145        }
146        self
147    }
148
149    /// The key that submits (default Ctrl+D).
150    pub fn submit_key(mut self, key: Key) -> Self {
151        self.submit = key;
152        self
153    }
154
155    /// Rows of text shown (default 5).
156    pub fn height(mut self, rows: usize) -> Self {
157        self.height = rows.max(1);
158        self
159    }
160
161    /// Number the lines.
162    pub fn line_numbers(mut self, on: bool) -> Self {
163        self.line_numbers = on;
164        self
165    }
166
167    pub fn theme(mut self, theme: Theme) -> Self {
168        self.theme = theme;
169        self
170    }
171
172    /// The text so far.
173    pub fn text(&self) -> String {
174        self.buffer.text()
175    }
176
177    /// The caret: (line, character column).
178    pub fn caret(&self) -> (usize, usize) {
179        self.buffer.caret_column()
180    }
181
182    fn count(&self) -> usize {
183        self.buffer.count()
184    }
185
186    /// Rows of text shown in a space `rows` high: the height asked for, cut
187    /// to what fits beside the question and the hint.
188    fn shown_rows(&self, rows: usize) -> usize {
189        self.height.min(rows.saturating_sub(2)).max(1)
190    }
191
192    fn gutter(&self) -> usize {
193        if self.line_numbers {
194            self.buffer.line_count().to_string().len() + 3
195        } else {
196            2
197        }
198    }
199
200    /// The rows at `width`, wrapped one cell short so the caret fits after
201    /// the last character.
202    fn rows(&self, width: usize) -> Vec<Row> {
203        let room = width.saturating_sub(self.gutter() + 1).max(1);
204        let mut rows = Vec::new();
205        for (index, line) in self.buffer.lines().iter().enumerate() {
206            let spans = graphemes(line);
207            let mut start = 0;
208            let mut cells = 0;
209            let mut first = true;
210            for (at, _, width) in spans {
211                if cells + width > room && at > start {
212                    rows.push(Row {
213                        line: index,
214                        start,
215                        end: at,
216                        first,
217                    });
218                    first = false;
219                    start = at;
220                    cells = 0;
221                }
222                cells += width;
223            }
224            rows.push(Row {
225                line: index,
226                start,
227                end: line.len(),
228                first,
229            });
230        }
231        rows
232    }
233
234    /// The caret's row and column among `rows`.
235    fn caret_at(&self, rows: &[Row]) -> (usize, usize) {
236        for (index, row) in rows.iter().enumerate() {
237            let next_same_line = rows
238                .get(index + 1)
239                .is_some_and(|next| next.line == row.line);
240            let Pos { line, at } = self.buffer.caret();
241            let within = at >= row.start && (at < row.end || !next_same_line);
242            if row.line == line && within {
243                let text = self.buffer.line(row.line);
244                return (index, cells(&text[row.start..at]));
245            }
246        }
247        (0, 0)
248    }
249
250    /// Scroll so the caret is shown, `height` rows of text on screen.
251    fn follow(&self, width: usize, height: usize) {
252        let rows = self.rows(width);
253        let (row, _) = self.caret_at(&rows);
254        let top = self.top.get();
255        if row < top {
256            self.top.set(row);
257        } else if row >= top + height {
258            self.top.set(row + 1 - height);
259        }
260    }
261}
262
263impl Component for TextArea {
264    type Output = String;
265
266    fn handle(&mut self, event: &Event, context: &Context<'_>) -> Flow<String> {
267        let height = self.shown_rows(context.height);
268        if let Event::Paste(pasted) = event {
269            self.buffer.insert(pasted);
270            self.follow(context.width, height);
271            return Flow::Continue;
272        }
273        let Some(key) = event.key() else {
274            if let Event::Resize { .. } = event {
275                self.follow(context.width, height);
276            }
277            return Flow::Continue;
278        };
279        let keymap = self.keymap();
280        // A key with modifiers that is not bound as it is does what it does
281        // without them, unless it types.
282        let action = keymap.action(key).or_else(|| {
283            let typing = matches!(key.code, KeyCode::Char(_));
284            (!typing && key.modifiers != crate::event::Modifiers::NONE)
285                .then(|| keymap.action(Key::new(key.code)))
286                .flatten()
287        });
288        match action.map(str::to_string).as_deref() {
289            Some("submit") => {
290                let text = self.text();
291                self.answer = Some(Some(text.clone()));
292                return Flow::Done(text);
293            }
294            Some("cancel") => {
295                self.answer = Some(None);
296                return Flow::Cancel;
297            }
298            Some("newline") => self.buffer.insert("\n"),
299            Some("indent") => self.buffer.insert("    "),
300            Some("backspace") => self.buffer.backspace(),
301            Some("delete") => self.buffer.delete(),
302            Some("left") => self.buffer.left(false),
303            Some("right") => self.buffer.right(false),
304            Some("up") => self.buffer.vertical(-1, false),
305            Some("down") => self.buffer.vertical(1, false),
306            Some("page-up") => self.buffer.vertical(-(height as isize), false),
307            Some("page-down") => self.buffer.vertical(height as isize, false),
308            Some("top") => self.buffer.top(false),
309            Some("bottom") => self.buffer.bottom(false),
310            Some("home") => self.buffer.home(false),
311            Some("end") => self.buffer.end(false),
312            Some("delete-to-start") => self.buffer.delete_to_start(),
313            Some("delete-to-end") => self.buffer.delete_to_end(),
314            Some("delete-word") => self.buffer.delete_word(),
315            // The buffer's other actions (kills, yanks, undo, word and
316            // paragraph moves), for keys rebound onto them.
317            Some(action)
318                if crate::edit_keys::perform(
319                    &mut self.buffer,
320                    action,
321                    crate::edit_keys::Act {
322                        page: height,
323                        ..crate::edit_keys::Act::default()
324                    },
325                ) == crate::edit_keys::Outcome::Used => {}
326            _ => match key.code {
327                KeyCode::Char(c) if !key.modifiers.ctrl && !key.modifiers.alt => {
328                    let mut buffer = [0; 4];
329                    self.buffer.insert(c.encode_utf8(&mut buffer));
330                }
331                _ => return Flow::Ignored,
332            },
333        }
334        self.follow(context.width, height);
335        Flow::Continue
336    }
337
338    fn keymap(&self) -> Keymap {
339        let mut keymap = Keymap::new("textarea")
340            .bind("submit", [self.submit], "submit")
341            .bind("cancel", keys("escape"), "cancel")
342            .bind("newline", keys("enter"), "new line")
343            .bind("indent", keys("tab"), "indent")
344            .bind("up", keys("up"), "line up")
345            .bind("down", keys("down"), "line down")
346            .bind("left", keys("left"), "move left")
347            .bind("right", keys("right"), "move right")
348            .bind("page-up", keys("pageup"), "page up")
349            .bind("page-down", keys("pagedown"), "page down")
350            .bind("home", keys("home ctrl+a"), "start of the line")
351            .bind("end", keys("end ctrl+e"), "end of the line")
352            .bind("top", keys("ctrl+home"), "start of the text")
353            .bind("bottom", keys("ctrl+end"), "end of the text")
354            .bind(
355                "delete-to-start",
356                keys("ctrl+u"),
357                "delete to the line's start",
358            )
359            .bind("delete-to-end", keys("ctrl+k"), "delete to the line's end")
360            .bind("delete-word", keys("ctrl+w"), "delete a word")
361            .bind("backspace", keys("backspace"), "delete back")
362            .bind("delete", keys("delete"), "delete forward");
363        // Bound to no key here; rebind them, or take `emacs_keys`.
364        for (action, description) in [
365            ("word-left", "word left"),
366            ("word-right", "word right"),
367            ("paragraph-up", "paragraph up"),
368            ("paragraph-down", "paragraph down"),
369            ("undo", "undo"),
370            ("redo", "redo"),
371            ("delete-next-word", "delete a word forward"),
372            ("kill-to-end", "kill to the line's end"),
373            ("kill-to-start", "kill to the line's start"),
374            ("kill-word", "kill a word back"),
375            ("kill-next-word", "kill a word forward"),
376            ("kill-previous-word", "kill to the word's start"),
377            ("yank", "yank the last kill"),
378            ("yank-pop", "swap the yank for an older kill"),
379        ] {
380            keymap = keymap.bind(action, [], description);
381        }
382        keymap.extend(self.rebound.clone());
383        keymap
384    }
385
386    fn render(&self, context: &Context<'_>) -> View {
387        let width = context.width;
388        let theme = &self.theme;
389        let mut header = question(theme, &self.prompt);
390        if let Some(answer) = &self.answer {
391            header.push(match answer {
392                Some(answer) => {
393                    let mut lines = answer.lines();
394                    let first = lines.next().unwrap_or("").to_string();
395                    let more = lines.count();
396                    if more > 0 {
397                        text(format!("{first} โ€ฆ (+{more} lines)"), &theme.answer)
398                    } else {
399                        text(first, &theme.answer)
400                    }
401                }
402                None => text("cancelled", &theme.hint),
403            });
404            return View::new(vec![fit(header, width)]);
405        }
406        let mut lines = vec![fit(header, width)];
407        let rows = self.rows(width);
408        let (caret_row, caret_column) = self.caret_at(&rows);
409        let height = self.shown_rows(context.height);
410        let top = self.top.get().min(rows.len().saturating_sub(1));
411        let gutter = self.gutter();
412        let empty = self.buffer.is_empty();
413        for index in top..top + height {
414            let mut line: Vec<Segment> = Vec::new();
415            match rows.get(index) {
416                Some(row) => {
417                    let number = if self.line_numbers && row.first {
418                        format!("{:>w$} โ”‚ ", row.line + 1, w = gutter - 3)
419                    } else if self.line_numbers {
420                        format!("{} โ”‚ ", " ".repeat(gutter - 3))
421                    } else {
422                        "โ”‚ ".to_string()
423                    };
424                    line.push(text(number, &theme.border));
425                    if empty {
426                        if let Some(placeholder) = &self.placeholder {
427                            line.push(text(placeholder.clone(), &theme.hint));
428                        }
429                    } else {
430                        let piece = &self.buffer.line(row.line)[row.start..row.end];
431                        line.push(plain(piece.replace('\t', &" ".repeat(TAB))));
432                    }
433                }
434                None => line.push(text(format!("{}~", " ".repeat(gutter - 2)), &theme.border)),
435            }
436            lines.push(fit(line, width));
437        }
438        let mut hint = String::from("  ");
439        if let Some(limit) = self.buffer.limit() {
440            hint.push_str(&format!("{}/{limit} ยท ", self.count()));
441        }
442        hint.push_str(&format!(
443            "enter new line ยท {} submit ยท esc cancel",
444            self.keymap()
445                .key("submit")
446                .map_or_else(|| "-".to_string(), |key| key.to_string())
447        ));
448        lines.push(fit(vec![text(hint, &theme.hint)], width));
449        let view = View::new(lines);
450        if caret_row >= top && caret_row < top + height {
451            view.with_cursor(
452                1 + caret_row - top,
453                (gutter + caret_column).min(width.saturating_sub(1)),
454            )
455        } else {
456            view
457        }
458    }
459
460    fn default_value(&self) -> Option<String> {
461        Some(self.text()).filter(|text| !text.is_empty())
462    }
463
464    /// Without a terminal: every line up to the end of input is the text,
465    /// cut to the character limit. No lines at all answer with the text
466    /// the area started with.
467    fn prompt(&mut self, io: &mut dyn LineIo) -> Result<Option<String>, NotInteractive> {
468        io.write(&format!("{} (end with Ctrl+D):\n", self.prompt));
469        let mut read: Vec<String> = Vec::new();
470        let mut total = 0usize;
471        while let Some(line) = io.read_line() {
472            total += length(&line) + 1;
473            read.push(clean(&line));
474            if self.buffer.limit().is_some_and(|limit| total > limit) {
475                break;
476            }
477        }
478        if read.is_empty() {
479            return Ok(Some(self.text()));
480        }
481        let text = read.join("\n");
482        Ok(Some(match self.buffer.limit() {
483            Some(limit) => TextArea::truncate(&text, limit).to_string(),
484            None => text,
485        }))
486    }
487}
488
489#[cfg(test)]
490mod tests {
491    use super::*;
492
493    #[test]
494    fn wraps_long_lines_one_cell_short() {
495        let area = TextArea::new("Notes").value("abcdefghij\nxy");
496        // 12 columns, a 2-cell gutter, one cell kept for the caret: 9.
497        let rows = area.rows(12);
498        let pieces: Vec<&str> = rows
499            .iter()
500            .map(|row| &area.buffer.line(row.line)[row.start..row.end])
501            .collect();
502        assert_eq!(pieces, ["abcdefghi", "j", "xy"]);
503        assert!(rows[0].first && !rows[1].first && rows[2].first);
504        assert_eq!(area.caret_at(&rows), (2, 2));
505    }
506}