Skip to main content

qframe/widgets/
text_area.rs

1//! Multi-line text entry.
2
3use std::time::Duration;
4
5use unicode_segmentation::UnicodeSegmentation;
6
7use super::cells;
8use super::edit_menu::{self, EditAction, TextMenu};
9use super::editor::Editor;
10use super::rows::WHEEL_ROWS;
11use super::scrollbar::{self, ScrollMetrics};
12use super::text_input::{Blink, draw_cursor};
13use super::text_rows;
14use crate::env::Env;
15use crate::event::{Event, KeyEvent, MouseButton, MouseEvent, MouseKind};
16use crate::geometry::{Padding, Rect, Size, clamp_u16};
17use crate::keymap::{Key, Modifiers, Scope};
18use crate::style::CellStyle;
19use crate::text;
20use crate::theme::State;
21use crate::widget::{EventCx, MeasureCx, PaintCx, Widget};
22
23/// Rows a text area measures at least, so it reads as a place for several lines.
24const MIN_ROWS: usize = 3;
25
26/// Rows a text area grows to with its content before it scrolls.
27const MAX_ROWS: usize = 8;
28
29/// Digits reserved for line numbers at least, so the gutter does not widen at line 10.
30const MIN_NUMBER_DIGITS: u16 = 2;
31
32type TextMessage<Msg> = Box<dyn Fn(String) -> Msg>;
33
34/// A multi-line text field with word wrap, scrolling and real editing.
35///
36/// Long lines wrap at word boundaries and the area scrolls vertically with a scrollbar once the
37/// text is taller than the area. The area measures three to eight rows, growing with its
38/// content; give the node a height for a fixed size. The application owns the value and
39/// receives every change through `on_change`; cursor, selection, scroll and undo history live
40/// in the runtime.
41///
42/// Keys: Enter inserts a line break. ←/→ move (with Ctrl by word), ↑/↓ move between rows
43/// keeping the column, Page Up/Page Down move a page, Home/End go to the row's start and end
44/// (with Ctrl to the text's), and Shift extends the selection with any of them. Without Shift an
45/// arrow clears a selection and moves one step on from its end in that direction. Backspace and
46/// Delete, Ctrl+W deletes a word, Ctrl+U deletes to the line start, Ctrl+A selects all,
47/// Ctrl+Z undo, Ctrl+Y or Ctrl+Shift+Z redo, Ctrl+C and Ctrl+X copy and cut. Ctrl+Enter
48/// submits when [`TextArea::on_submit`] is set; terminals without the kitty keyboard protocol
49/// may not report it, so offer a button as well. Pasting keeps line breaks. Clicking places the
50/// cursor, dragging selects, the wheel and the scrollbar scroll.
51///
52/// A right click (or Shift+F10 and the menu key) opens the edit menu of
53/// [`TextInput`](super::TextInput): Cut, Copy, Paste and Select all, with the same rules.
54///
55/// Style keys: `text-area` (`bg`, `fg`, `padding`) with `hover`, `focus`, `invalid`,
56/// `disabled`; `text-area-line-number` (`fg`) with `selected` on the cursor's line;
57/// `text-area-counter` (`fg`); and the text input's `text-input-placeholder`,
58/// `text-input-selection`, `text-input-cursor` and `scrollbar`.
59pub struct TextArea<Msg> {
60    value: String,
61    placeholder: String,
62    invalid: bool,
63    disabled: bool,
64    max_length: Option<usize>,
65    line_numbers: bool,
66    counter: bool,
67    on_change: Option<TextMessage<Msg>>,
68    on_submit: Option<TextMessage<Msg>>,
69}
70
71#[derive(Debug, Default)]
72struct AreaMemory {
73    editor: Editor,
74    synced: Option<String>,
75    /// First visible row.
76    scroll: usize,
77    /// The column ↑ and ↓ keep while moving through shorter rows.
78    goal: Option<u16>,
79    /// Whether the next paint scrolls the cursor into view.
80    follow: bool,
81    last_edit: Duration,
82    selecting: bool,
83    dragging_bar: bool,
84}
85
86/// Where the parts of a text area go inside its area.
87struct Layout {
88    rows: Vec<std::ops::Range<usize>>,
89    /// The text cells: one cell wider than the wrap width, for a cursor after a full row.
90    text: Rect,
91    /// Cells of the line number column, including its gap.
92    gutter: u16,
93    bar: Option<Rect>,
94    counter: Option<Rect>,
95}
96
97impl Layout {
98    fn visible(&self) -> usize {
99        usize::from(self.text.height.max(1))
100    }
101
102    fn metrics(&self, offset: usize) -> ScrollMetrics {
103        ScrollMetrics { total: self.rows.len(), visible: self.visible(), offset }
104    }
105}
106
107/// What an event did.
108#[derive(Default)]
109struct Outcome {
110    handled: bool,
111    changed: bool,
112    submit: bool,
113    copy: Option<String>,
114    capture: bool,
115    /// Whether the cursor should be scrolled into view.
116    follow: bool,
117}
118
119impl<Msg: 'static> TextArea<Msg> {
120    /// An area showing `value`.
121    #[must_use]
122    pub fn new(value: impl Into<String>) -> Self {
123        Self {
124            value: value.into(),
125            placeholder: String::new(),
126            invalid: false,
127            disabled: false,
128            max_length: None,
129            line_numbers: false,
130            counter: false,
131            on_change: None,
132            on_submit: None,
133        }
134    }
135
136    /// Faint text shown while the area is empty.
137    #[must_use]
138    pub fn placeholder(mut self, text: impl Into<String>) -> Self {
139        self.placeholder = text.into();
140        self
141    }
142
143    /// Marks the value as failing validation.
144    #[must_use]
145    pub fn invalid(mut self, invalid: bool) -> Self {
146        self.invalid = invalid;
147        self
148    }
149
150    /// Makes the area read-only and unfocusable.
151    #[must_use]
152    pub fn disabled(mut self, disabled: bool) -> Self {
153        self.disabled = disabled;
154        self
155    }
156
157    /// Limits the value to `max` characters; a line break counts as one.
158    #[must_use]
159    pub fn max_length(mut self, max: usize) -> Self {
160        self.max_length = Some(max);
161        self
162    }
163
164    /// Numbers every line in a faint column on the left; wrapped rows are not numbered.
165    #[must_use]
166    pub fn line_numbers(mut self, on: bool) -> Self {
167        self.line_numbers = on;
168        self
169    }
170
171    /// Shows the character count on a row below the text, with the limit when there is one.
172    #[must_use]
173    pub fn counter(mut self, on: bool) -> Self {
174        self.counter = on;
175        self
176    }
177
178    /// Message carrying the new value after every edit.
179    #[must_use]
180    pub fn on_change(mut self, message: impl Fn(String) -> Msg + 'static) -> Self {
181        self.on_change = Some(Box::new(message));
182        self
183    }
184
185    /// Message carrying the value when Ctrl+Enter is pressed.
186    #[must_use]
187    pub fn on_submit(mut self, message: impl Fn(String) -> Msg + 'static) -> Self {
188        self.on_submit = Some(Box::new(message));
189        self
190    }
191
192    fn sync<'m>(&self, memory: &'m mut AreaMemory) -> &'m mut AreaMemory {
193        if memory.synced.as_deref() != Some(self.value.as_str()) {
194            if memory.editor.text() != self.value {
195                memory.editor.replace_all(&self.value);
196                memory.goal = None;
197            }
198            memory.synced = Some(self.value.clone());
199        }
200        memory
201    }
202
203    fn gutter(&self, text: &str) -> u16 {
204        if !self.line_numbers {
205            return 0;
206        }
207        let lines = text.matches('\n').count() + 1;
208        let digits = clamp_u16(i32::try_from(lines.to_string().len()).unwrap_or(i32::MAX));
209        digits.max(MIN_NUMBER_DIGITS) + 1
210    }
211
212    fn layout(&self, env: &Env, area: Rect, text: &str) -> Layout {
213        let inner = area.inset(padding(env));
214        let counter =
215            (self.counter && inner.height >= 2).then(|| Rect::new(inner.x, inner.bottom() - 1, inner.width, 1));
216        let height = inner.height - u16::from(counter.is_some());
217        let gutter = self.gutter(text).min(inner.width);
218        let full = inner.width - gutter;
219        let x = inner.x + i32::from(gutter);
220        let rows = text_rows::wrap(text, full.saturating_sub(1));
221        if rows.len() > usize::from(height) && full > 2 {
222            let bar = Rect::new(inner.right() - 1, inner.y, 1, height);
223            let rows = text_rows::wrap(text, full - 2);
224            return Layout { rows, text: Rect::new(x, inner.y, full - 1, height), gutter, bar: Some(bar), counter };
225        }
226        Layout { rows, text: Rect::new(x, inner.y, full, height), gutter, bar: None, counter }
227    }
228
229    fn key(&self, memory: &mut AreaMemory, key: &KeyEvent, layout: &Layout) -> Outcome {
230        let mut out = Outcome { handled: true, follow: true, ..Outcome::default() };
231        let max = self.max_length;
232        let mods = key.chord.mods;
233        let ctrl = mods.ctrl && !mods.alt;
234        let text = memory.editor.text().to_owned();
235        let cursor = memory.editor.cursor();
236        // ↑ and ↓ without Shift clear a selection and move a row on from its end in that direction.
237        let from = match (memory.editor.selection(), key.chord.key) {
238            (Some(range), Key::Up | Key::PageUp) if !mods.shift => range.start,
239            (Some(range), Key::Down | Key::PageDown) if !mods.shift => range.end,
240            _ => cursor,
241        };
242        let (row, column) = text_rows::locate(&text, &layout.rows, from);
243        let vertical = |memory: &mut AreaMemory, delta: isize| {
244            let goal = memory.goal.unwrap_or(column);
245            let target = row.checked_add_signed(delta).filter(|target| *target < layout.rows.len());
246            let offset = match target {
247                Some(target) => text_rows::offset_at(&text, &layout.rows, target, goal),
248                None if delta < 0 => 0,
249                None => text.len(),
250            };
251            memory.editor.move_to_offset(offset, mods.shift);
252            memory.goal = Some(goal);
253        };
254        let page = isize::try_from(layout.visible()).unwrap_or(1);
255        match key.chord.key {
256            Key::Up | Key::Down | Key::PageUp | Key::PageDown if !ctrl => {
257                let delta = match key.chord.key {
258                    Key::Up => -1,
259                    Key::Down => 1,
260                    Key::PageUp => -page,
261                    _ => page,
262                };
263                vertical(memory, delta);
264                return out;
265            }
266            _ => memory.goal = None,
267        }
268        let editor = &mut memory.editor;
269        match key.chord.key {
270            Key::Char(c) if ctrl => match (c, mods.shift) {
271                ('a', false) => editor.select_all(),
272                ('z', false) => out.changed = editor.undo(),
273                ('y', false) | ('z', true) => out.changed = editor.redo(),
274                ('w', false) => out.changed = editor.delete_word_back(),
275                ('u', false) => {
276                    let line_start = text[..cursor].rfind('\n').map_or(0, |index| index + 1);
277                    out.changed = editor.delete_range(line_start..cursor);
278                }
279                ('c', false) | ('x', false) => {
280                    out.copy = editor.selected_text().map(str::to_owned);
281                    if c == 'x' && out.copy.is_some() {
282                        out.changed = editor.backspace();
283                    }
284                    out.handled = out.copy.is_some();
285                }
286                _ => out.handled = false,
287            },
288            Key::Enter if ctrl && !mods.shift => {
289                out.submit = self.on_submit.is_some();
290                out.handled = out.submit;
291            }
292            Key::Enter if mods == Modifiers::default() => out.changed = editor.insert_lines("\n", max),
293            Key::Left => editor.move_left(mods.shift, mods.ctrl),
294            Key::Right => editor.move_right(mods.shift, mods.ctrl),
295            Key::Home if mods.ctrl => editor.move_home(mods.shift),
296            Key::End if mods.ctrl => editor.move_end(mods.shift),
297            Key::Home => editor.move_to_offset(layout.rows.get(row).map_or(0, |r| r.start), mods.shift),
298            Key::End => editor.move_to_offset(text_rows::offset_at(&text, &layout.rows, row, u16::MAX), mods.shift),
299            Key::Backspace if mods == Modifiers::default() || mods.ctrl => {
300                out.changed = if mods.ctrl { editor.delete_word_back() } else { editor.backspace() };
301            }
302            Key::Delete => out.changed = editor.delete(),
303            _ => match key.text {
304                Some(c) if !mods.ctrl && !mods.alt => out.changed = editor.insert_lines(&c.to_string(), max),
305                _ => out.handled = false,
306            },
307        }
308        out
309    }
310
311    /// The byte offset of the text under the pointer.
312    fn offset_under(memory: &AreaMemory, mouse: &MouseEvent, layout: &Layout) -> usize {
313        let relative = isize::try_from(mouse.y - layout.text.y).unwrap_or(0);
314        let last = layout.rows.len().saturating_sub(1);
315        let row = memory.scroll.checked_add_signed(relative).unwrap_or(0).min(last);
316        let column = clamp_u16(mouse.x - layout.text.x);
317        text_rows::offset_at(memory.editor.text(), &layout.rows, row, column)
318    }
319
320    /// Offers `event` to the edit menu, which opens on a right press or its keys and takes every
321    /// event while open. Returns `None` when the menu did not use the event.
322    fn menu_event(&self, cx: &mut EventCx<'_, Msg>, event: &Event, layout: &Layout) -> Option<Outcome> {
323        let open = edit_menu::is_open(cx);
324        if !open && !edit_menu::asks(event) {
325            return None;
326        }
327        if let Event::Mouse(mouse) = event
328            && !open
329        {
330            // A right press inside the selection keeps it; elsewhere it places the cursor first.
331            let memory = self.sync(cx.memory::<AreaMemory>());
332            let offset = Self::offset_under(memory, mouse, layout);
333            if !memory.editor.selection().is_some_and(|range| range.contains(&offset)) {
334                memory.editor.move_to_offset(offset, false);
335                memory.goal = None;
336            }
337        }
338        let selection = self.sync(cx.memory::<AreaMemory>()).editor.selection().is_some();
339        let (used, chosen) = TextMenu::edit(cx.env(), selection, cx.can_paste()).event(cx, event);
340        if used && !open {
341            cx.probe_clipboard();
342        }
343        let mut out = Outcome { handled: used, ..Outcome::default() };
344        let Some(action) = chosen else {
345            return used.then_some(out);
346        };
347        let editor = &mut self.sync(cx.memory::<AreaMemory>()).editor;
348        match action {
349            EditAction::Cut | EditAction::Copy => {
350                out.copy = editor.selected_text().map(str::to_owned);
351                out.changed = action == EditAction::Cut && out.copy.is_some() && editor.backspace();
352            }
353            EditAction::Paste => cx.run_action(Scope::Global, "paste"),
354            EditAction::SelectAll => editor.select_all(),
355        }
356        out.handled = true;
357        out.follow = true;
358        Some(out)
359    }
360
361    fn mouse(&self, memory: &mut AreaMemory, mouse: &MouseEvent, layout: &Layout) -> Outcome {
362        let mut out = Outcome { handled: true, ..Outcome::default() };
363        let metrics = layout.metrics(memory.scroll);
364        let bar_row = |bar: Rect| clamp_u16(mouse.y - bar.y);
365        let on_bar = layout.bar.is_some_and(|bar| bar.contains(mouse.x, mouse.y));
366        match mouse.kind {
367            MouseKind::ScrollUp => memory.scroll = memory.scroll.saturating_sub(usize::from(WHEEL_ROWS)),
368            MouseKind::ScrollDown => {
369                memory.scroll = (memory.scroll + usize::from(WHEEL_ROWS)).min(metrics.max_offset());
370            }
371            MouseKind::Down(MouseButton::Left) if on_bar => {
372                if let Some(bar) = layout.bar {
373                    memory.scroll = metrics.offset_at(bar_row(bar), bar.height);
374                }
375                memory.dragging_bar = true;
376                out.capture = true;
377            }
378            MouseKind::Drag(MouseButton::Left) if memory.dragging_bar => {
379                if let Some(bar) = layout.bar {
380                    memory.scroll = metrics.offset_at(bar_row(bar), bar.height);
381                }
382            }
383            MouseKind::Down(MouseButton::Left) | MouseKind::Drag(MouseButton::Left) => {
384                let dragging = matches!(mouse.kind, MouseKind::Drag(_));
385                if dragging && !memory.selecting {
386                    return Outcome::default();
387                }
388                let offset = Self::offset_under(memory, mouse, layout);
389                memory.editor.move_to_offset(offset, dragging);
390                memory.goal = None;
391                memory.selecting = true;
392                out.capture = !dragging;
393                out.follow = true;
394            }
395            MouseKind::Up(MouseButton::Left) => {
396                memory.selecting = false;
397                memory.dragging_bar = false;
398            }
399            _ => out.handled = false,
400        }
401        out
402    }
403}
404
405/// The padding of the text area from the theme.
406fn padding(env: &Env) -> Padding {
407    let (vertical, horizontal) = env.theme().style("text-area", None, &[]).pair("padding").unwrap_or((0, 1));
408    Padding::symmetric(vertical, horizontal)
409}
410
411impl<Msg: 'static> Widget<Msg> for TextArea<Msg> {
412    fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
413        let padding = padding(cx.env());
414        let width = available.width.saturating_sub(cells::sum([padding.horizontal(), self.gutter(&self.value), 1]));
415        let rows = text_rows::wrap(&self.value, width).len().clamp(MIN_ROWS, MAX_ROWS);
416        let height = clamp_u16(i32::try_from(rows).unwrap_or(i32::MAX)) + u16::from(self.counter);
417        Size::new(available.width, height.saturating_add(padding.vertical())).min(available)
418    }
419
420    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
421        let mut states = if self.disabled { vec![State::Disabled] } else { cx.states() };
422        if self.invalid {
423            states.push(State::Invalid);
424        }
425        let focused = states.contains(&State::Focus);
426        let area_style = cx.style("text-area", None, &states);
427        let surface = area_style.text();
428        cx.clear(area, surface.bg.unwrap_or_else(|| cx.color("raised")));
429        // The pillar runs down the whole left padding column; the text never slides.
430        if let Some(color) = area_style.color("pillar").filter(|_| area_style.padding().left >= 1) {
431            for row in 0..area.height {
432                cx.pillar(area.x, area.y + i32::from(row), color);
433            }
434        }
435        if !self.disabled {
436            cx.register_hit(area);
437            edit_menu::request_overlay(cx, area);
438        }
439        let (text, cursor, selection, last_edit) = {
440            let memory = self.sync(cx.memory::<AreaMemory>());
441            let editor = &memory.editor;
442            (editor.text().to_owned(), editor.cursor(), editor.selection(), memory.last_edit)
443        };
444        let layout = self.layout(cx.env(), area, &text);
445        let visible = layout.visible();
446        let (cursor_row, cursor_column) = text_rows::locate(&text, &layout.rows, cursor);
447        let scroll = {
448            let memory = cx.memory::<AreaMemory>();
449            if memory.follow {
450                if cursor_row < memory.scroll {
451                    memory.scroll = cursor_row;
452                } else if cursor_row >= memory.scroll + visible {
453                    memory.scroll = cursor_row + 1 - visible;
454                }
455                memory.follow = false;
456            }
457            memory.scroll = memory.scroll.min(layout.rows.len().saturating_sub(visible));
458            memory.scroll
459        };
460
461        if let Some(counter) = layout.counter {
462            let count = text.graphemes(true).count();
463            let label = self.max_length.map_or_else(|| count.to_string(), |max| format!("{count} / {max}"));
464            let style = cx.style("text-area-counter", None, &states).text();
465            let width = text::width(&label).min(counter.width);
466            cx.text(counter.right() - i32::from(width), counter.y, &label, style, width);
467        }
468
469        let text_style = CellStyle { bg: None, ..surface };
470        // An empty area keeps its placeholder in place; when focused the cursor sits on its first
471        // letter in inverted colours instead of pushing it aside.
472        let mut placeholder_head = None;
473        if text.is_empty() && !self.placeholder.is_empty() {
474            let placeholder = cx.style("text-input-placeholder", None, &states).text();
475            let budget = layout.text.width;
476            let shown = text::truncate(&self.placeholder, budget).into_owned();
477            cx.text(layout.text.x, layout.text.y, &shown, placeholder, budget);
478            placeholder_head = shown.graphemes(true).next().map(str::to_owned);
479        }
480        let selection_style = cx.style("text-input-selection", None, &states).text();
481        let cursor_line = text[..cursor].matches('\n').count();
482        for (index, range) in layout.rows.iter().enumerate().skip(scroll).take(visible) {
483            let y = layout.text.y + i32::try_from(index - scroll).unwrap_or(0);
484            if self.line_numbers && text_rows::starts_line(&text, range) {
485                let line = text[..range.start].matches('\n').count();
486                let number_states = if line == cursor_line && focused { vec![State::Selected] } else { Vec::new() };
487                let style = cx.style("text-area-line-number", None, &number_states).text();
488                let number = (line + 1).to_string();
489                let width = text::width(&number).min(layout.gutter.saturating_sub(1));
490                let x = layout.text.x - 1 - i32::from(width);
491                cx.text(x, y, &number, style, width);
492            }
493            let mut x = layout.text.x;
494            for (offset, grapheme) in text[range.clone()].grapheme_indices(true) {
495                let start = range.start + offset;
496                let width = text::grapheme_width(grapheme).max(1);
497                if x + i32::from(width) > layout.text.right() {
498                    break;
499                }
500                let selected = selection.as_ref().is_some_and(|selection| selection.contains(&start));
501                let style = if selected {
502                    CellStyle { fg: selection_style.fg.or(text_style.fg), bg: selection_style.bg, ..text_style }
503                } else {
504                    text_style
505                };
506                cx.text(x, y, grapheme, style, width);
507                x += i32::from(width);
508            }
509        }
510        if focused && (scroll..scroll + visible).contains(&cursor_row) {
511            let y = layout.text.y + i32::try_from(cursor_row - scroll).unwrap_or(0);
512            let x = layout.text.x + i32::from(cursor_column.min(layout.text.width.saturating_sub(1)));
513            let row_end = layout.rows.get(cursor_row).map_or(cursor, |row| row.end);
514            let glyph = text[cursor..row_end]
515                .graphemes(true)
516                .next()
517                .map(str::to_owned)
518                .or(placeholder_head)
519                .unwrap_or_else(|| " ".to_owned());
520            let blink = Blink { now: cx.now(), last_edit, period: cx.env().theme().motion().cursor_blink };
521            draw_cursor(cx, x, y, &glyph, blink, &states);
522        }
523        if let Some(bar) = layout.bar {
524            let dragging = cx.memory::<AreaMemory>().dragging_bar;
525            let active = dragging || cx.pointer().is_some_and(|(x, _)| x == bar.x);
526            scrollbar::paint(cx, bar, layout.metrics(scroll), active, None);
527        }
528    }
529
530    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
531        if self.disabled {
532            return false;
533        }
534        let now = cx.now();
535        let area = cx.area();
536        let text = self.sync(cx.memory::<AreaMemory>()).editor.text().to_owned();
537        let layout = self.layout(cx.env(), area, &text);
538        let menu = self.menu_event(cx, event, &layout);
539        let (out, value) = {
540            let memory = self.sync(cx.memory::<AreaMemory>());
541            let out = match event {
542                _ if let Some(out) = menu => out,
543                Event::Paste(pasted) => Outcome {
544                    handled: true,
545                    changed: memory.editor.insert_lines(pasted, self.max_length),
546                    follow: true,
547                    ..Outcome::default()
548                },
549                Event::Key(key) => self.key(memory, key, &layout),
550                Event::Mouse(mouse) => self.mouse(memory, mouse, &layout),
551                Event::PointerOutside => Outcome::default(),
552            };
553            if out.handled {
554                memory.last_edit = now;
555                memory.follow |= out.follow;
556            }
557            if out.changed {
558                memory.synced = Some(memory.editor.text().to_owned());
559            }
560            (out, memory.editor.text().to_owned())
561        };
562        if out.capture {
563            cx.capture_pointer();
564        }
565        if let Some(copied) = out.copy {
566            cx.copy(copied);
567        }
568        if out.changed
569            && let Some(message) = &self.on_change
570        {
571            cx.emit(message(value.clone()));
572        }
573        if out.submit
574            && let Some(message) = &self.on_submit
575        {
576            cx.emit(message(value));
577        }
578        out.handled
579    }
580
581    fn paint_overlay(&self, cx: &mut PaintCx<'_>, anchor: Rect) {
582        let selection = self.sync(cx.memory::<AreaMemory>()).editor.selection().is_some();
583        TextMenu::edit(cx.env(), selection, cx.can_paste()).paint_overlay(cx, anchor);
584    }
585
586    fn focusable(&self) -> bool {
587        !self.disabled
588    }
589}
590
591#[cfg(test)]
592mod tests {
593    use super::*;
594    use crate::runtime::{App, Command, Harness};
595    use crate::widget::{Length, View};
596
597    #[derive(Default)]
598    struct Demo {
599        value: String,
600        submitted: Option<String>,
601        numbers: bool,
602        counter: bool,
603        disabled: bool,
604        /// Extra columns beyond the usual 16.
605        wider: u16,
606    }
607
608    enum Msg {
609        Changed(String),
610        Submitted(String),
611    }
612
613    impl App for Demo {
614        type Msg = Msg;
615        fn update(&mut self, msg: Msg) -> Command<Msg> {
616            match msg {
617                Msg::Changed(value) => self.value = value,
618                Msg::Submitted(value) => self.submitted = Some(value),
619            }
620            Command::none()
621        }
622        fn view(&self, ui: &mut View<'_, Msg>) {
623            ui.add(
624                TextArea::new(&self.value)
625                    .placeholder("Release notes")
626                    .max_length(60)
627                    .line_numbers(self.numbers)
628                    .counter(self.counter)
629                    .disabled(self.disabled)
630                    .on_change(Msg::Changed)
631                    .on_submit(Msg::Submitted),
632            )
633            .width(Length::Cells(16 + self.wider))
634            .id("notes");
635        }
636    }
637
638    fn harness(value: &str) -> Harness<Demo> {
639        let mut h = Harness::new(Demo { value: value.into(), ..Demo::default() }, 16, 4);
640        h.set_reduced_motion(true);
641        h
642    }
643
644    #[test]
645    fn placeholder_then_typing_with_line_breaks_and_submit_on_ctrl_enter() {
646        let mut h = harness("");
647        assert_eq!(h.screen(), "  Release not…\n\n\n\n");
648        h.press("tab").type_text("fixed").press("enter").type_text("faster");
649        assert_eq!(h.app().value, "fixed\nfaster");
650        assert_eq!(h.screen(), "▌ fixed\n▌ faster\n▌\n\n");
651        h.press("ctrl+enter");
652        assert_eq!(h.app().submitted.as_deref(), Some("fixed\nfaster"));
653        h.paste("\r\nlast");
654        assert_eq!(h.app().value, "fixed\nfaster\nlast");
655    }
656
657    #[test]
658    fn wraps_words_and_up_down_keep_the_column() {
659        // Two cells wider than the other tests: typing `Y` must not rewrap the middle row.
660        let demo = Demo { value: "the canary deploy went well".into(), wider: 2, ..Demo::default() };
661        let mut h = Harness::new(demo, 18, 4);
662        h.set_reduced_motion(true);
663        assert_eq!(h.screen(), "  the canary\n  deploy went\n  well\n\n");
664        h.press("tab").press("ctrl+home").press("down");
665        for _ in 0..9 {
666            h.press("right");
667        }
668        // Down through the short last row to the end of the text and back up: column 9 is kept.
669        h.press("down").press("down").press("up").type_text("Y");
670        assert_eq!(h.app().value, "the canary deploy weYnt well");
671        h.press("home").type_text("Z").press("end").type_text("!");
672        assert_eq!(h.app().value, "the canary Zdeploy weYnt! well", "end stops before the wrapped space");
673    }
674
675    #[test]
676    fn selection_copy_cut_and_line_deletion() {
677        let mut h = harness("alpha\nbeta");
678        h.press("tab").press("ctrl+end").press("shift+up").press("ctrl+c");
679        assert_eq!(h.copied(), &["a\nbeta".to_owned()]);
680        h.press("ctrl+x");
681        assert_eq!(h.app().value, "alph");
682        h.press("ctrl+z").press("ctrl+end").press("ctrl+u");
683        assert_eq!(h.app().value, "alpha\n");
684        let mut h = harness("alpha\nbeta");
685        h.mouse(MouseKind::Down(MouseButton::Left), 5, 0);
686        h.mouse(MouseKind::Drag(MouseButton::Left), 4, 1);
687        h.mouse(MouseKind::Up(MouseButton::Left), 4, 1).press("ctrl+c");
688        assert_eq!(h.copied(), &["ha\nbe".to_owned()], "dragging across a line break selects it");
689    }
690
691    #[test]
692    fn scrolls_to_the_cursor_with_a_scrollbar_and_the_wheel() {
693        let text = "one\ntwo\nthree\nfour\nfive\nsix";
694        let mut h = harness(text);
695        let screen = h.screen();
696        assert!(screen.starts_with("  one"), "{screen}");
697        assert!(super::super::scrollbar::column(&h, 13).starts_with("##"), "{screen}");
698        h.press("tab").press("ctrl+end");
699        assert!(h.screen().contains("six"), "{}", h.screen());
700        assert!(!h.screen().contains("one"));
701        h.mouse(MouseKind::ScrollUp, 3, 1);
702        assert!(h.screen().contains("one"));
703        h.click(4, 1).type_text("X");
704        assert_eq!(h.app().value, "one\ntwXo\nthree\nfour\nfive\nsix");
705    }
706
707    #[test]
708    fn line_numbers_counter_limit_and_disabled() {
709        let mut h = Harness::new(Demo { value: "a\nb".into(), numbers: true, counter: true, ..Demo::default() }, 16, 4);
710        assert_eq!(h.screen(), "   1 a\n   2 b\n\n        3 / 60\n");
711        h.press("tab").press("ctrl+end").paste(&"x".repeat(80));
712        assert_eq!(h.app().value.chars().count(), 60);
713        let mut h = Harness::new(Demo { value: "a".into(), disabled: true, ..Demo::default() }, 16, 4);
714        h.press("tab").type_text("b");
715        assert_eq!(h.app().value, "a");
716        assert_eq!(h.fg(2, 0), h.env().theme().color("muted"));
717    }
718
719    // Selection, copying, the edit menu, paste sources and arrows with a selection.
720
721    fn roomy(value: &str) -> Harness<Demo> {
722        let mut h = Harness::new(Demo { value: value.into(), ..Demo::default() }, 30, 9);
723        h.set_reduced_motion(true);
724        h
725    }
726
727    fn right_click(h: &mut Harness<Demo>, x: i32, y: i32) {
728        h.mouse(MouseKind::Down(MouseButton::Right), x, y);
729        h.mouse(MouseKind::Up(MouseButton::Right), x, y);
730    }
731
732    #[test]
733    fn dragging_selects_its_own_text_and_releasing_copies_nothing() {
734        let mut h = roomy("alpha\nbeta");
735        let plain = h.bg(3, 1);
736        h.drag((2, 0), (4, 1));
737        assert!(h.copied().is_empty(), "releasing copies nothing");
738        assert_ne!(h.bg(3, 1), plain, "the selection is shown");
739        h.press("ctrl+c");
740        assert_eq!(h.copied(), ["alpha\nbe"]);
741    }
742
743    #[test]
744    fn right_click_opens_the_edit_menu() {
745        let mut h = roomy("alpha\nbeta");
746        right_click(&mut h, 3, 1);
747        let screen = h.screen();
748        let lines: Vec<&str> = screen.lines().collect();
749        assert_eq!(
750            &lines[2..6],
751            [
752                "▌    Cut           ctrl x",
753                "     Copy          ctrl c",
754                "     Paste         ctrl v",
755                "     Select all    ctrl a"
756            ],
757            "{screen}"
758        );
759        let muted = h.env().theme().color("muted");
760        assert_eq!((h.fg(5, 2), h.fg(5, 3), h.fg(5, 4)), (muted, muted, muted));
761        h.click_text("Select all");
762        right_click(&mut h, 3, 0);
763        assert_ne!(h.fg(5, 1), muted, "a right click inside the selection keeps it");
764        h.click_text("Cut");
765        assert_eq!((h.app().value.as_str(), h.clipboard()), ("", Some("alpha\nbeta")));
766        right_click(&mut h, 3, 0);
767        h.click_text("Paste");
768        assert_eq!(h.app().value, "alpha\nbeta", "line breaks survive the round trip");
769        right_click(&mut h, 4, 1);
770        h.press("esc").type_text("X");
771        assert_eq!(h.app().value, "alpha\nbeXta", "a right click elsewhere placed the cursor");
772    }
773
774    #[test]
775    fn paste_reads_the_system_clipboard_first() {
776        let mut h = roomy("");
777        h.set_system_clipboard(Some("one\ntwo")).press("tab").press("ctrl+v");
778        assert_eq!(h.app().value, "one\ntwo");
779    }
780
781    #[test]
782    fn arrows_with_a_selection_clear_it_and_move_on_from_its_end() {
783        let mut h = roomy("alpha\nbeta\ngamma");
784        h.press("tab").press("ctrl+home").press("shift+right").press("shift+right").press("right").type_text("R");
785        assert_eq!(h.app().value, "alpRha\nbeta\ngamma", "one past the right end");
786        h.press("ctrl+home").press("down").press("shift+right").press("shift+right").press("left").type_text("L");
787        assert_eq!(h.app().value, "alpRhaL\nbeta\ngamma", "one before the left end, across the line break");
788        h.press("ctrl+home").press("down").press("shift+right").press("shift+right").press("up").type_text("U");
789        assert_eq!(h.app().value, "UalpRhaL\nbeta\ngamma", "a row up from the upper end");
790        h.press("ctrl+home").press("down").press("shift+right").press("shift+right").press("down").type_text("D");
791        assert_eq!(h.app().value, "UalpRhaL\nbeta\ngaDmma", "a row down from the lower end");
792    }
793}