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 cx.takes_text();
436 let mut states = if self.disabled { vec![State::Disabled] } else { cx.states() };
437 if self.invalid {
438 states.push(State::Invalid);
439 }
440 let focused = states.contains(&State::Focus);
441 let area_style = cx.style("text-area", self.variant.as_deref(), &states);
442 let surface = area_style.text();
443 if !area_style.flag("see-through") {
445 cx.clear(area, surface.bg.unwrap_or_else(|| cx.color("raised")));
446 }
447 if let Some(color) = area_style.color("pillar").filter(|_| area_style.padding().left >= 1) {
449 for row in 0..area.height {
450 cx.pillar(area.x, area.y + i32::from(row), color);
451 }
452 }
453 if !self.disabled {
454 cx.register_hit(area);
455 edit_menu::request_overlay(cx, area);
456 }
457 let (text, cursor, selection, last_edit) = {
458 let memory = self.sync(cx.memory::<AreaMemory>());
459 let editor = &memory.editor;
460 (editor.text().to_owned(), editor.cursor(), editor.selection(), memory.last_edit)
461 };
462 let layout = self.layout(cx.env(), area, &text);
463 let visible = layout.visible();
464 let (cursor_row, cursor_column) = text_rows::locate(&text, &layout.rows, cursor);
465 let scroll = {
466 let memory = cx.memory::<AreaMemory>();
467 if memory.follow {
468 if cursor_row < memory.scroll {
469 memory.scroll = cursor_row;
470 } else if cursor_row >= memory.scroll + visible {
471 memory.scroll = cursor_row + 1 - visible;
472 }
473 memory.follow = false;
474 }
475 memory.scroll = memory.scroll.min(layout.rows.len().saturating_sub(visible));
476 memory.scroll
477 };
478
479 if let Some(counter) = layout.counter {
480 let count = text.graphemes(true).count();
481 let label = self.max_length.map_or_else(|| count.to_string(), |max| format!("{count} / {max}"));
482 let style = cx.style("text-area-counter", None, &states).text();
483 let width = text::width(&label).min(counter.width);
484 cx.text(counter.right() - i32::from(width), counter.y, &label, style, width);
485 }
486
487 let text_style = CellStyle { bg: None, ..surface };
488 let mut placeholder_head = None;
491 if text.is_empty() && !self.placeholder.is_empty() {
492 let placeholder = cx.style("text-input-placeholder", None, &states).text();
493 let budget = layout.text.width;
494 let shown = text::truncate(&self.placeholder, budget).into_owned();
495 cx.text(layout.text.x, layout.text.y, &shown, placeholder, budget);
496 placeholder_head = shown.graphemes(true).next().map(str::to_owned);
497 }
498 let selection_style = cx.style("text-input-selection", None, &states).text();
499 let cursor_line = text[..cursor].matches('\n').count();
500 for (index, range) in layout.rows.iter().enumerate().skip(scroll).take(visible) {
501 let y = layout.text.y + i32::try_from(index - scroll).unwrap_or(0);
502 if self.line_numbers && text_rows::starts_line(&text, range) {
503 let line = text[..range.start].matches('\n').count();
504 let number_states = if line == cursor_line && focused { vec![State::Selected] } else { Vec::new() };
505 let style = cx.style("text-area-line-number", None, &number_states).text();
506 let number = (line + 1).to_string();
507 let width = text::width(&number).min(layout.gutter.saturating_sub(1));
508 let x = layout.text.x - 1 - i32::from(width);
509 cx.text(x, y, &number, style, width);
510 }
511 let mut x = layout.text.x;
512 for (offset, grapheme) in text[range.clone()].grapheme_indices(true) {
513 let start = range.start + offset;
514 let width = text::grapheme_width(grapheme).max(1);
515 if x + i32::from(width) > layout.text.right() {
516 break;
517 }
518 let selected = selection.as_ref().is_some_and(|selection| selection.contains(&start));
519 let style = if selected {
520 CellStyle { fg: selection_style.fg.or(text_style.fg), bg: selection_style.bg, ..text_style }
521 } else {
522 text_style
523 };
524 cx.text(x, y, grapheme, style, width);
525 x += i32::from(width);
526 }
527 }
528 if focused && (scroll..scroll + visible).contains(&cursor_row) {
529 let y = layout.text.y + i32::try_from(cursor_row - scroll).unwrap_or(0);
530 let x = layout.text.x + i32::from(cursor_column.min(layout.text.width.saturating_sub(1)));
531 let row_end = layout.rows.get(cursor_row).map_or(cursor, |row| row.end);
532 let glyph = text[cursor..row_end]
533 .graphemes(true)
534 .next()
535 .map(str::to_owned)
536 .or(placeholder_head)
537 .unwrap_or_else(|| " ".to_owned());
538 let blink = Blink { now: cx.now(), last_edit, period: cx.env().theme().motion().cursor_blink };
539 draw_cursor(cx, x, y, &glyph, blink, &states);
540 }
541 if let Some(bar) = layout.bar {
542 let dragging = cx.memory::<AreaMemory>().dragging_bar;
543 let active = dragging || cx.pointer().is_some_and(|(x, _)| x == bar.x);
544 scrollbar::paint(cx, bar, layout.metrics(scroll), active, None);
545 }
546 }
547
548 fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
549 if self.disabled {
550 return false;
551 }
552 let now = cx.now();
553 let area = cx.area();
554 let text = self.sync(cx.memory::<AreaMemory>()).editor.text().to_owned();
555 let layout = self.layout(cx.env(), area, &text);
556 let menu = self.menu_event(cx, event, &layout);
557 let (out, value) = {
558 let memory = self.sync(cx.memory::<AreaMemory>());
559 let out = match event {
560 _ if let Some(out) = menu => out,
561 Event::Paste(pasted) => Outcome {
562 handled: true,
563 changed: memory.editor.insert_lines(pasted, self.max_length),
564 follow: true,
565 ..Outcome::default()
566 },
567 Event::Key(key) => self.key(memory, key, &layout),
568 Event::Mouse(mouse) => self.mouse(memory, mouse, &layout),
569 Event::PointerOutside => Outcome::default(),
570 };
571 if out.handled {
572 memory.last_edit = now;
573 memory.follow |= out.follow;
574 }
575 if out.changed {
576 memory.synced = Some(memory.editor.text().to_owned());
577 }
578 (out, memory.editor.text().to_owned())
579 };
580 if out.capture {
581 cx.capture_pointer();
582 }
583 if let Some(copied) = out.copy {
584 cx.copy(copied);
585 }
586 if out.changed
587 && let Some(message) = &self.on_change
588 {
589 cx.emit(message(value.clone()));
590 }
591 if out.submit
592 && let Some(message) = &self.on_submit
593 {
594 cx.emit(message(value));
595 }
596 out.handled
597 }
598
599 fn paint_overlay(&self, cx: &mut PaintCx<'_>, anchor: Rect) {
600 let selection = self.sync(cx.memory::<AreaMemory>()).editor.selection().is_some();
601 TextMenu::edit(cx.env(), selection, cx.can_paste()).paint_overlay(cx, anchor);
602 }
603
604 fn focusable(&self) -> bool {
605 !self.disabled
606 }
607}
608
609#[cfg(test)]
610mod tests {
611 use super::*;
612 use crate::runtime::{App, Command, Harness};
613 use crate::widget::{Length, View};
614
615 #[derive(Default)]
616 struct Demo {
617 value: String,
618 submitted: Option<String>,
619 numbers: bool,
620 counter: bool,
621 disabled: bool,
622 wider: u16,
624 }
625
626 #[derive(Clone)]
627 enum Msg {
628 Changed(String),
629 Submitted(String),
630 }
631
632 impl App for Demo {
633 type Msg = Msg;
634 fn update(&mut self, msg: Msg) -> Command<Msg> {
635 match msg {
636 Msg::Changed(value) => self.value = value,
637 Msg::Submitted(value) => self.submitted = Some(value),
638 }
639 Command::none()
640 }
641 fn view(&self, ui: &mut View<'_, Msg>) {
642 ui.add(
643 TextArea::new(&self.value)
644 .placeholder("Release notes")
645 .max_length(60)
646 .line_numbers(self.numbers)
647 .counter(self.counter)
648 .disabled(self.disabled)
649 .on_change(Msg::Changed)
650 .on_submit(Msg::Submitted),
651 )
652 .width(Length::Cells(16 + self.wider))
653 .id("notes");
654 }
655 }
656
657 fn harness(value: &str) -> Harness<Demo> {
658 let mut h = Harness::new(Demo { value: value.into(), ..Demo::default() }, 16, 4);
659 h.set_reduced_motion(true);
660 h
661 }
662
663 #[test]
664 fn placeholder_then_typing_with_line_breaks_and_submit_on_ctrl_enter() {
665 let mut h = harness("");
666 assert_eq!(h.screen(), " Release not…\n\n\n\n");
667 h.press("tab").type_text("fixed").press("enter").type_text("faster");
668 assert_eq!(h.app().value, "fixed\nfaster");
669 assert_eq!(h.screen(), "▌ fixed\n▌ faster\n▌\n\n");
670 h.press("ctrl+enter");
671 assert_eq!(h.app().submitted.as_deref(), Some("fixed\nfaster"));
672 h.paste("\r\nlast");
673 assert_eq!(h.app().value, "fixed\nfaster\nlast");
674 }
675
676 #[test]
677 fn wraps_words_and_up_down_keep_the_column() {
678 let demo = Demo { value: "the canary deploy went well".into(), wider: 2, ..Demo::default() };
680 let mut h = Harness::new(demo, 18, 4);
681 h.set_reduced_motion(true);
682 assert_eq!(h.screen(), " the canary\n deploy went\n well\n\n");
683 h.press("tab").press("ctrl+home").press("down");
684 for _ in 0..9 {
685 h.press("right");
686 }
687 h.press("down").press("down").press("up").type_text("Y");
689 assert_eq!(h.app().value, "the canary deploy weYnt well");
690 h.press("home").type_text("Z").press("end").type_text("!");
691 assert_eq!(h.app().value, "the canary Zdeploy weYnt! well", "end stops before the wrapped space");
692 }
693
694 #[test]
695 fn selection_copy_cut_and_line_deletion() {
696 let mut h = harness("alpha\nbeta");
697 h.press("tab").press("ctrl+end").press("shift+up").press("ctrl+c");
698 assert_eq!(h.copied(), &["a\nbeta".to_owned()]);
699 h.press("ctrl+x");
700 assert_eq!(h.app().value, "alph");
701 h.press("ctrl+z").press("ctrl+end").press("ctrl+u");
702 assert_eq!(h.app().value, "alpha\n");
703 let mut h = harness("alpha\nbeta");
704 h.mouse(MouseKind::Down(MouseButton::Left), 5, 0);
705 h.mouse(MouseKind::Drag(MouseButton::Left), 4, 1);
706 h.mouse(MouseKind::Up(MouseButton::Left), 4, 1).press("ctrl+c");
707 assert_eq!(h.copied(), &["ha\nbe".to_owned()], "dragging across a line break selects it");
708 }
709
710 #[test]
711 fn scrolls_to_the_cursor_with_a_scrollbar_and_the_wheel() {
712 let text = "one\ntwo\nthree\nfour\nfive\nsix";
713 let mut h = harness(text);
714 let screen = h.screen();
715 assert!(screen.starts_with(" one"), "{screen}");
716 assert!(super::super::scrollbar::column(&h, 13).starts_with("##"), "{screen}");
717 h.press("tab").press("ctrl+end");
718 assert!(h.screen().contains("six"), "{}", h.screen());
719 assert!(!h.screen().contains("one"));
720 h.mouse(MouseKind::ScrollUp, 3, 1);
721 assert!(h.screen().contains("one"));
722 h.click(4, 1).type_text("X");
723 assert_eq!(h.app().value, "one\ntwXo\nthree\nfour\nfive\nsix");
724 }
725
726 #[test]
727 fn line_numbers_counter_limit_and_disabled() {
728 let mut h = Harness::new(Demo { value: "a\nb".into(), numbers: true, counter: true, ..Demo::default() }, 16, 4);
729 assert_eq!(h.screen(), " 1 a\n 2 b\n\n 3 / 60\n");
730 h.press("tab").press("ctrl+end").paste(&"x".repeat(80));
731 assert_eq!(h.app().value.chars().count(), 60);
732 let mut h = Harness::new(Demo { value: "a".into(), disabled: true, ..Demo::default() }, 16, 4);
733 h.press("tab").type_text("b");
734 assert_eq!(h.app().value, "a");
735 assert_eq!(h.fg(2, 0), h.env().theme().color("muted"));
736 }
737
738 struct Paper {
740 value: String,
741 variant: Option<&'static str>,
742 invalid: bool,
743 }
744
745 impl App for Paper {
746 type Msg = Msg;
747 fn update(&mut self, msg: Msg) -> Command<Msg> {
748 if let Msg::Changed(value) = msg {
749 self.value = value;
750 }
751 Command::none()
752 }
753 fn view(&self, ui: &mut View<'_, Msg>) {
754 ui.add_with(crate::widgets::Panel::new(), |ui| {
755 let mut area = TextArea::new(&self.value).invalid(self.invalid).on_change(Msg::Changed);
756 if let Some(variant) = self.variant {
757 area = area.variant(variant);
758 }
759 ui.add(area).height(Length::Cells(3));
760 })
761 .fill();
762 }
763 }
764
765 #[derive(Debug, PartialEq)]
769 struct Looks {
770 surface: Option<crate::color::Rgb>,
771 rest: Option<crate::color::Rgb>,
772 focused: Option<crate::color::Rgb>,
773 pillar: (Option<char>, Option<crate::color::Rgb>),
774 cursor: Option<crate::color::Rgb>,
775 selected: Option<crate::color::Rgb>,
776 }
777
778 fn looks(variant: Option<&'static str>) -> (Looks, String) {
779 let mut h = Harness::new(Paper { value: "hello paper".into(), variant, invalid: false }, 30, 7);
780 h.set_reduced_motion(true);
781 let (x, y) = h.find("hello").expect("drawn");
782 let cell = |x: i32| u16::try_from(x).expect("on screen");
783 let (row, far) = (cell(y), cell(x + 13));
784 let rest = h.bg(far, row);
785 h.click(x + 2, y);
786 let focused = h.bg(far, row);
787 let glyph =
788 h.screen().lines().nth(usize::from(row)).and_then(|line| line.chars().nth(usize::from(cell(x - 2))));
789 let pillar = (glyph, h.fg(cell(x - 2), row));
790 let cursor = h.bg(cell(x + 2), row);
791 h.type_text("!");
792 h.press("ctrl+a");
793 let selected = h.bg(cell(x + 4), row);
794 let surface = h.env().theme().color("surface");
795 (Looks { surface, rest, focused, pillar, cursor, selected }, h.app().value.clone())
796 }
797
798 #[test]
799 fn a_plain_area_takes_the_panel_tone_and_still_shows_focus() {
800 let (plain, typed) = looks(Some("plain"));
801 let surface = plain.surface;
802 assert_eq!(typed, "he!llo paper", "the click placed the cursor");
803 assert_eq!((plain.rest, plain.focused), (surface, surface), "{plain:?}");
804 assert_eq!(plain.pillar.0, Some('▌'), "focus is shown by the pillar");
805 let (field, _) = looks(None);
806 assert_ne!(field.rest, surface, "the default area is a raised field");
807 assert_ne!(field.focused, surface);
808 assert_eq!(plain.pillar, field.pillar, "the same pillar");
809 assert_eq!(
810 (plain.cursor, plain.selected),
811 (field.cursor, field.selected),
812 "cursor and selection keep their look"
813 );
814 }
815
816 #[test]
817 fn a_plain_area_shows_invalid_text_with_a_danger_pillar_at_rest() {
818 let h = Harness::new(Paper { value: "hello".into(), variant: Some("plain"), invalid: true }, 30, 7);
819 let (x, y) = h.find("hello").expect("drawn");
820 let (column, row) = (u16::try_from(x - 2).expect("on screen"), u16::try_from(y).expect("on screen"));
821 assert_eq!(h.fg(column, row), h.env().theme().color("danger"));
822 assert_eq!(h.bg(column + 10, row), h.env().theme().color("surface"));
823 }
824
825 fn roomy(value: &str) -> Harness<Demo> {
828 let mut h = Harness::new(Demo { value: value.into(), ..Demo::default() }, 30, 9);
829 h.set_reduced_motion(true);
830 h
831 }
832
833 fn right_click(h: &mut Harness<Demo>, x: i32, y: i32) {
834 h.mouse(MouseKind::Down(MouseButton::Right), x, y);
835 h.mouse(MouseKind::Up(MouseButton::Right), x, y);
836 }
837
838 #[test]
839 fn dragging_selects_its_own_text_and_releasing_copies_nothing() {
840 let mut h = roomy("alpha\nbeta");
841 let plain = h.bg(3, 1);
842 h.drag((2, 0), (4, 1));
843 assert!(h.copied().is_empty(), "releasing copies nothing");
844 assert_ne!(h.bg(3, 1), plain, "the selection is shown");
845 h.press("ctrl+c");
846 assert_eq!(h.copied(), ["alpha\nbe"]);
847 }
848
849 #[test]
850 fn right_click_opens_the_edit_menu() {
851 let mut h = roomy("alpha\nbeta");
852 right_click(&mut h, 3, 1);
853 let screen = h.screen();
854 let lines: Vec<&str> = screen.lines().collect();
855 assert_eq!(
856 &lines[2..6],
857 [
858 "▌ Cut ctrl x",
859 " Copy ctrl c",
860 " Paste ctrl v",
861 " Select all ctrl a"
862 ],
863 "{screen}"
864 );
865 let muted = h.env().theme().color("muted");
866 assert_eq!((h.fg(5, 2), h.fg(5, 3), h.fg(5, 4)), (muted, muted, muted));
867 h.click_text("Select all");
868 right_click(&mut h, 3, 0);
869 assert_ne!(h.fg(5, 1), muted, "a right click inside the selection keeps it");
870 h.click_text("Cut");
871 assert_eq!((h.app().value.as_str(), h.clipboard()), ("", Some("alpha\nbeta")));
872 right_click(&mut h, 3, 0);
873 h.click_text("Paste");
874 assert_eq!(h.app().value, "alpha\nbeta", "line breaks survive the round trip");
875 right_click(&mut h, 4, 1);
876 h.press("esc").type_text("X");
877 assert_eq!(h.app().value, "alpha\nbeXta", "a right click elsewhere placed the cursor");
878 }
879
880 #[test]
881 fn paste_reads_the_system_clipboard_first() {
882 let mut h = roomy("");
883 h.set_system_clipboard(Some("one\ntwo")).press("tab").press("ctrl+v");
884 assert_eq!(h.app().value, "one\ntwo");
885 }
886
887 #[test]
888 fn arrows_with_a_selection_clear_it_and_move_on_from_its_end() {
889 let mut h = roomy("alpha\nbeta\ngamma");
890 h.press("tab").press("ctrl+home").press("shift+right").press("shift+right").press("right").type_text("R");
891 assert_eq!(h.app().value, "alpRha\nbeta\ngamma", "one past the right end");
892 h.press("ctrl+home").press("down").press("shift+right").press("shift+right").press("left").type_text("L");
893 assert_eq!(h.app().value, "alpRhaL\nbeta\ngamma", "one before the left end, across the line break");
894 h.press("ctrl+home").press("down").press("shift+right").press("shift+right").press("up").type_text("U");
895 assert_eq!(h.app().value, "UalpRhaL\nbeta\ngamma", "a row up from the upper end");
896 h.press("ctrl+home").press("down").press("shift+right").press("shift+right").press("down").type_text("D");
897 assert_eq!(h.app().value, "UalpRhaL\nbeta\ngaDmma", "a row down from the lower end");
898 }
899}