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