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