1use std::ops::Range;
4use std::time::Duration;
5
6use unicode_segmentation::UnicodeSegmentation;
7
8use super::cells;
9use super::edit_menu::{self, EditAction, TextMenu};
10use super::editor::Editor;
11use crate::event::{Event, KeyEvent, MouseButton, MouseKind};
12use crate::geometry::{Padding, Rect, Size, clamp_u16};
13use crate::keymap::{Key, Modifiers, Scope};
14use crate::style::CellStyle;
15use crate::text;
16use crate::theme::State;
17use crate::widget::{EventCx, MeasureCx, PaintCx, Widget};
18
19type TextMessage<Msg> = Box<dyn Fn(String) -> Msg>;
20
21pub(crate) struct Edit {
23 pub(crate) handled: bool,
25 pub(crate) changed: Option<String>,
27 pub(crate) submit: bool,
29}
30
31pub struct TextInput<Msg> {
56 value: String,
57 placeholder: String,
58 password: bool,
59 invalid: bool,
60 disabled: bool,
61 max_length: Option<usize>,
62 on_change: Option<TextMessage<Msg>>,
63 on_submit: Option<TextMessage<Msg>>,
64 accept: Option<Box<dyn Fn(char) -> bool>>,
65 select_on_focus: Option<Range<usize>>,
66}
67
68#[derive(Debug, Default)]
69struct InputMemory {
70 editor: Editor,
71 synced: Option<String>,
72 scroll: usize,
73 last_edit: Duration,
74 dragging: bool,
75 focused: bool,
78}
79
80impl<Msg: 'static> TextInput<Msg> {
81 #[must_use]
83 pub fn new(value: impl Into<String>) -> Self {
84 Self {
85 value: value.into(),
86 placeholder: String::new(),
87 password: false,
88 invalid: false,
89 disabled: false,
90 max_length: None,
91 on_change: None,
92 on_submit: None,
93 accept: None,
94 select_on_focus: None,
95 }
96 }
97
98 #[must_use]
100 pub fn placeholder(mut self, text: impl Into<String>) -> Self {
101 self.placeholder = text.into();
102 self
103 }
104
105 #[must_use]
107 pub fn password(mut self, password: bool) -> Self {
108 self.password = password;
109 self
110 }
111
112 #[must_use]
114 pub fn invalid(mut self, invalid: bool) -> Self {
115 self.invalid = invalid;
116 self
117 }
118
119 #[must_use]
121 pub fn disabled(mut self, disabled: bool) -> Self {
122 self.disabled = disabled;
123 self
124 }
125
126 #[must_use]
128 pub fn max_length(mut self, max: usize) -> Self {
129 self.max_length = Some(max);
130 self
131 }
132
133 #[must_use]
135 pub fn on_change(mut self, message: impl Fn(String) -> Msg + 'static) -> Self {
136 self.on_change = Some(Box::new(message));
137 self
138 }
139
140 #[must_use]
142 pub fn on_submit(mut self, message: impl Fn(String) -> Msg + 'static) -> Self {
143 self.on_submit = Some(Box::new(message));
144 self
145 }
146
147 #[must_use]
153 pub fn select_on_focus(mut self, range: Range<usize>) -> Self {
154 self.select_on_focus = Some(range);
155 self
156 }
157
158 #[must_use]
161 pub fn select_all_on_focus(self) -> Self {
162 self.select_on_focus(0..usize::MAX)
163 }
164
165 pub(crate) fn accept(mut self, accept: impl Fn(char) -> bool + 'static) -> Self {
167 self.accept = Some(Box::new(accept));
168 self
169 }
170
171 fn accepted(&self, text: &str) -> String {
173 text.chars().filter(|c| self.accept.as_ref().is_none_or(|accept| accept(*c))).collect()
174 }
175
176 fn sync<'m>(&self, memory: &'m mut InputMemory) -> &'m mut InputMemory {
177 if memory.synced.as_deref() != Some(self.value.as_str()) {
178 if memory.editor.text() != self.value {
179 memory.editor.replace_all(&self.value);
180 }
181 memory.synced = Some(self.value.clone());
182 }
183 memory
184 }
185
186 fn follow_focus(&self, memory: &mut InputMemory, focused: bool) {
188 let gained = focused && !memory.focused;
189 memory.focused = focused;
190 let Some(range) = self.select_on_focus.clone().filter(|_| gained) else {
191 return;
192 };
193 let editor = &mut memory.editor;
194 let text = editor.text();
195 let chars = text.chars().count();
196 let (start, end) = (range.start.min(chars), range.end.min(chars));
197 let (start, end) = (grapheme_at_char(text, start), grapheme_at_char(text, end.max(start)));
198 editor.move_to_grapheme(start, false);
199 editor.move_to_grapheme(end, true);
200 }
201
202 fn shown(&self, text: &str, mask: &str) -> Vec<String> {
204 if self.password {
205 text.graphemes(true).map(|_| mask.to_owned()).collect()
206 } else {
207 text.graphemes(true).map(str::to_owned).collect()
208 }
209 }
210
211 fn text_row(prompt_width: u16, area: Rect, padding: Padding) -> Rect {
214 let left = padding.left.saturating_add(prompt_width);
215 Rect::new(
216 area.x + i32::from(left),
217 area.y + i32::from(padding.top),
218 area.width.saturating_sub(left.saturating_add(padding.left)),
219 1,
220 )
221 }
222}
223
224fn grapheme_at_char(text: &str, index: usize) -> usize {
227 let byte = text.char_indices().nth(index).map_or(text.len(), |(byte, _)| byte);
228 text.grapheme_indices(true).take_while(|(start, _)| *start < byte).count()
229}
230
231fn grapheme_index(text: &str, byte: usize) -> usize {
233 text[..byte].graphemes(true).count()
234}
235
236impl<Msg: 'static> Widget<Msg> for TextInput<Msg> {
237 fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
238 let style = cx.env().theme().style("text-input", None, &[]);
239 let (vertical, horizontal) = style.pair("padding").unwrap_or((0, 1));
240 let prompt = text::width(&cx.env().icons().glyph("prompt")) + 1;
241 let content = text::width(&self.value).max(text::width(&self.placeholder)).max(12).saturating_add(1);
242 Size::new(
243 cells::sum([content, prompt, horizontal.saturating_mul(2)]),
244 vertical.saturating_mul(2).saturating_add(1),
245 )
246 .min(available)
247 }
248
249 fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
250 let mut states = if self.disabled { vec![State::Disabled] } else { cx.states() };
251 if self.invalid {
252 states.push(State::Invalid);
253 }
254 let focused = states.contains(&State::Focus);
255 let style = cx.style("text-input", None, &states);
256 let surface = style.text();
257 cx.clear(area, surface.bg.unwrap_or_else(|| cx.color("raised")));
258 if !self.disabled {
259 cx.register_hit(area);
260 edit_menu::request_overlay(cx, area);
261 }
262 let padding = style.padding();
263 if let Some(color) = style.color("pillar").filter(|_| padding.left >= 1) {
266 cx.pillar(area.x, area.y + i32::from(padding.top), color);
267 }
268 let prompt_glyph = cx.env().icons().glyph("prompt").into_owned();
269 let prompt_style = cx.style("text-input-prompt", None, &states).text();
270 let prompt_x = area.x + i32::from(padding.left);
271 let y = area.y + i32::from(padding.top);
272 let prompt_width = cx.text(prompt_x, y, &prompt_glyph, prompt_style, area.width) + 1;
273 let field = Self::text_row(prompt_width, area, padding);
274
275 let mask = cx.env().icons().glyph("mask").into_owned();
276 let now = cx.now();
277 let blink = cx.env().theme().motion().cursor_blink;
278 let has_focus = cx.is_focused();
279 let (glyphs, cursor_index, selection, last_edit) = {
280 let memory = self.sync(cx.memory::<InputMemory>());
281 self.follow_focus(memory, has_focus);
282 let text = memory.editor.text();
283 let cursor_index = grapheme_index(text, memory.editor.cursor());
284 let selection =
285 memory.editor.selection().map(|r| grapheme_index(text, r.start)..grapheme_index(text, r.end));
286 (self.shown(text, &mask), cursor_index, selection, memory.last_edit)
287 };
288
289 if glyphs.is_empty() && !focused {
290 let placeholder = cx.style("text-input-placeholder", None, &states).text();
291 let shown = text::truncate(&self.placeholder, field.width).into_owned();
292 cx.text(field.x, field.y, &shown, placeholder, field.width);
293 return;
294 }
295 let placeholder_head = if glyphs.is_empty() && !self.placeholder.is_empty() {
298 let placeholder = cx.style("text-input-placeholder", None, &states).text();
299 let shown = text::truncate(&self.placeholder, field.width).into_owned();
300 cx.text(field.x, field.y, &shown, placeholder, field.width);
301 shown.graphemes(true).next().map(str::to_owned)
302 } else {
303 None
304 };
305
306 let widths: Vec<u16> = glyphs.iter().map(|g| text::grapheme_width(g).max(1)).collect();
307 let scroll = {
308 let memory = cx.memory::<InputMemory>();
309 let mut scroll = memory.scroll.min(cursor_index);
310 let cells = |from: usize, to: usize| widths[from..to].iter().map(|w| u32::from(*w)).sum::<u32>();
311 while scroll < cursor_index && cells(scroll, cursor_index) + 1 > u32::from(field.width) {
312 scroll += 1;
313 }
314 memory.scroll = scroll;
315 scroll
316 };
317
318 let selection_style = cx.style("text-input-selection", None, &states).text();
319 let mut text_style = surface;
320 text_style.bg = None;
321 let mut x = field.x;
322 for (index, glyph) in glyphs.iter().enumerate().skip(scroll) {
323 let width = widths[index];
324 if x + i32::from(width) > field.right() {
325 break;
326 }
327 let style = if selection.as_ref().is_some_and(|r| r.contains(&index)) {
328 CellStyle { fg: selection_style.fg.or(text_style.fg), bg: selection_style.bg, ..text_style }
329 } else {
330 text_style
331 };
332 cx.text(x, field.y, glyph, style, width);
333 if index == cursor_index && focused {
334 draw_cursor(cx, x, field.y, glyph, Blink { now, last_edit, period: blink }, &states);
335 }
336 x += i32::from(width);
337 }
338 if focused && cursor_index == glyphs.len() && x < field.right() {
339 let glyph = placeholder_head.as_deref().unwrap_or(" ");
340 draw_cursor(cx, x, field.y, glyph, Blink { now, last_edit, period: blink }, &states);
341 }
342 }
343
344 fn paint_overlay(&self, cx: &mut PaintCx<'_>, anchor: Rect) {
345 let selection = self.sync(cx.memory::<InputMemory>()).editor.selection().is_some();
346 self.menu(cx.env(), selection, cx.can_paste()).paint_overlay(cx, anchor);
347 }
348
349 fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
350 let edit = self.edit(cx, event);
351 if let (Some(value), Some(message)) = (edit.changed, &self.on_change) {
352 cx.emit(message(value));
353 }
354 if edit.submit
355 && let Some(message) = &self.on_submit
356 {
357 let value = cx.memory::<InputMemory>().editor.text().to_owned();
358 cx.emit(message(value));
359 }
360 edit.handled
361 }
362
363 fn focusable(&self) -> bool {
364 !self.disabled
365 }
366}
367
368impl<Msg: 'static> TextInput<Msg> {
369 fn menu(&self, env: &crate::env::Env, selection: bool, can_paste: bool) -> TextMenu<EditAction> {
371 TextMenu::edit(env, selection && !self.password, can_paste)
372 }
373
374 fn index_at(memory: &InputMemory, area: Rect, text_left: u16, x: i32) -> usize {
376 let column = usize::from(clamp_u16(x - area.x - i32::from(text_left)));
377 let mut cells = 0usize;
378 let mut index = memory.scroll;
379 for grapheme in memory.editor.text().graphemes(true).skip(memory.scroll) {
380 let width = usize::from(text::grapheme_width(grapheme).max(1));
381 if cells + width / 2 >= column {
382 break;
383 }
384 cells += width;
385 index += 1;
386 }
387 index
388 }
389
390 fn menu_event(&self, cx: &mut EventCx<'_, Msg>, event: &Event, text_left: u16) -> Option<Edit> {
393 let open = edit_menu::is_open(cx);
394 if !open && !edit_menu::asks(event) {
395 return None;
396 }
397 let area = cx.area();
398 if let Event::Mouse(mouse) = event
399 && !open
400 {
401 let memory = self.sync(cx.memory::<InputMemory>());
403 let index = Self::index_at(memory, area, text_left, mouse.x);
404 let text = memory.editor.text();
405 let inside = memory
406 .editor
407 .selection()
408 .is_some_and(|r| (grapheme_index(text, r.start)..grapheme_index(text, r.end)).contains(&index));
409 if !inside {
410 memory.editor.move_to_grapheme(index, false);
411 }
412 }
413 let selection = self.sync(cx.memory::<InputMemory>()).editor.selection().is_some();
414 let (used, chosen) = self.menu(cx.env(), selection, cx.can_paste()).event(cx, event);
415 if used && !open {
416 cx.probe_clipboard();
417 }
418 let mut edit = Edit { handled: used, changed: None, submit: false };
419 let Some(action) = chosen else {
420 return used.then_some(edit);
421 };
422 let now = cx.now();
423 let memory = self.sync(cx.memory::<InputMemory>());
424 memory.last_edit = now;
425 let editor = &mut memory.editor;
426 let mut copy = None;
427 match action {
428 EditAction::Cut | EditAction::Copy if !self.password => {
429 copy = editor.selected_text().map(str::to_owned);
430 if action == EditAction::Cut && copy.is_some() && editor.backspace() {
431 edit.changed = Some(editor.text().to_owned());
432 memory.synced = edit.changed.clone();
433 }
434 }
435 EditAction::Cut | EditAction::Copy | EditAction::Paste => {}
436 EditAction::SelectAll => editor.select_all(),
437 }
438 if action == EditAction::Paste {
439 cx.run_action(Scope::Global, "paste");
440 }
441 if let Some(text) = copy {
442 cx.copy(text);
443 }
444 edit.handled = true;
445 Some(edit)
446 }
447
448 fn key(&self, editor: &mut Editor, key: &KeyEvent) -> KeyEdit {
450 let mods = key.chord.mods;
451 let ctrl = mods.ctrl && !mods.alt;
452 let mut edit = KeyEdit { handled: true, ..KeyEdit::default() };
453 match key.chord.key {
454 Key::Char(c) if ctrl => match (c, mods.shift) {
455 ('a', false) => editor.select_all(),
456 ('z', false) => edit.changed = editor.undo(),
457 ('y', false) | ('z', true) => edit.changed = editor.redo(),
458 ('w', false) => edit.changed = editor.delete_word_back(),
459 ('u', false) => edit.changed = editor.delete_to_start(),
460 ('c', false) | ('x', false) if !self.password => {
462 edit.copy = editor.selected_text().map(str::to_owned);
463 if c == 'x' && edit.copy.is_some() {
464 edit.changed = editor.backspace();
465 }
466 edit.handled = edit.copy.is_some();
467 }
468 _ => edit.handled = false,
469 },
470 Key::Left => editor.move_left(mods.shift, mods.ctrl),
471 Key::Right => editor.move_right(mods.shift, mods.ctrl),
472 Key::Home => editor.move_home(mods.shift),
473 Key::End => editor.move_end(mods.shift),
474 Key::Backspace if mods == Modifiers::default() || mods.ctrl => {
475 edit.changed = if mods.ctrl { editor.delete_word_back() } else { editor.backspace() };
476 }
477 Key::Delete => edit.changed = editor.delete(),
478 Key::Enter if mods == Modifiers::default() => {
479 edit.submit = self.on_submit.is_some();
480 edit.handled = edit.submit;
481 }
482 _ => match key.text {
483 Some(c) if !mods.ctrl && !mods.alt => {
484 edit.changed = editor.insert(&self.accepted(&c.to_string()), self.max_length);
485 }
486 _ => edit.handled = false,
487 },
488 }
489 edit
490 }
491
492 pub(crate) fn edit(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> Edit {
495 let unused = Edit { handled: false, changed: None, submit: false };
496 if self.disabled {
497 return unused;
498 }
499 let max = self.max_length;
500 let now = cx.now();
501 let area = cx.area();
502 let text_left = {
503 let padding = cx.env().theme().style("text-input", None, &[]).pair("padding").unwrap_or((0, 1)).1;
504 cells::sum([padding, text::width(&cx.env().icons().glyph("prompt")), 1])
505 };
506 let has_focus = cx.is_focused();
509 {
510 let memory = self.sync(cx.memory::<InputMemory>());
511 if matches!(event, Event::Mouse(mouse) if matches!(mouse.kind, MouseKind::Down(_))) {
512 memory.focused = has_focus;
513 } else {
514 self.follow_focus(memory, has_focus);
515 }
516 }
517 if let Some(edit) = self.menu_event(cx, event, text_left) {
518 return edit;
519 }
520 let (handled, changed, submit, copy) = {
521 let memory = self.sync(cx.memory::<InputMemory>());
522 let editor = &mut memory.editor;
523 let mut changed = false;
524 let mut submit = false;
525 let mut copy = None;
526 let handled = match event {
527 Event::Paste(text) => {
528 changed = editor.insert(&self.accepted(&text.replace(['\n', '\r'], " ")), max);
529 true
530 }
531 Event::Key(key) => {
532 let edit = self.key(editor, key);
533 (changed, submit, copy) = (edit.changed, edit.submit, edit.copy);
534 edit.handled
535 }
536 Event::Mouse(mouse) => match mouse.kind {
537 MouseKind::Down(MouseButton::Left) | MouseKind::Drag(MouseButton::Left) => {
538 let dragging = matches!(mouse.kind, MouseKind::Drag(_));
539 let index = Self::index_at(memory, area, text_left, mouse.x);
540 memory.editor.move_to_grapheme(index, dragging);
541 memory.dragging = true;
542 true
543 }
544 MouseKind::Up(MouseButton::Left) => {
545 memory.dragging = false;
546 true
547 }
548 _ => false,
549 },
550 Event::PointerOutside => false,
551 };
552 if handled {
553 memory.last_edit = now;
554 }
555 if changed {
556 memory.synced = Some(memory.editor.text().to_owned());
557 }
558 (handled, changed.then(|| memory.editor.text().to_owned()), submit, copy)
559 };
560 if let Event::Mouse(mouse) = event
561 && mouse.kind == MouseKind::Down(MouseButton::Left)
562 {
563 cx.capture_pointer();
564 }
565 if let Some(text) = copy {
566 cx.copy(text);
567 }
568 Edit { handled, changed, submit }
569 }
570}
571
572#[derive(Default)]
574struct KeyEdit {
575 handled: bool,
576 changed: bool,
577 submit: bool,
578 copy: Option<String>,
579}
580
581#[derive(Debug, Clone, Copy)]
583pub(crate) struct Blink {
584 pub(crate) now: Duration,
585 pub(crate) last_edit: Duration,
586 pub(crate) period: Duration,
587}
588
589pub(crate) fn draw_cursor(cx: &mut PaintCx<'_>, x: i32, y: i32, glyph: &str, blink: Blink, states: &[State]) {
592 let since = blink.now.saturating_sub(blink.last_edit);
593 let period = blink.period.max(Duration::from_millis(1));
594 let phase = since.as_millis() / period.as_millis();
595 let next = period.saturating_mul(u32::try_from(phase + 1).unwrap_or(u32::MAX)).saturating_sub(since);
596 cx.request_frame_in(next);
597 if phase.is_multiple_of(2) {
598 let style = cx.style("text-input-cursor", None, states).text();
599 cx.text(x, y, glyph, style, text::width(glyph).max(1));
600 }
601}
602
603#[cfg(test)]
604mod tests {
605 use super::*;
606 use crate::runtime::{App, Command, Harness};
607 use crate::widget::View;
608
609 #[derive(Default)]
610 struct Demo {
611 value: String,
612 submitted: Option<String>,
613 password: bool,
614 }
615
616 enum Msg {
617 Changed(String),
618 Submitted(String),
619 }
620
621 impl App for Demo {
622 type Msg = Msg;
623 fn update(&mut self, msg: Msg) -> Command<Msg> {
624 match msg {
625 Msg::Changed(value) => self.value = value,
626 Msg::Submitted(value) => self.submitted = Some(value),
627 }
628 Command::none()
629 }
630 fn view(&self, ui: &mut View<'_, Msg>) {
631 ui.add(
632 TextInput::new(&self.value)
633 .placeholder("Project name")
634 .password(self.password)
635 .max_length(20)
636 .on_change(Msg::Changed)
637 .on_submit(Msg::Submitted),
638 )
639 .fill_width()
640 .id("name");
641 }
642 }
643
644 #[test]
645 fn types_edits_and_submits() {
646 let mut h = Harness::new(Demo::default(), 30, 1);
647 assert_eq!(h.screen(), " ❯ Project name\n");
648 h.press("tab").type_text("quvyta framework");
649 assert_eq!(h.app().value, "quvyta framework");
650 h.press("ctrl+w").press("ctrl+w");
651 assert_eq!(h.app().value, "");
652 h.press("ctrl+z");
653 assert_eq!(h.app().value, "quvyta ");
654 h.press("enter");
655 assert_eq!(h.app().submitted.as_deref(), Some("quvyta "));
656 }
657
658 #[test]
659 fn selection_copy_and_paste() {
660 let mut h = Harness::new(Demo { value: "hello world".into(), ..Demo::default() }, 30, 1);
661 h.press("tab").press("ctrl+shift+left").press("ctrl+c");
662 assert_eq!(h.copied(), &["world".to_owned()]);
663 h.paste("there");
664 assert_eq!(h.app().value, "hello there");
665 }
666
667 #[test]
668 fn scrolls_long_text_and_masks_passwords() {
669 let mut h = Harness::new(Demo { password: true, ..Demo::default() }, 12, 1);
670 h.press("tab").type_text("abcdefghijklmn");
671 let screen = h.screen();
672 assert_eq!(h.app().value, "abcdefghijklmn");
673 assert!(screen.starts_with("▌ ❯ •••••"), "{screen}");
674 assert!(!screen.contains('a'));
675 }
676
677 fn right_click(h: &mut Harness<Demo>, x: i32, y: i32) {
678 h.mouse(MouseKind::Down(MouseButton::Right), x, y);
679 h.mouse(MouseKind::Up(MouseButton::Right), x, y);
680 }
681
682 fn menu_harness(value: &str, password: bool) -> Harness<Demo> {
683 let mut h = Harness::new(Demo { value: value.into(), password, ..Demo::default() }, 30, 7);
684 h.set_reduced_motion(true);
685 h
686 }
687
688 #[test]
689 fn right_click_opens_an_edit_menu_whose_entries_need_a_selection_or_a_clipboard() {
690 let mut h = menu_harness("hello world", false);
691 right_click(&mut h, 12, 0);
692 assert_eq!(
693 h.screen(),
694 "▌ ❯ hello world\n Cut ctrl x\n Copy ctrl c\n Paste ctrl v\n Select all ctrl a\n\n\n"
695 );
696 let theme = h.env().theme();
697 let muted = theme.color("muted");
698 assert_eq!((h.fg(8, 1), h.fg(8, 2), h.fg(8, 3)), (muted, muted, muted), "nothing selected, nothing to paste");
699 assert_ne!(h.fg(8, 4), muted, "Select all always works");
700 h.press("down").press("enter");
701 assert!(!h.screen().contains("Cut"), "choosing closes the menu");
702 right_click(&mut h, 12, 0);
703 assert_ne!(h.fg(8, 1), muted, "a right click inside the selection keeps it: Cut is enabled");
704 h.click_text("Copy");
705 assert_eq!(h.clipboard(), Some("hello world"));
706 right_click(&mut h, 12, 0);
707 assert_ne!(h.fg(8, 3), muted, "now there is something to paste");
708 h.click_text("Cut");
709 assert_eq!(h.app().value, "");
710 right_click(&mut h, 6, 0);
711 h.click_text("Paste");
712 assert_eq!(h.app().value, "hello world");
713 assert!(h.is_focused("name"));
714 }
715
716 #[test]
717 fn right_click_outside_the_selection_places_the_cursor_first() {
718 let mut h = menu_harness("hello world", false);
719 h.press("tab").press("ctrl+shift+left");
720 right_click(&mut h, 4, 0);
721 let theme = h.env().theme();
722 assert_eq!(h.fg(8, 1), theme.color("muted"), "the selection is gone: Cut is disabled");
723 h.press("esc").type_text("X");
724 assert_eq!(h.app().value, "Xhello world");
725 }
726
727 #[test]
728 fn paste_reads_the_system_clipboard_before_the_last_copy_inside_the_application() {
729 let mut h = menu_harness("hello", false);
730 h.press("tab").press("ctrl+a").press("ctrl+c").press("end");
731 h.set_system_clipboard(Some(" from web"));
732 right_click(&mut h, 20, 0);
733 h.click_text("Paste");
734 assert_eq!(h.app().value, "hello from web");
735 h.set_system_clipboard(None).press("ctrl+v");
736 assert_eq!(h.app().value, "hello from webhello", "an empty system clipboard falls back");
737 }
738
739 #[test]
740 fn shift_f10_and_the_menu_key_open_it_under_the_field() {
741 let mut h = menu_harness("hello", false);
742 h.press("tab").press("shift+f10");
743 assert!(h.screen().lines().nth(1).is_some_and(|line| line.contains("Cut")), "{}", h.screen());
744 h.press("esc");
745 assert!(!h.screen().contains("Cut"));
746 h.press("menu").press("up").press("enter").type_text("!");
747 assert_eq!(h.app().value, "!", "↑ wrapped to Select all");
748 }
749
750 #[test]
751 fn the_menu_fits_a_narrow_screen_in_ascii() {
752 let mut h = Harness::new(Demo { value: "hello".into(), ..Demo::default() }, 18, 6);
753 h.set_reduced_motion(true);
754 h.set_glyph_mode(crate::icons::GlyphMode::Ascii);
755 right_click(&mut h, 15, 0);
756 assert_eq!(h.screen(), " > hello\n Cut ctrl x\n Copy ctrl c\n Paste ctrl v\n Selec… ctrl a\n\n");
759 assert_ne!(h.bg(0, 0), h.bg(1, 0), "the pillar cell stands out from the field");
760 }
761
762 #[test]
763 fn password_fields_never_copy() {
764 let mut h = menu_harness("secret", true);
765 h.press("tab").press("ctrl+a").press("ctrl+c").press("ctrl+x");
766 assert!(h.copied().is_empty());
767 assert_eq!(h.app().value, "secret");
768 right_click(&mut h, 5, 0);
769 let theme = h.env().theme();
770 assert_eq!((h.fg(8, 1), h.fg(8, 2)), (theme.color("muted"), theme.color("muted")));
771 }
772
773 struct Rename {
775 value: String,
776 range: Option<Range<usize>>,
777 all: bool,
778 }
779
780 impl App for Rename {
781 type Msg = String;
782 fn update(&mut self, value: String) -> Command<String> {
783 self.value = value;
784 Command::none()
785 }
786 fn view(&self, ui: &mut View<'_, String>) {
787 let mut input = TextInput::new(&self.value).on_change(|value| value);
788 if let Some(range) = self.range.clone() {
789 input = input.select_on_focus(range);
790 }
791 if self.all {
792 input = input.select_all_on_focus();
793 }
794 ui.add(input).fill_width().id("name");
795 }
796 }
797
798 fn rename(value: &str, range: Option<Range<usize>>) -> Harness<Rename> {
799 Harness::new(Rename { value: value.into(), range, all: false }, 30, 1)
800 }
801
802 #[test]
803 fn typing_replaces_the_part_selected_on_focus() {
804 let mut h = rename("main.rs", Some(0..4));
805 h.press("tab");
806 assert_eq!(h.screen(), "▌ ❯ main.rs\n");
807 let (selected, plain) = (h.bg(4, 0), h.bg(9, 0));
808 assert_ne!(selected, plain, "main is selected");
809 assert_eq!(h.bg(7, 0), selected, "all four letters");
810 h.type_text("x");
811 assert_eq!(h.app().value, "x.rs");
812 }
813
814 #[test]
815 fn the_cursor_stands_at_the_end_of_the_range_and_an_arrow_drops_the_selection() {
816 let mut h = rename("main.rs", Some(0..4));
817 h.press("tab").press("right").type_text("_");
818 assert_eq!(h.app().value, "main._rs", "→ leaves the selection one step on from its end");
819 let mut h = rename("main.rs", Some(0..4));
820 h.press("tab").press("shift+right").type_text("x");
821 assert_eq!(h.app().value, "xrs", "the cursor was at the range's end, so shift+→ grows it");
822 }
823
824 #[test]
825 fn without_a_range_the_field_opens_as_before() {
826 let mut h = rename("main.rs", None);
827 h.press("tab").type_text("x");
828 assert_eq!(h.app().value, "main.rsx");
829 }
830
831 #[test]
832 fn the_range_counts_characters_and_is_cut_to_the_text() {
833 let mut h = rename("şğü.txt", Some(0..3));
834 h.press("tab").type_text("a");
835 assert_eq!(h.app().value, "a.txt", "three Turkish letters are three characters, six bytes");
836 let mut h = rename("çay", Some(1..40));
837 h.press("tab").type_text("ok");
838 assert_eq!(h.app().value, "çok");
839 let mut h = Harness::new(Rename { value: "e\u{301}te".into(), range: Some(0..1), all: false }, 30, 1);
840 h.press("tab").type_text("a");
841 assert_eq!(h.app().value, "ate", "a range never splits an accented letter built from two characters");
842 }
843
844 #[test]
845 fn select_all_on_focus_selects_everything() {
846 let mut h = Harness::new(Rename { value: "draft".into(), range: None, all: true }, 30, 1);
847 h.press("tab").type_text("final");
848 assert_eq!(h.app().value, "final");
849 }
850
851 #[test]
852 fn a_value_changed_from_outside_keeps_the_users_selection() {
853 let mut h = rename("main.rs", Some(0..4));
854 h.press("tab").press("end");
855 h.send("lib.rs".to_owned()).type_text("!");
856 assert_eq!(h.app().value, "lib.rs!", "the new value is not selected again");
857 }
858
859 #[test]
860 fn a_click_that_brings_focus_places_the_cursor() {
861 let mut h = rename("main.rs", Some(0..4));
862 h.click(9, 0).type_text("X");
863 assert_eq!(h.app().value, "main.Xrs", "the cursor lands where clicked, before r");
864 }
865
866 #[test]
867 fn coming_back_selects_the_range_again() {
868 struct Two(String);
869 impl App for Two {
870 type Msg = String;
871 fn update(&mut self, value: String) -> Command<String> {
872 self.0 = value;
873 Command::none()
874 }
875 fn view(&self, ui: &mut View<'_, String>) {
876 ui.add(TextInput::new(&self.0).select_on_focus(0..4).on_change(|value| value)).fill_width();
877 ui.add(TextInput::new("other")).fill_width();
878 }
879 }
880 let mut h = Harness::new(Two("main.rs".into()), 30, 2);
881 h.press("tab").press("end").press("tab").press("shift+tab").type_text("x");
882 assert_eq!(h.app().0, "x.rs");
883 }
884
885 #[test]
886 fn click_places_cursor_and_value_changes_resync() {
887 let mut h = Harness::new(Demo { value: "abcd".into(), ..Demo::default() }, 30, 1);
888 h.click(5, 0).type_text("X");
889 assert_eq!(h.app().value, "aXbcd");
890 }
891}