1use 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
23const MIN_ROWS: usize = 3;
25
26const MAX_ROWS: usize = 8;
28
29const MIN_NUMBER_DIGITS: u16 = 2;
31
32type TextMessage<Msg> = Box<dyn Fn(String) -> Msg>;
33
34pub 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 scroll: usize,
81 goal: Option<u16>,
83 follow: bool,
85 last_edit: Duration,
86 selecting: bool,
87 dragging_bar: bool,
88}
89
90struct Layout {
92 rows: Vec<std::ops::Range<usize>>,
93 text: Rect,
95 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#[derive(Default)]
113struct Outcome {
114 handled: bool,
115 changed: bool,
116 submit: bool,
117 copy: Option<String>,
118 capture: bool,
119 follow: bool,
121}
122
123impl<Msg: 'static> TextArea<Msg> {
124 #[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 #[must_use]
143 pub fn placeholder(mut self, text: impl Into<String>) -> Self {
144 self.placeholder = text.into();
145 self
146 }
147
148 #[must_use]
152 pub fn variant(mut self, variant: impl Into<String>) -> Self {
153 self.variant = Some(variant.into());
154 self
155 }
156
157 #[must_use]
159 pub fn invalid(mut self, invalid: bool) -> Self {
160 self.invalid = invalid;
161 self
162 }
163
164 #[must_use]
166 pub fn disabled(mut self, disabled: bool) -> Self {
167 self.disabled = disabled;
168 self
169 }
170
171 #[must_use]
173 pub fn max_length(mut self, max: usize) -> Self {
174 self.max_length = Some(max);
175 self
176 }
177
178 #[must_use]
180 pub fn line_numbers(mut self, on: bool) -> Self {
181 self.line_numbers = on;
182 self
183 }
184
185 #[must_use]
187 pub fn counter(mut self, on: bool) -> Self {
188 self.counter = on;
189 self
190 }
191
192 #[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 #[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 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 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 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 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
419fn 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 if !area_style.flag("see-through") {
444 cx.clear(area, surface.bg.unwrap_or_else(|| cx.color("raised")));
445 }
446 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 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 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 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 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 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 #[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 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}