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 crate::event::{Event, KeyEvent, MouseButton, MouseKind};
11use crate::geometry::{Padding, Rect, Size, clamp_u16};
12use crate::keymap::{Key, Modifiers, Scope};
13use crate::style::CellStyle;
14use crate::text;
15use crate::theme::State;
16use crate::widget::{EventCx, MeasureCx, PaintCx, Widget};
17
18type TextMessage<Msg> = Box<dyn Fn(String) -> Msg>;
19
20pub(crate) struct Edit {
22 pub(crate) handled: bool,
24 pub(crate) changed: Option<String>,
26 pub(crate) submit: bool,
28}
29
30pub struct TextInput<Msg> {
52 value: String,
53 placeholder: String,
54 password: bool,
55 invalid: bool,
56 disabled: bool,
57 max_length: Option<usize>,
58 on_change: Option<TextMessage<Msg>>,
59 on_submit: Option<TextMessage<Msg>>,
60 accept: Option<Box<dyn Fn(char) -> bool>>,
61}
62
63#[derive(Debug, Default)]
64struct InputMemory {
65 editor: Editor,
66 synced: Option<String>,
67 scroll: usize,
68 last_edit: Duration,
69 dragging: bool,
70}
71
72impl<Msg: 'static> TextInput<Msg> {
73 #[must_use]
75 pub fn new(value: impl Into<String>) -> Self {
76 Self {
77 value: value.into(),
78 placeholder: String::new(),
79 password: false,
80 invalid: false,
81 disabled: false,
82 max_length: None,
83 on_change: None,
84 on_submit: None,
85 accept: None,
86 }
87 }
88
89 #[must_use]
91 pub fn placeholder(mut self, text: impl Into<String>) -> Self {
92 self.placeholder = text.into();
93 self
94 }
95
96 #[must_use]
98 pub fn password(mut self, password: bool) -> Self {
99 self.password = password;
100 self
101 }
102
103 #[must_use]
105 pub fn invalid(mut self, invalid: bool) -> Self {
106 self.invalid = invalid;
107 self
108 }
109
110 #[must_use]
112 pub fn disabled(mut self, disabled: bool) -> Self {
113 self.disabled = disabled;
114 self
115 }
116
117 #[must_use]
119 pub fn max_length(mut self, max: usize) -> Self {
120 self.max_length = Some(max);
121 self
122 }
123
124 #[must_use]
126 pub fn on_change(mut self, message: impl Fn(String) -> Msg + 'static) -> Self {
127 self.on_change = Some(Box::new(message));
128 self
129 }
130
131 #[must_use]
133 pub fn on_submit(mut self, message: impl Fn(String) -> Msg + 'static) -> Self {
134 self.on_submit = Some(Box::new(message));
135 self
136 }
137
138 pub(crate) fn accept(mut self, accept: impl Fn(char) -> bool + 'static) -> Self {
140 self.accept = Some(Box::new(accept));
141 self
142 }
143
144 fn accepted(&self, text: &str) -> String {
146 text.chars().filter(|c| self.accept.as_ref().is_none_or(|accept| accept(*c))).collect()
147 }
148
149 fn sync<'m>(&self, memory: &'m mut InputMemory) -> &'m mut InputMemory {
150 if memory.synced.as_deref() != Some(self.value.as_str()) {
151 if memory.editor.text() != self.value {
152 memory.editor.replace_all(&self.value);
153 }
154 memory.synced = Some(self.value.clone());
155 }
156 memory
157 }
158
159 fn shown(&self, text: &str, mask: &str) -> Vec<String> {
161 if self.password {
162 text.graphemes(true).map(|_| mask.to_owned()).collect()
163 } else {
164 text.graphemes(true).map(str::to_owned).collect()
165 }
166 }
167
168 fn text_row(prompt_width: u16, area: Rect, padding: Padding) -> Rect {
171 let left = padding.left.saturating_add(prompt_width);
172 Rect::new(
173 area.x + i32::from(left),
174 area.y + i32::from(padding.top),
175 area.width.saturating_sub(left.saturating_add(padding.left)),
176 1,
177 )
178 }
179}
180
181fn grapheme_index(text: &str, byte: usize) -> usize {
183 text[..byte].graphemes(true).count()
184}
185
186impl<Msg: 'static> Widget<Msg> for TextInput<Msg> {
187 fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
188 let style = cx.env().theme().style("text-input", None, &[]);
189 let (vertical, horizontal) = style.pair("padding").unwrap_or((0, 1));
190 let prompt = text::width(&cx.env().icons().glyph("prompt")) + 1;
191 let content = text::width(&self.value).max(text::width(&self.placeholder)).max(12).saturating_add(1);
192 Size::new(
193 cells::sum([content, prompt, horizontal.saturating_mul(2)]),
194 vertical.saturating_mul(2).saturating_add(1),
195 )
196 .min(available)
197 }
198
199 fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
200 let mut states = if self.disabled { vec![State::Disabled] } else { cx.states() };
201 if self.invalid {
202 states.push(State::Invalid);
203 }
204 let focused = states.contains(&State::Focus);
205 let style = cx.style("text-input", None, &states);
206 let surface = style.text();
207 cx.clear(area, surface.bg.unwrap_or_else(|| cx.color("raised")));
208 if !self.disabled {
209 cx.register_hit(area);
210 edit_menu::request_overlay(cx, area);
211 }
212 let padding = style.padding();
213 if let Some(color) = style.color("pillar").filter(|_| padding.left >= 1) {
216 cx.pillar(area.x, area.y + i32::from(padding.top), color);
217 }
218 let prompt_glyph = cx.env().icons().glyph("prompt").into_owned();
219 let prompt_style = cx.style("text-input-prompt", None, &states).text();
220 let prompt_x = area.x + i32::from(padding.left);
221 let y = area.y + i32::from(padding.top);
222 let prompt_width = cx.text(prompt_x, y, &prompt_glyph, prompt_style, area.width) + 1;
223 let field = Self::text_row(prompt_width, area, padding);
224
225 let mask = cx.env().icons().glyph("mask").into_owned();
226 let now = cx.now();
227 let blink = cx.env().theme().motion().cursor_blink;
228 let (glyphs, cursor_index, selection, last_edit) = {
229 let memory = self.sync(cx.memory::<InputMemory>());
230 let text = memory.editor.text();
231 let cursor_index = grapheme_index(text, memory.editor.cursor());
232 let selection =
233 memory.editor.selection().map(|r| grapheme_index(text, r.start)..grapheme_index(text, r.end));
234 (self.shown(text, &mask), cursor_index, selection, memory.last_edit)
235 };
236
237 if glyphs.is_empty() && !focused {
238 let placeholder = cx.style("text-input-placeholder", None, &states).text();
239 let shown = text::truncate(&self.placeholder, field.width).into_owned();
240 cx.text(field.x, field.y, &shown, placeholder, field.width);
241 return;
242 }
243 let placeholder_head = if glyphs.is_empty() && !self.placeholder.is_empty() {
246 let placeholder = cx.style("text-input-placeholder", None, &states).text();
247 let shown = text::truncate(&self.placeholder, field.width).into_owned();
248 cx.text(field.x, field.y, &shown, placeholder, field.width);
249 shown.graphemes(true).next().map(str::to_owned)
250 } else {
251 None
252 };
253
254 let widths: Vec<u16> = glyphs.iter().map(|g| text::grapheme_width(g).max(1)).collect();
255 let scroll = {
256 let memory = cx.memory::<InputMemory>();
257 let mut scroll = memory.scroll.min(cursor_index);
258 let cells = |from: usize, to: usize| widths[from..to].iter().map(|w| u32::from(*w)).sum::<u32>();
259 while scroll < cursor_index && cells(scroll, cursor_index) + 1 > u32::from(field.width) {
260 scroll += 1;
261 }
262 memory.scroll = scroll;
263 scroll
264 };
265
266 let selection_style = cx.style("text-input-selection", None, &states).text();
267 let mut text_style = surface;
268 text_style.bg = None;
269 let mut x = field.x;
270 for (index, glyph) in glyphs.iter().enumerate().skip(scroll) {
271 let width = widths[index];
272 if x + i32::from(width) > field.right() {
273 break;
274 }
275 let style = if selection.as_ref().is_some_and(|r| r.contains(&index)) {
276 CellStyle { fg: selection_style.fg.or(text_style.fg), bg: selection_style.bg, ..text_style }
277 } else {
278 text_style
279 };
280 cx.text(x, field.y, glyph, style, width);
281 if index == cursor_index && focused {
282 draw_cursor(cx, x, field.y, glyph, Blink { now, last_edit, period: blink }, &states);
283 }
284 x += i32::from(width);
285 }
286 if focused && cursor_index == glyphs.len() && x < field.right() {
287 let glyph = placeholder_head.as_deref().unwrap_or(" ");
288 draw_cursor(cx, x, field.y, glyph, Blink { now, last_edit, period: blink }, &states);
289 }
290 }
291
292 fn paint_overlay(&self, cx: &mut PaintCx<'_>, anchor: Rect) {
293 let selection = self.sync(cx.memory::<InputMemory>()).editor.selection().is_some();
294 self.menu(cx.env(), selection, cx.can_paste()).paint_overlay(cx, anchor);
295 }
296
297 fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
298 let edit = self.edit(cx, event);
299 if let (Some(value), Some(message)) = (edit.changed, &self.on_change) {
300 cx.emit(message(value));
301 }
302 if edit.submit
303 && let Some(message) = &self.on_submit
304 {
305 let value = cx.memory::<InputMemory>().editor.text().to_owned();
306 cx.emit(message(value));
307 }
308 edit.handled
309 }
310
311 fn focusable(&self) -> bool {
312 !self.disabled
313 }
314}
315
316impl<Msg: 'static> TextInput<Msg> {
317 fn menu(&self, env: &crate::env::Env, selection: bool, can_paste: bool) -> TextMenu<EditAction> {
319 TextMenu::edit(env, selection && !self.password, can_paste)
320 }
321
322 fn index_at(memory: &InputMemory, area: Rect, text_left: u16, x: i32) -> usize {
324 let column = usize::from(clamp_u16(x - area.x - i32::from(text_left)));
325 let mut cells = 0usize;
326 let mut index = memory.scroll;
327 for grapheme in memory.editor.text().graphemes(true).skip(memory.scroll) {
328 let width = usize::from(text::grapheme_width(grapheme).max(1));
329 if cells + width / 2 >= column {
330 break;
331 }
332 cells += width;
333 index += 1;
334 }
335 index
336 }
337
338 fn menu_event(&self, cx: &mut EventCx<'_, Msg>, event: &Event, text_left: u16) -> Option<Edit> {
341 let open = edit_menu::is_open(cx);
342 if !open && !edit_menu::asks(event) {
343 return None;
344 }
345 let area = cx.area();
346 if let Event::Mouse(mouse) = event
347 && !open
348 {
349 let memory = self.sync(cx.memory::<InputMemory>());
351 let index = Self::index_at(memory, area, text_left, mouse.x);
352 let text = memory.editor.text();
353 let inside = memory
354 .editor
355 .selection()
356 .is_some_and(|r| (grapheme_index(text, r.start)..grapheme_index(text, r.end)).contains(&index));
357 if !inside {
358 memory.editor.move_to_grapheme(index, false);
359 }
360 }
361 let selection = self.sync(cx.memory::<InputMemory>()).editor.selection().is_some();
362 let (used, chosen) = self.menu(cx.env(), selection, cx.can_paste()).event(cx, event);
363 if used && !open {
364 cx.probe_clipboard();
365 }
366 let mut edit = Edit { handled: used, changed: None, submit: false };
367 let Some(action) = chosen else {
368 return used.then_some(edit);
369 };
370 let now = cx.now();
371 let memory = self.sync(cx.memory::<InputMemory>());
372 memory.last_edit = now;
373 let editor = &mut memory.editor;
374 let mut copy = None;
375 match action {
376 EditAction::Cut | EditAction::Copy if !self.password => {
377 copy = editor.selected_text().map(str::to_owned);
378 if action == EditAction::Cut && copy.is_some() && editor.backspace() {
379 edit.changed = Some(editor.text().to_owned());
380 memory.synced = edit.changed.clone();
381 }
382 }
383 EditAction::Cut | EditAction::Copy | EditAction::Paste => {}
384 EditAction::SelectAll => editor.select_all(),
385 }
386 if action == EditAction::Paste {
387 cx.run_action(Scope::Global, "paste");
388 }
389 if let Some(text) = copy {
390 cx.copy(text);
391 }
392 edit.handled = true;
393 Some(edit)
394 }
395
396 fn key(&self, editor: &mut Editor, key: &KeyEvent) -> KeyEdit {
398 let mods = key.chord.mods;
399 let ctrl = mods.ctrl && !mods.alt;
400 let mut edit = KeyEdit { handled: true, ..KeyEdit::default() };
401 match key.chord.key {
402 Key::Char(c) if ctrl => match (c, mods.shift) {
403 ('a', false) => editor.select_all(),
404 ('z', false) => edit.changed = editor.undo(),
405 ('y', false) | ('z', true) => edit.changed = editor.redo(),
406 ('w', false) => edit.changed = editor.delete_word_back(),
407 ('u', false) => edit.changed = editor.delete_to_start(),
408 ('c', false) | ('x', false) if !self.password => {
410 edit.copy = editor.selected_text().map(str::to_owned);
411 if c == 'x' && edit.copy.is_some() {
412 edit.changed = editor.backspace();
413 }
414 edit.handled = edit.copy.is_some();
415 }
416 _ => edit.handled = false,
417 },
418 Key::Left => editor.move_left(mods.shift, mods.ctrl),
419 Key::Right => editor.move_right(mods.shift, mods.ctrl),
420 Key::Home => editor.move_home(mods.shift),
421 Key::End => editor.move_end(mods.shift),
422 Key::Backspace if mods == Modifiers::default() || mods.ctrl => {
423 edit.changed = if mods.ctrl { editor.delete_word_back() } else { editor.backspace() };
424 }
425 Key::Delete => edit.changed = editor.delete(),
426 Key::Enter if mods == Modifiers::default() => {
427 edit.submit = self.on_submit.is_some();
428 edit.handled = edit.submit;
429 }
430 _ => match key.text {
431 Some(c) if !mods.ctrl && !mods.alt => {
432 edit.changed = editor.insert(&self.accepted(&c.to_string()), self.max_length);
433 }
434 _ => edit.handled = false,
435 },
436 }
437 edit
438 }
439
440 pub(crate) fn edit(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> Edit {
443 let unused = Edit { handled: false, changed: None, submit: false };
444 if self.disabled {
445 return unused;
446 }
447 let max = self.max_length;
448 let now = cx.now();
449 let area = cx.area();
450 let text_left = {
451 let padding = cx.env().theme().style("text-input", None, &[]).pair("padding").unwrap_or((0, 1)).1;
452 cells::sum([padding, text::width(&cx.env().icons().glyph("prompt")), 1])
453 };
454 if let Some(edit) = self.menu_event(cx, event, text_left) {
455 return edit;
456 }
457 let (handled, changed, submit, copy) = {
458 let memory = self.sync(cx.memory::<InputMemory>());
459 let editor = &mut memory.editor;
460 let mut changed = false;
461 let mut submit = false;
462 let mut copy = None;
463 let handled = match event {
464 Event::Paste(text) => {
465 changed = editor.insert(&self.accepted(&text.replace(['\n', '\r'], " ")), max);
466 true
467 }
468 Event::Key(key) => {
469 let edit = self.key(editor, key);
470 (changed, submit, copy) = (edit.changed, edit.submit, edit.copy);
471 edit.handled
472 }
473 Event::Mouse(mouse) => match mouse.kind {
474 MouseKind::Down(MouseButton::Left) | MouseKind::Drag(MouseButton::Left) => {
475 let dragging = matches!(mouse.kind, MouseKind::Drag(_));
476 let index = Self::index_at(memory, area, text_left, mouse.x);
477 memory.editor.move_to_grapheme(index, dragging);
478 memory.dragging = true;
479 true
480 }
481 MouseKind::Up(MouseButton::Left) => {
482 memory.dragging = false;
483 true
484 }
485 _ => false,
486 },
487 Event::PointerOutside => false,
488 };
489 if handled {
490 memory.last_edit = now;
491 }
492 if changed {
493 memory.synced = Some(memory.editor.text().to_owned());
494 }
495 (handled, changed.then(|| memory.editor.text().to_owned()), submit, copy)
496 };
497 if let Event::Mouse(mouse) = event
498 && mouse.kind == MouseKind::Down(MouseButton::Left)
499 {
500 cx.capture_pointer();
501 }
502 if let Some(text) = copy {
503 cx.copy(text);
504 }
505 Edit { handled, changed, submit }
506 }
507}
508
509#[derive(Default)]
511struct KeyEdit {
512 handled: bool,
513 changed: bool,
514 submit: bool,
515 copy: Option<String>,
516}
517
518#[derive(Debug, Clone, Copy)]
520pub(crate) struct Blink {
521 pub(crate) now: Duration,
522 pub(crate) last_edit: Duration,
523 pub(crate) period: Duration,
524}
525
526pub(crate) fn draw_cursor(cx: &mut PaintCx<'_>, x: i32, y: i32, glyph: &str, blink: Blink, states: &[State]) {
529 let since = blink.now.saturating_sub(blink.last_edit);
530 let period = blink.period.max(Duration::from_millis(1));
531 let phase = since.as_millis() / period.as_millis();
532 let next = period.saturating_mul(u32::try_from(phase + 1).unwrap_or(u32::MAX)).saturating_sub(since);
533 cx.request_frame_in(next);
534 if phase.is_multiple_of(2) {
535 let style = cx.style("text-input-cursor", None, states).text();
536 cx.text(x, y, glyph, style, text::width(glyph).max(1));
537 }
538}
539
540#[cfg(test)]
541mod tests {
542 use super::*;
543 use crate::runtime::{App, Command, Harness};
544 use crate::widget::View;
545
546 #[derive(Default)]
547 struct Demo {
548 value: String,
549 submitted: Option<String>,
550 password: bool,
551 }
552
553 enum Msg {
554 Changed(String),
555 Submitted(String),
556 }
557
558 impl App for Demo {
559 type Msg = Msg;
560 fn update(&mut self, msg: Msg) -> Command<Msg> {
561 match msg {
562 Msg::Changed(value) => self.value = value,
563 Msg::Submitted(value) => self.submitted = Some(value),
564 }
565 Command::none()
566 }
567 fn view(&self, ui: &mut View<'_, Msg>) {
568 ui.add(
569 TextInput::new(&self.value)
570 .placeholder("Project name")
571 .password(self.password)
572 .max_length(20)
573 .on_change(Msg::Changed)
574 .on_submit(Msg::Submitted),
575 )
576 .fill_width()
577 .id("name");
578 }
579 }
580
581 #[test]
582 fn types_edits_and_submits() {
583 let mut h = Harness::new(Demo::default(), 30, 1);
584 assert_eq!(h.screen(), " ❯ Project name\n");
585 h.press("tab").type_text("quvyta framework");
586 assert_eq!(h.app().value, "quvyta framework");
587 h.press("ctrl+w").press("ctrl+w");
588 assert_eq!(h.app().value, "");
589 h.press("ctrl+z");
590 assert_eq!(h.app().value, "quvyta ");
591 h.press("enter");
592 assert_eq!(h.app().submitted.as_deref(), Some("quvyta "));
593 }
594
595 #[test]
596 fn selection_copy_and_paste() {
597 let mut h = Harness::new(Demo { value: "hello world".into(), ..Demo::default() }, 30, 1);
598 h.press("tab").press("ctrl+shift+left").press("ctrl+c");
599 assert_eq!(h.copied(), &["world".to_owned()]);
600 h.paste("there");
601 assert_eq!(h.app().value, "hello there");
602 }
603
604 #[test]
605 fn scrolls_long_text_and_masks_passwords() {
606 let mut h = Harness::new(Demo { password: true, ..Demo::default() }, 12, 1);
607 h.press("tab").type_text("abcdefghijklmn");
608 let screen = h.screen();
609 assert_eq!(h.app().value, "abcdefghijklmn");
610 assert!(screen.starts_with("▌ ❯ •••••"), "{screen}");
611 assert!(!screen.contains('a'));
612 }
613
614 fn right_click(h: &mut Harness<Demo>, x: i32, y: i32) {
615 h.mouse(MouseKind::Down(MouseButton::Right), x, y);
616 h.mouse(MouseKind::Up(MouseButton::Right), x, y);
617 }
618
619 fn menu_harness(value: &str, password: bool) -> Harness<Demo> {
620 let mut h = Harness::new(Demo { value: value.into(), password, ..Demo::default() }, 30, 7);
621 h.set_reduced_motion(true);
622 h
623 }
624
625 #[test]
626 fn right_click_opens_an_edit_menu_whose_entries_need_a_selection_or_a_clipboard() {
627 let mut h = menu_harness("hello world", false);
628 right_click(&mut h, 12, 0);
629 assert_eq!(
630 h.screen(),
631 "▌ ❯ hello world\n Cut ctrl x\n Copy ctrl c\n Paste ctrl v\n Select all ctrl a\n\n\n"
632 );
633 let theme = h.env().theme();
634 let muted = theme.color("muted");
635 assert_eq!((h.fg(8, 1), h.fg(8, 2), h.fg(8, 3)), (muted, muted, muted), "nothing selected, nothing to paste");
636 assert_ne!(h.fg(8, 4), muted, "Select all always works");
637 h.press("down").press("enter");
638 assert!(!h.screen().contains("Cut"), "choosing closes the menu");
639 right_click(&mut h, 12, 0);
640 assert_ne!(h.fg(8, 1), muted, "a right click inside the selection keeps it: Cut is enabled");
641 h.click_text("Copy");
642 assert_eq!(h.clipboard(), Some("hello world"));
643 right_click(&mut h, 12, 0);
644 assert_ne!(h.fg(8, 3), muted, "now there is something to paste");
645 h.click_text("Cut");
646 assert_eq!(h.app().value, "");
647 right_click(&mut h, 6, 0);
648 h.click_text("Paste");
649 assert_eq!(h.app().value, "hello world");
650 assert!(h.is_focused("name"));
651 }
652
653 #[test]
654 fn right_click_outside_the_selection_places_the_cursor_first() {
655 let mut h = menu_harness("hello world", false);
656 h.press("tab").press("ctrl+shift+left");
657 right_click(&mut h, 4, 0);
658 let theme = h.env().theme();
659 assert_eq!(h.fg(8, 1), theme.color("muted"), "the selection is gone: Cut is disabled");
660 h.press("esc").type_text("X");
661 assert_eq!(h.app().value, "Xhello world");
662 }
663
664 #[test]
665 fn paste_reads_the_system_clipboard_before_the_last_copy_inside_the_application() {
666 let mut h = menu_harness("hello", false);
667 h.press("tab").press("ctrl+a").press("ctrl+c").press("end");
668 h.set_system_clipboard(Some(" from web"));
669 right_click(&mut h, 20, 0);
670 h.click_text("Paste");
671 assert_eq!(h.app().value, "hello from web");
672 h.set_system_clipboard(None).press("ctrl+v");
673 assert_eq!(h.app().value, "hello from webhello", "an empty system clipboard falls back");
674 }
675
676 #[test]
677 fn shift_f10_and_the_menu_key_open_it_under_the_field() {
678 let mut h = menu_harness("hello", false);
679 h.press("tab").press("shift+f10");
680 assert!(h.screen().lines().nth(1).is_some_and(|line| line.contains("Cut")), "{}", h.screen());
681 h.press("esc");
682 assert!(!h.screen().contains("Cut"));
683 h.press("menu").press("up").press("enter").type_text("!");
684 assert_eq!(h.app().value, "!", "↑ wrapped to Select all");
685 }
686
687 #[test]
688 fn the_menu_fits_a_narrow_screen_in_ascii() {
689 let mut h = Harness::new(Demo { value: "hello".into(), ..Demo::default() }, 18, 6);
690 h.set_reduced_motion(true);
691 h.set_glyph_mode(crate::icons::GlyphMode::Ascii);
692 right_click(&mut h, 15, 0);
693 assert_eq!(h.screen(), " > hello\n Cut ctrl x\n Copy ctrl c\n Paste ctrl v\n Selec… ctrl a\n\n");
696 assert_ne!(h.bg(0, 0), h.bg(1, 0), "the pillar cell stands out from the field");
697 }
698
699 #[test]
700 fn password_fields_never_copy() {
701 let mut h = menu_harness("secret", true);
702 h.press("tab").press("ctrl+a").press("ctrl+c").press("ctrl+x");
703 assert!(h.copied().is_empty());
704 assert_eq!(h.app().value, "secret");
705 right_click(&mut h, 5, 0);
706 let theme = h.env().theme();
707 assert_eq!((h.fg(8, 1), h.fg(8, 2)), (theme.color("muted"), theme.color("muted")));
708 }
709
710 #[test]
711 fn click_places_cursor_and_value_changes_resync() {
712 let mut h = Harness::new(Demo { value: "abcd".into(), ..Demo::default() }, 30, 1);
713 h.click(5, 0).type_text("X");
714 assert_eq!(h.app().value, "aXbcd");
715 }
716}