use std::time::Duration;
use unicode_segmentation::UnicodeSegmentation;
use super::cells;
use super::edit_menu::{self, EditAction, TextMenu};
use super::editor::Editor;
use super::rows::WHEEL_ROWS;
use super::scrollbar::{self, ScrollMetrics};
use super::text_input::{Blink, draw_cursor};
use super::text_rows;
use crate::env::Env;
use crate::event::{Event, KeyEvent, MouseButton, MouseEvent, MouseKind};
use crate::geometry::{Padding, Rect, Size, clamp_u16};
use crate::keymap::{Key, Modifiers, Scope};
use crate::style::CellStyle;
use crate::text;
use crate::theme::State;
use crate::widget::{EventCx, MeasureCx, PaintCx, Widget};
const MIN_ROWS: usize = 3;
const MAX_ROWS: usize = 8;
const MIN_NUMBER_DIGITS: u16 = 2;
type TextMessage<Msg> = Box<dyn Fn(String) -> Msg>;
pub struct TextArea<Msg> {
value: String,
placeholder: String,
invalid: bool,
disabled: bool,
max_length: Option<usize>,
line_numbers: bool,
counter: bool,
on_change: Option<TextMessage<Msg>>,
on_submit: Option<TextMessage<Msg>>,
}
#[derive(Debug, Default)]
struct AreaMemory {
editor: Editor,
synced: Option<String>,
scroll: usize,
goal: Option<u16>,
follow: bool,
last_edit: Duration,
selecting: bool,
dragging_bar: bool,
}
struct Layout {
rows: Vec<std::ops::Range<usize>>,
text: Rect,
gutter: u16,
bar: Option<Rect>,
counter: Option<Rect>,
}
impl Layout {
fn visible(&self) -> usize {
usize::from(self.text.height.max(1))
}
fn metrics(&self, offset: usize) -> ScrollMetrics {
ScrollMetrics { total: self.rows.len(), visible: self.visible(), offset }
}
}
#[derive(Default)]
struct Outcome {
handled: bool,
changed: bool,
submit: bool,
copy: Option<String>,
capture: bool,
follow: bool,
}
impl<Msg: 'static> TextArea<Msg> {
#[must_use]
pub fn new(value: impl Into<String>) -> Self {
Self {
value: value.into(),
placeholder: String::new(),
invalid: false,
disabled: false,
max_length: None,
line_numbers: false,
counter: false,
on_change: None,
on_submit: None,
}
}
#[must_use]
pub fn placeholder(mut self, text: impl Into<String>) -> Self {
self.placeholder = text.into();
self
}
#[must_use]
pub fn invalid(mut self, invalid: bool) -> Self {
self.invalid = invalid;
self
}
#[must_use]
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
#[must_use]
pub fn max_length(mut self, max: usize) -> Self {
self.max_length = Some(max);
self
}
#[must_use]
pub fn line_numbers(mut self, on: bool) -> Self {
self.line_numbers = on;
self
}
#[must_use]
pub fn counter(mut self, on: bool) -> Self {
self.counter = on;
self
}
#[must_use]
pub fn on_change(mut self, message: impl Fn(String) -> Msg + 'static) -> Self {
self.on_change = Some(Box::new(message));
self
}
#[must_use]
pub fn on_submit(mut self, message: impl Fn(String) -> Msg + 'static) -> Self {
self.on_submit = Some(Box::new(message));
self
}
fn sync<'m>(&self, memory: &'m mut AreaMemory) -> &'m mut AreaMemory {
if memory.synced.as_deref() != Some(self.value.as_str()) {
if memory.editor.text() != self.value {
memory.editor.replace_all(&self.value);
memory.goal = None;
}
memory.synced = Some(self.value.clone());
}
memory
}
fn gutter(&self, text: &str) -> u16 {
if !self.line_numbers {
return 0;
}
let lines = text.matches('\n').count() + 1;
let digits = clamp_u16(i32::try_from(lines.to_string().len()).unwrap_or(i32::MAX));
digits.max(MIN_NUMBER_DIGITS) + 1
}
fn layout(&self, env: &Env, area: Rect, text: &str) -> Layout {
let inner = area.inset(padding(env));
let counter =
(self.counter && inner.height >= 2).then(|| Rect::new(inner.x, inner.bottom() - 1, inner.width, 1));
let height = inner.height - u16::from(counter.is_some());
let gutter = self.gutter(text).min(inner.width);
let full = inner.width - gutter;
let x = inner.x + i32::from(gutter);
let rows = text_rows::wrap(text, full.saturating_sub(1));
if rows.len() > usize::from(height) && full > 2 {
let bar = Rect::new(inner.right() - 1, inner.y, 1, height);
let rows = text_rows::wrap(text, full - 2);
return Layout { rows, text: Rect::new(x, inner.y, full - 1, height), gutter, bar: Some(bar), counter };
}
Layout { rows, text: Rect::new(x, inner.y, full, height), gutter, bar: None, counter }
}
fn key(&self, memory: &mut AreaMemory, key: &KeyEvent, layout: &Layout) -> Outcome {
let mut out = Outcome { handled: true, follow: true, ..Outcome::default() };
let max = self.max_length;
let mods = key.chord.mods;
let ctrl = mods.ctrl && !mods.alt;
let text = memory.editor.text().to_owned();
let cursor = memory.editor.cursor();
let from = match (memory.editor.selection(), key.chord.key) {
(Some(range), Key::Up | Key::PageUp) if !mods.shift => range.start,
(Some(range), Key::Down | Key::PageDown) if !mods.shift => range.end,
_ => cursor,
};
let (row, column) = text_rows::locate(&text, &layout.rows, from);
let vertical = |memory: &mut AreaMemory, delta: isize| {
let goal = memory.goal.unwrap_or(column);
let target = row.checked_add_signed(delta).filter(|target| *target < layout.rows.len());
let offset = match target {
Some(target) => text_rows::offset_at(&text, &layout.rows, target, goal),
None if delta < 0 => 0,
None => text.len(),
};
memory.editor.move_to_offset(offset, mods.shift);
memory.goal = Some(goal);
};
let page = isize::try_from(layout.visible()).unwrap_or(1);
match key.chord.key {
Key::Up | Key::Down | Key::PageUp | Key::PageDown if !ctrl => {
let delta = match key.chord.key {
Key::Up => -1,
Key::Down => 1,
Key::PageUp => -page,
_ => page,
};
vertical(memory, delta);
return out;
}
_ => memory.goal = None,
}
let editor = &mut memory.editor;
match key.chord.key {
Key::Char(c) if ctrl => match (c, mods.shift) {
('a', false) => editor.select_all(),
('z', false) => out.changed = editor.undo(),
('y', false) | ('z', true) => out.changed = editor.redo(),
('w', false) => out.changed = editor.delete_word_back(),
('u', false) => {
let line_start = text[..cursor].rfind('\n').map_or(0, |index| index + 1);
out.changed = editor.delete_range(line_start..cursor);
}
('c', false) | ('x', false) => {
out.copy = editor.selected_text().map(str::to_owned);
if c == 'x' && out.copy.is_some() {
out.changed = editor.backspace();
}
out.handled = out.copy.is_some();
}
_ => out.handled = false,
},
Key::Enter if ctrl && !mods.shift => {
out.submit = self.on_submit.is_some();
out.handled = out.submit;
}
Key::Enter if mods == Modifiers::default() => out.changed = editor.insert_lines("\n", max),
Key::Left => editor.move_left(mods.shift, mods.ctrl),
Key::Right => editor.move_right(mods.shift, mods.ctrl),
Key::Home if mods.ctrl => editor.move_home(mods.shift),
Key::End if mods.ctrl => editor.move_end(mods.shift),
Key::Home => editor.move_to_offset(layout.rows.get(row).map_or(0, |r| r.start), mods.shift),
Key::End => editor.move_to_offset(text_rows::offset_at(&text, &layout.rows, row, u16::MAX), mods.shift),
Key::Backspace if mods == Modifiers::default() || mods.ctrl => {
out.changed = if mods.ctrl { editor.delete_word_back() } else { editor.backspace() };
}
Key::Delete => out.changed = editor.delete(),
_ => match key.text {
Some(c) if !mods.ctrl && !mods.alt => out.changed = editor.insert_lines(&c.to_string(), max),
_ => out.handled = false,
},
}
out
}
fn offset_under(memory: &AreaMemory, mouse: &MouseEvent, layout: &Layout) -> usize {
let relative = isize::try_from(mouse.y - layout.text.y).unwrap_or(0);
let last = layout.rows.len().saturating_sub(1);
let row = memory.scroll.checked_add_signed(relative).unwrap_or(0).min(last);
let column = clamp_u16(mouse.x - layout.text.x);
text_rows::offset_at(memory.editor.text(), &layout.rows, row, column)
}
fn menu_event(&self, cx: &mut EventCx<'_, Msg>, event: &Event, layout: &Layout) -> Option<Outcome> {
let open = edit_menu::is_open(cx);
if !open && !edit_menu::asks(event) {
return None;
}
if let Event::Mouse(mouse) = event
&& !open
{
let memory = self.sync(cx.memory::<AreaMemory>());
let offset = Self::offset_under(memory, mouse, layout);
if !memory.editor.selection().is_some_and(|range| range.contains(&offset)) {
memory.editor.move_to_offset(offset, false);
memory.goal = None;
}
}
let selection = self.sync(cx.memory::<AreaMemory>()).editor.selection().is_some();
let (used, chosen) = TextMenu::edit(cx.env(), selection, cx.can_paste()).event(cx, event);
if used && !open {
cx.probe_clipboard();
}
let mut out = Outcome { handled: used, ..Outcome::default() };
let Some(action) = chosen else {
return used.then_some(out);
};
let editor = &mut self.sync(cx.memory::<AreaMemory>()).editor;
match action {
EditAction::Cut | EditAction::Copy => {
out.copy = editor.selected_text().map(str::to_owned);
out.changed = action == EditAction::Cut && out.copy.is_some() && editor.backspace();
}
EditAction::Paste => cx.run_action(Scope::Global, "paste"),
EditAction::SelectAll => editor.select_all(),
}
out.handled = true;
out.follow = true;
Some(out)
}
fn mouse(&self, memory: &mut AreaMemory, mouse: &MouseEvent, layout: &Layout) -> Outcome {
let mut out = Outcome { handled: true, ..Outcome::default() };
let metrics = layout.metrics(memory.scroll);
let bar_row = |bar: Rect| clamp_u16(mouse.y - bar.y);
let on_bar = layout.bar.is_some_and(|bar| bar.contains(mouse.x, mouse.y));
match mouse.kind {
MouseKind::ScrollUp => memory.scroll = memory.scroll.saturating_sub(usize::from(WHEEL_ROWS)),
MouseKind::ScrollDown => {
memory.scroll = (memory.scroll + usize::from(WHEEL_ROWS)).min(metrics.max_offset());
}
MouseKind::Down(MouseButton::Left) if on_bar => {
if let Some(bar) = layout.bar {
memory.scroll = metrics.offset_at(bar_row(bar), bar.height);
}
memory.dragging_bar = true;
out.capture = true;
}
MouseKind::Drag(MouseButton::Left) if memory.dragging_bar => {
if let Some(bar) = layout.bar {
memory.scroll = metrics.offset_at(bar_row(bar), bar.height);
}
}
MouseKind::Down(MouseButton::Left) | MouseKind::Drag(MouseButton::Left) => {
let dragging = matches!(mouse.kind, MouseKind::Drag(_));
if dragging && !memory.selecting {
return Outcome::default();
}
let offset = Self::offset_under(memory, mouse, layout);
memory.editor.move_to_offset(offset, dragging);
memory.goal = None;
memory.selecting = true;
out.capture = !dragging;
out.follow = true;
}
MouseKind::Up(MouseButton::Left) => {
memory.selecting = false;
memory.dragging_bar = false;
}
_ => out.handled = false,
}
out
}
}
fn padding(env: &Env) -> Padding {
let (vertical, horizontal) = env.theme().style("text-area", None, &[]).pair("padding").unwrap_or((0, 1));
Padding::symmetric(vertical, horizontal)
}
impl<Msg: 'static> Widget<Msg> for TextArea<Msg> {
fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
let padding = padding(cx.env());
let width = available.width.saturating_sub(cells::sum([padding.horizontal(), self.gutter(&self.value), 1]));
let rows = text_rows::wrap(&self.value, width).len().clamp(MIN_ROWS, MAX_ROWS);
let height = clamp_u16(i32::try_from(rows).unwrap_or(i32::MAX)) + u16::from(self.counter);
Size::new(available.width, height.saturating_add(padding.vertical())).min(available)
}
fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
let mut states = if self.disabled { vec![State::Disabled] } else { cx.states() };
if self.invalid {
states.push(State::Invalid);
}
let focused = states.contains(&State::Focus);
let area_style = cx.style("text-area", None, &states);
let surface = area_style.text();
cx.clear(area, surface.bg.unwrap_or_else(|| cx.color("raised")));
if let Some(color) = area_style.color("pillar").filter(|_| area_style.padding().left >= 1) {
for row in 0..area.height {
cx.pillar(area.x, area.y + i32::from(row), color);
}
}
if !self.disabled {
cx.register_hit(area);
edit_menu::request_overlay(cx, area);
}
let (text, cursor, selection, last_edit) = {
let memory = self.sync(cx.memory::<AreaMemory>());
let editor = &memory.editor;
(editor.text().to_owned(), editor.cursor(), editor.selection(), memory.last_edit)
};
let layout = self.layout(cx.env(), area, &text);
let visible = layout.visible();
let (cursor_row, cursor_column) = text_rows::locate(&text, &layout.rows, cursor);
let scroll = {
let memory = cx.memory::<AreaMemory>();
if memory.follow {
if cursor_row < memory.scroll {
memory.scroll = cursor_row;
} else if cursor_row >= memory.scroll + visible {
memory.scroll = cursor_row + 1 - visible;
}
memory.follow = false;
}
memory.scroll = memory.scroll.min(layout.rows.len().saturating_sub(visible));
memory.scroll
};
if let Some(counter) = layout.counter {
let count = text.graphemes(true).count();
let label = self.max_length.map_or_else(|| count.to_string(), |max| format!("{count} / {max}"));
let style = cx.style("text-area-counter", None, &states).text();
let width = text::width(&label).min(counter.width);
cx.text(counter.right() - i32::from(width), counter.y, &label, style, width);
}
let text_style = CellStyle { bg: None, ..surface };
let mut placeholder_head = None;
if text.is_empty() && !self.placeholder.is_empty() {
let placeholder = cx.style("text-input-placeholder", None, &states).text();
let budget = layout.text.width;
let shown = text::truncate(&self.placeholder, budget).into_owned();
cx.text(layout.text.x, layout.text.y, &shown, placeholder, budget);
placeholder_head = shown.graphemes(true).next().map(str::to_owned);
}
let selection_style = cx.style("text-input-selection", None, &states).text();
let cursor_line = text[..cursor].matches('\n').count();
for (index, range) in layout.rows.iter().enumerate().skip(scroll).take(visible) {
let y = layout.text.y + i32::try_from(index - scroll).unwrap_or(0);
if self.line_numbers && text_rows::starts_line(&text, range) {
let line = text[..range.start].matches('\n').count();
let number_states = if line == cursor_line && focused { vec![State::Selected] } else { Vec::new() };
let style = cx.style("text-area-line-number", None, &number_states).text();
let number = (line + 1).to_string();
let width = text::width(&number).min(layout.gutter.saturating_sub(1));
let x = layout.text.x - 1 - i32::from(width);
cx.text(x, y, &number, style, width);
}
let mut x = layout.text.x;
for (offset, grapheme) in text[range.clone()].grapheme_indices(true) {
let start = range.start + offset;
let width = text::grapheme_width(grapheme).max(1);
if x + i32::from(width) > layout.text.right() {
break;
}
let selected = selection.as_ref().is_some_and(|selection| selection.contains(&start));
let style = if selected {
CellStyle { fg: selection_style.fg.or(text_style.fg), bg: selection_style.bg, ..text_style }
} else {
text_style
};
cx.text(x, y, grapheme, style, width);
x += i32::from(width);
}
}
if focused && (scroll..scroll + visible).contains(&cursor_row) {
let y = layout.text.y + i32::try_from(cursor_row - scroll).unwrap_or(0);
let x = layout.text.x + i32::from(cursor_column.min(layout.text.width.saturating_sub(1)));
let row_end = layout.rows.get(cursor_row).map_or(cursor, |row| row.end);
let glyph = text[cursor..row_end]
.graphemes(true)
.next()
.map(str::to_owned)
.or(placeholder_head)
.unwrap_or_else(|| " ".to_owned());
let blink = Blink { now: cx.now(), last_edit, period: cx.env().theme().motion().cursor_blink };
draw_cursor(cx, x, y, &glyph, blink, &states);
}
if let Some(bar) = layout.bar {
let dragging = cx.memory::<AreaMemory>().dragging_bar;
let active = dragging || cx.pointer().is_some_and(|(x, _)| x == bar.x);
scrollbar::paint(cx, bar, layout.metrics(scroll), active, None);
}
}
fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
if self.disabled {
return false;
}
let now = cx.now();
let area = cx.area();
let text = self.sync(cx.memory::<AreaMemory>()).editor.text().to_owned();
let layout = self.layout(cx.env(), area, &text);
let menu = self.menu_event(cx, event, &layout);
let (out, value) = {
let memory = self.sync(cx.memory::<AreaMemory>());
let out = match event {
_ if let Some(out) = menu => out,
Event::Paste(pasted) => Outcome {
handled: true,
changed: memory.editor.insert_lines(pasted, self.max_length),
follow: true,
..Outcome::default()
},
Event::Key(key) => self.key(memory, key, &layout),
Event::Mouse(mouse) => self.mouse(memory, mouse, &layout),
Event::PointerOutside => Outcome::default(),
};
if out.handled {
memory.last_edit = now;
memory.follow |= out.follow;
}
if out.changed {
memory.synced = Some(memory.editor.text().to_owned());
}
(out, memory.editor.text().to_owned())
};
if out.capture {
cx.capture_pointer();
}
if let Some(copied) = out.copy {
cx.copy(copied);
}
if out.changed
&& let Some(message) = &self.on_change
{
cx.emit(message(value.clone()));
}
if out.submit
&& let Some(message) = &self.on_submit
{
cx.emit(message(value));
}
out.handled
}
fn paint_overlay(&self, cx: &mut PaintCx<'_>, anchor: Rect) {
let selection = self.sync(cx.memory::<AreaMemory>()).editor.selection().is_some();
TextMenu::edit(cx.env(), selection, cx.can_paste()).paint_overlay(cx, anchor);
}
fn focusable(&self) -> bool {
!self.disabled
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::runtime::{App, Command, Harness};
use crate::widget::{Length, View};
#[derive(Default)]
struct Demo {
value: String,
submitted: Option<String>,
numbers: bool,
counter: bool,
disabled: bool,
wider: u16,
}
enum Msg {
Changed(String),
Submitted(String),
}
impl App for Demo {
type Msg = Msg;
fn update(&mut self, msg: Msg) -> Command<Msg> {
match msg {
Msg::Changed(value) => self.value = value,
Msg::Submitted(value) => self.submitted = Some(value),
}
Command::none()
}
fn view(&self, ui: &mut View<'_, Msg>) {
ui.add(
TextArea::new(&self.value)
.placeholder("Release notes")
.max_length(60)
.line_numbers(self.numbers)
.counter(self.counter)
.disabled(self.disabled)
.on_change(Msg::Changed)
.on_submit(Msg::Submitted),
)
.width(Length::Cells(16 + self.wider))
.id("notes");
}
}
fn harness(value: &str) -> Harness<Demo> {
let mut h = Harness::new(Demo { value: value.into(), ..Demo::default() }, 16, 4);
h.set_reduced_motion(true);
h
}
#[test]
fn placeholder_then_typing_with_line_breaks_and_submit_on_ctrl_enter() {
let mut h = harness("");
assert_eq!(h.screen(), " Release not…\n\n\n\n");
h.press("tab").type_text("fixed").press("enter").type_text("faster");
assert_eq!(h.app().value, "fixed\nfaster");
assert_eq!(h.screen(), "▌ fixed\n▌ faster\n▌\n\n");
h.press("ctrl+enter");
assert_eq!(h.app().submitted.as_deref(), Some("fixed\nfaster"));
h.paste("\r\nlast");
assert_eq!(h.app().value, "fixed\nfaster\nlast");
}
#[test]
fn wraps_words_and_up_down_keep_the_column() {
let demo = Demo { value: "the canary deploy went well".into(), wider: 2, ..Demo::default() };
let mut h = Harness::new(demo, 18, 4);
h.set_reduced_motion(true);
assert_eq!(h.screen(), " the canary\n deploy went\n well\n\n");
h.press("tab").press("ctrl+home").press("down");
for _ in 0..9 {
h.press("right");
}
h.press("down").press("down").press("up").type_text("Y");
assert_eq!(h.app().value, "the canary deploy weYnt well");
h.press("home").type_text("Z").press("end").type_text("!");
assert_eq!(h.app().value, "the canary Zdeploy weYnt! well", "end stops before the wrapped space");
}
#[test]
fn selection_copy_cut_and_line_deletion() {
let mut h = harness("alpha\nbeta");
h.press("tab").press("ctrl+end").press("shift+up").press("ctrl+c");
assert_eq!(h.copied(), &["a\nbeta".to_owned()]);
h.press("ctrl+x");
assert_eq!(h.app().value, "alph");
h.press("ctrl+z").press("ctrl+end").press("ctrl+u");
assert_eq!(h.app().value, "alpha\n");
let mut h = harness("alpha\nbeta");
h.mouse(MouseKind::Down(MouseButton::Left), 5, 0);
h.mouse(MouseKind::Drag(MouseButton::Left), 4, 1);
h.mouse(MouseKind::Up(MouseButton::Left), 4, 1).press("ctrl+c");
assert_eq!(h.copied(), &["ha\nbe".to_owned()], "dragging across a line break selects it");
}
#[test]
fn scrolls_to_the_cursor_with_a_scrollbar_and_the_wheel() {
let text = "one\ntwo\nthree\nfour\nfive\nsix";
let mut h = harness(text);
let screen = h.screen();
assert!(screen.starts_with(" one"), "{screen}");
assert!(super::super::scrollbar::column(&h, 13).starts_with("##"), "{screen}");
h.press("tab").press("ctrl+end");
assert!(h.screen().contains("six"), "{}", h.screen());
assert!(!h.screen().contains("one"));
h.mouse(MouseKind::ScrollUp, 3, 1);
assert!(h.screen().contains("one"));
h.click(4, 1).type_text("X");
assert_eq!(h.app().value, "one\ntwXo\nthree\nfour\nfive\nsix");
}
#[test]
fn line_numbers_counter_limit_and_disabled() {
let mut h = Harness::new(Demo { value: "a\nb".into(), numbers: true, counter: true, ..Demo::default() }, 16, 4);
assert_eq!(h.screen(), " 1 a\n 2 b\n\n 3 / 60\n");
h.press("tab").press("ctrl+end").paste(&"x".repeat(80));
assert_eq!(h.app().value.chars().count(), 60);
let mut h = Harness::new(Demo { value: "a".into(), disabled: true, ..Demo::default() }, 16, 4);
h.press("tab").type_text("b");
assert_eq!(h.app().value, "a");
assert_eq!(h.fg(2, 0), h.env().theme().color("muted"));
}
fn roomy(value: &str) -> Harness<Demo> {
let mut h = Harness::new(Demo { value: value.into(), ..Demo::default() }, 30, 9);
h.set_reduced_motion(true);
h
}
fn right_click(h: &mut Harness<Demo>, x: i32, y: i32) {
h.mouse(MouseKind::Down(MouseButton::Right), x, y);
h.mouse(MouseKind::Up(MouseButton::Right), x, y);
}
#[test]
fn dragging_selects_its_own_text_and_releasing_copies_nothing() {
let mut h = roomy("alpha\nbeta");
let plain = h.bg(3, 1);
h.drag((2, 0), (4, 1));
assert!(h.copied().is_empty(), "releasing copies nothing");
assert_ne!(h.bg(3, 1), plain, "the selection is shown");
h.press("ctrl+c");
assert_eq!(h.copied(), ["alpha\nbe"]);
}
#[test]
fn right_click_opens_the_edit_menu() {
let mut h = roomy("alpha\nbeta");
right_click(&mut h, 3, 1);
let screen = h.screen();
let lines: Vec<&str> = screen.lines().collect();
assert_eq!(
&lines[2..6],
[
"▌ Cut ctrl x",
" Copy ctrl c",
" Paste ctrl v",
" Select all ctrl a"
],
"{screen}"
);
let muted = h.env().theme().color("muted");
assert_eq!((h.fg(5, 2), h.fg(5, 3), h.fg(5, 4)), (muted, muted, muted));
h.click_text("Select all");
right_click(&mut h, 3, 0);
assert_ne!(h.fg(5, 1), muted, "a right click inside the selection keeps it");
h.click_text("Cut");
assert_eq!((h.app().value.as_str(), h.clipboard()), ("", Some("alpha\nbeta")));
right_click(&mut h, 3, 0);
h.click_text("Paste");
assert_eq!(h.app().value, "alpha\nbeta", "line breaks survive the round trip");
right_click(&mut h, 4, 1);
h.press("esc").type_text("X");
assert_eq!(h.app().value, "alpha\nbeXta", "a right click elsewhere placed the cursor");
}
#[test]
fn paste_reads_the_system_clipboard_first() {
let mut h = roomy("");
h.set_system_clipboard(Some("one\ntwo")).press("tab").press("ctrl+v");
assert_eq!(h.app().value, "one\ntwo");
}
#[test]
fn arrows_with_a_selection_clear_it_and_move_on_from_its_end() {
let mut h = roomy("alpha\nbeta\ngamma");
h.press("tab").press("ctrl+home").press("shift+right").press("shift+right").press("right").type_text("R");
assert_eq!(h.app().value, "alpRha\nbeta\ngamma", "one past the right end");
h.press("ctrl+home").press("down").press("shift+right").press("shift+right").press("left").type_text("L");
assert_eq!(h.app().value, "alpRhaL\nbeta\ngamma", "one before the left end, across the line break");
h.press("ctrl+home").press("down").press("shift+right").press("shift+right").press("up").type_text("U");
assert_eq!(h.app().value, "UalpRhaL\nbeta\ngamma", "a row up from the upper end");
h.press("ctrl+home").press("down").press("shift+right").press("shift+right").press("down").type_text("D");
assert_eq!(h.app().value, "UalpRhaL\nbeta\ngaDmma", "a row down from the lower end");
}
}