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 cx.takes_text();
251 let mut states = if self.disabled { vec![State::Disabled] } else { cx.states() };
252 if self.invalid {
253 states.push(State::Invalid);
254 }
255 let focused = states.contains(&State::Focus);
256 let style = cx.style("text-input", None, &states);
257 let surface = style.text();
258 cx.clear(area, surface.bg.unwrap_or_else(|| cx.color("raised")));
259 if !self.disabled {
260 cx.register_hit(area);
261 edit_menu::request_overlay(cx, area);
262 }
263 let padding = style.padding();
264 if let Some(color) = style.color("pillar").filter(|_| padding.left >= 1) {
267 cx.pillar(area.x, area.y + i32::from(padding.top), color);
268 }
269 let prompt_glyph = cx.env().icons().glyph("prompt").into_owned();
270 let prompt_style = cx.style("text-input-prompt", None, &states).text();
271 let prompt_x = area.x + i32::from(padding.left);
272 let y = area.y + i32::from(padding.top);
273 let prompt_width = cx.text(prompt_x, y, &prompt_glyph, prompt_style, area.width) + 1;
274 let field = Self::text_row(prompt_width, area, padding);
275
276 let mask = cx.env().icons().glyph("mask").into_owned();
277 let now = cx.now();
278 let blink = cx.env().theme().motion().cursor_blink;
279 let has_focus = cx.is_focused();
280 let (glyphs, cursor_index, selection, last_edit) = {
281 let memory = self.sync(cx.memory::<InputMemory>());
282 self.follow_focus(memory, has_focus);
283 let text = memory.editor.text();
284 let cursor_index = grapheme_index(text, memory.editor.cursor());
285 let selection =
286 memory.editor.selection().map(|r| grapheme_index(text, r.start)..grapheme_index(text, r.end));
287 (self.shown(text, &mask), cursor_index, selection, memory.last_edit)
288 };
289
290 if glyphs.is_empty() && !focused {
291 let placeholder = cx.style("text-input-placeholder", None, &states).text();
292 let shown = text::truncate(&self.placeholder, field.width).into_owned();
293 cx.text(field.x, field.y, &shown, placeholder, field.width);
294 return;
295 }
296 let placeholder_head = if glyphs.is_empty() && !self.placeholder.is_empty() {
299 let placeholder = cx.style("text-input-placeholder", None, &states).text();
300 let shown = text::truncate(&self.placeholder, field.width).into_owned();
301 cx.text(field.x, field.y, &shown, placeholder, field.width);
302 shown.graphemes(true).next().map(str::to_owned)
303 } else {
304 None
305 };
306
307 let widths: Vec<u16> = glyphs.iter().map(|g| text::grapheme_width(g).max(1)).collect();
308 let scroll = {
309 let memory = cx.memory::<InputMemory>();
310 let mut scroll = memory.scroll.min(cursor_index);
311 let cells = |from: usize, to: usize| widths[from..to].iter().map(|w| u32::from(*w)).sum::<u32>();
312 while scroll < cursor_index && cells(scroll, cursor_index) + 1 > u32::from(field.width) {
313 scroll += 1;
314 }
315 memory.scroll = scroll;
316 scroll
317 };
318
319 let selection_style = cx.style("text-input-selection", None, &states).text();
320 let mut text_style = surface;
321 text_style.bg = None;
322 let mut x = field.x;
323 for (index, glyph) in glyphs.iter().enumerate().skip(scroll) {
324 let width = widths[index];
325 if x + i32::from(width) > field.right() {
326 break;
327 }
328 let style = if selection.as_ref().is_some_and(|r| r.contains(&index)) {
329 CellStyle { fg: selection_style.fg.or(text_style.fg), bg: selection_style.bg, ..text_style }
330 } else {
331 text_style
332 };
333 cx.text(x, field.y, glyph, style, width);
334 if index == cursor_index && focused {
335 draw_cursor(cx, x, field.y, glyph, Blink { now, last_edit, period: blink }, &states);
336 }
337 x += i32::from(width);
338 }
339 if focused && cursor_index == glyphs.len() && x < field.right() {
340 let glyph = placeholder_head.as_deref().unwrap_or(" ");
341 draw_cursor(cx, x, field.y, glyph, Blink { now, last_edit, period: blink }, &states);
342 }
343 }
344
345 fn paint_overlay(&self, cx: &mut PaintCx<'_>, anchor: Rect) {
346 let selection = self.sync(cx.memory::<InputMemory>()).editor.selection().is_some();
347 self.menu(cx.env(), selection, cx.can_paste()).paint_overlay(cx, anchor);
348 }
349
350 fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
351 let edit = self.edit(cx, event);
352 if let (Some(value), Some(message)) = (edit.changed, &self.on_change) {
353 cx.emit(message(value));
354 }
355 if edit.submit
356 && let Some(message) = &self.on_submit
357 {
358 let value = cx.memory::<InputMemory>().editor.text().to_owned();
359 cx.emit(message(value));
360 }
361 edit.handled
362 }
363
364 fn focusable(&self) -> bool {
365 !self.disabled
366 }
367}
368
369impl<Msg: 'static> TextInput<Msg> {
370 fn menu(&self, env: &crate::env::Env, selection: bool, can_paste: bool) -> TextMenu<EditAction> {
372 TextMenu::edit(env, selection && !self.password, can_paste)
373 }
374
375 fn index_at(memory: &InputMemory, area: Rect, text_left: u16, x: i32) -> usize {
377 let column = usize::from(clamp_u16(x - area.x - i32::from(text_left)));
378 let mut cells = 0usize;
379 let mut index = memory.scroll;
380 for grapheme in memory.editor.text().graphemes(true).skip(memory.scroll) {
381 let width = usize::from(text::grapheme_width(grapheme).max(1));
382 if cells + width / 2 >= column {
383 break;
384 }
385 cells += width;
386 index += 1;
387 }
388 index
389 }
390
391 fn menu_event(&self, cx: &mut EventCx<'_, Msg>, event: &Event, text_left: u16) -> Option<Edit> {
394 let open = edit_menu::is_open(cx);
395 if !open && !edit_menu::asks(event) {
396 return None;
397 }
398 let area = cx.area();
399 if let Event::Mouse(mouse) = event
400 && !open
401 {
402 let memory = self.sync(cx.memory::<InputMemory>());
404 let index = Self::index_at(memory, area, text_left, mouse.x);
405 let text = memory.editor.text();
406 let inside = memory
407 .editor
408 .selection()
409 .is_some_and(|r| (grapheme_index(text, r.start)..grapheme_index(text, r.end)).contains(&index));
410 if !inside {
411 memory.editor.move_to_grapheme(index, false);
412 }
413 }
414 let selection = self.sync(cx.memory::<InputMemory>()).editor.selection().is_some();
415 let (used, chosen) = self.menu(cx.env(), selection, cx.can_paste()).event(cx, event);
416 if used && !open {
417 cx.probe_clipboard();
418 }
419 let mut edit = Edit { handled: used, changed: None, submit: false };
420 let Some(action) = chosen else {
421 return used.then_some(edit);
422 };
423 let now = cx.now();
424 let memory = self.sync(cx.memory::<InputMemory>());
425 memory.last_edit = now;
426 let editor = &mut memory.editor;
427 let mut copy = None;
428 match action {
429 EditAction::Cut | EditAction::Copy if !self.password => {
430 copy = editor.selected_text().map(str::to_owned);
431 if action == EditAction::Cut && copy.is_some() && editor.backspace() {
432 edit.changed = Some(editor.text().to_owned());
433 memory.synced = edit.changed.clone();
434 }
435 }
436 EditAction::Cut | EditAction::Copy | EditAction::Paste => {}
437 EditAction::SelectAll => editor.select_all(),
438 }
439 if action == EditAction::Paste {
440 cx.run_action(Scope::Global, "paste");
441 }
442 if let Some(text) = copy {
443 cx.copy(text);
444 }
445 edit.handled = true;
446 Some(edit)
447 }
448
449 fn key(&self, editor: &mut Editor, key: &KeyEvent) -> KeyEdit {
451 let mods = key.chord.mods;
452 let ctrl = mods.ctrl && !mods.alt;
453 let mut edit = KeyEdit { handled: true, ..KeyEdit::default() };
454 match key.chord.key {
455 Key::Char(c) if ctrl => match (c, mods.shift) {
456 ('a', false) => editor.select_all(),
457 ('z', false) => edit.changed = editor.undo(),
458 ('y', false) | ('z', true) => edit.changed = editor.redo(),
459 ('w', false) => edit.changed = editor.delete_word_back(),
460 ('u', false) => edit.changed = editor.delete_to_start(),
461 ('c', false) | ('x', false) if !self.password => {
463 edit.copy = editor.selected_text().map(str::to_owned);
464 if c == 'x' && edit.copy.is_some() {
465 edit.changed = editor.backspace();
466 }
467 edit.handled = edit.copy.is_some();
468 }
469 _ => edit.handled = false,
470 },
471 Key::Left => editor.move_left(mods.shift, mods.ctrl),
472 Key::Right => editor.move_right(mods.shift, mods.ctrl),
473 Key::Home => editor.move_home(mods.shift),
474 Key::End => editor.move_end(mods.shift),
475 Key::Backspace if mods == Modifiers::default() || mods.ctrl => {
476 edit.changed = if mods.ctrl { editor.delete_word_back() } else { editor.backspace() };
477 }
478 Key::Delete => edit.changed = editor.delete(),
479 Key::Enter if mods == Modifiers::default() => {
480 edit.submit = self.on_submit.is_some();
481 edit.handled = edit.submit;
482 }
483 _ => match key.text {
484 Some(c) if !mods.ctrl && !mods.alt => {
485 edit.changed = editor.insert(&self.accepted(&c.to_string()), self.max_length);
486 }
487 _ => edit.handled = false,
488 },
489 }
490 edit
491 }
492
493 pub(crate) fn edit(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> Edit {
496 let unused = Edit { handled: false, changed: None, submit: false };
497 if self.disabled {
498 return unused;
499 }
500 let max = self.max_length;
501 let now = cx.now();
502 let area = cx.area();
503 let text_left = {
504 let padding = cx.env().theme().style("text-input", None, &[]).pair("padding").unwrap_or((0, 1)).1;
505 cells::sum([padding, text::width(&cx.env().icons().glyph("prompt")), 1])
506 };
507 let has_focus = cx.is_focused();
510 {
511 let memory = self.sync(cx.memory::<InputMemory>());
512 if matches!(event, Event::Mouse(mouse) if matches!(mouse.kind, MouseKind::Down(_))) {
513 memory.focused = has_focus;
514 } else {
515 self.follow_focus(memory, has_focus);
516 }
517 }
518 if let Some(edit) = self.menu_event(cx, event, text_left) {
519 return edit;
520 }
521 let (handled, changed, submit, copy) = {
522 let memory = self.sync(cx.memory::<InputMemory>());
523 let editor = &mut memory.editor;
524 let mut changed = false;
525 let mut submit = false;
526 let mut copy = None;
527 let handled = match event {
528 Event::Paste(text) => {
529 changed = editor.insert(&self.accepted(&text.replace(['\n', '\r'], " ")), max);
530 true
531 }
532 Event::Key(key) => {
533 let edit = self.key(editor, key);
534 (changed, submit, copy) = (edit.changed, edit.submit, edit.copy);
535 edit.handled
536 }
537 Event::Mouse(mouse) => match mouse.kind {
538 MouseKind::Down(MouseButton::Left) | MouseKind::Drag(MouseButton::Left) => {
539 let dragging = matches!(mouse.kind, MouseKind::Drag(_));
540 let index = Self::index_at(memory, area, text_left, mouse.x);
541 memory.editor.move_to_grapheme(index, dragging);
542 memory.dragging = true;
543 true
544 }
545 MouseKind::Up(MouseButton::Left) => {
546 memory.dragging = false;
547 true
548 }
549 _ => false,
550 },
551 Event::PointerOutside => false,
552 };
553 if handled {
554 memory.last_edit = now;
555 }
556 if changed {
557 memory.synced = Some(memory.editor.text().to_owned());
558 }
559 (handled, changed.then(|| memory.editor.text().to_owned()), submit, copy)
560 };
561 if let Event::Mouse(mouse) = event
562 && mouse.kind == MouseKind::Down(MouseButton::Left)
563 {
564 cx.capture_pointer();
565 }
566 if let Some(text) = copy {
567 cx.copy(text);
568 }
569 Edit { handled, changed, submit }
570 }
571}
572
573#[derive(Default)]
575struct KeyEdit {
576 handled: bool,
577 changed: bool,
578 submit: bool,
579 copy: Option<String>,
580}
581
582#[derive(Debug, Clone, Copy)]
584pub(crate) struct Blink {
585 pub(crate) now: Duration,
586 pub(crate) last_edit: Duration,
587 pub(crate) period: Duration,
588}
589
590pub(crate) fn draw_cursor(cx: &mut PaintCx<'_>, x: i32, y: i32, glyph: &str, blink: Blink, states: &[State]) {
594 let since = blink.now.saturating_sub(blink.last_edit);
595 let period = blink.period.max(Duration::from_millis(1));
596 let rest = cx.motion_rests_at();
597 let phase = if blink.now >= rest { 0 } else { since.as_millis() / period.as_millis() };
598 if blink.now < rest {
599 let next = period.saturating_mul(u32::try_from(phase + 1).unwrap_or(u32::MAX)).saturating_sub(since);
600 cx.request_frame_in(next.min(rest - blink.now));
601 }
602 if phase.is_multiple_of(2) {
603 let style = cx.style("text-input-cursor", None, states).text();
604 cx.text(x, y, glyph, style, text::width(glyph).max(1));
605 }
606}
607
608#[cfg(test)]
609mod tests {
610 use super::*;
611 use crate::runtime::{App, Command, Harness};
612 use crate::widget::View;
613
614 #[derive(Default)]
615 struct Demo {
616 value: String,
617 submitted: Option<String>,
618 password: bool,
619 }
620
621 enum Msg {
622 Changed(String),
623 Submitted(String),
624 }
625
626 impl App for Demo {
627 type Msg = Msg;
628 fn update(&mut self, msg: Msg) -> Command<Msg> {
629 match msg {
630 Msg::Changed(value) => self.value = value,
631 Msg::Submitted(value) => self.submitted = Some(value),
632 }
633 Command::none()
634 }
635 fn view(&self, ui: &mut View<'_, Msg>) {
636 ui.add(
637 TextInput::new(&self.value)
638 .placeholder("Project name")
639 .password(self.password)
640 .max_length(20)
641 .on_change(Msg::Changed)
642 .on_submit(Msg::Submitted),
643 )
644 .fill_width()
645 .id("name");
646 }
647 }
648
649 #[test]
650 fn types_edits_and_submits() {
651 let mut h = Harness::new(Demo::default(), 30, 1);
652 assert_eq!(h.screen(), " ❯ Project name\n");
653 h.press("tab").type_text("quvyta framework");
654 assert_eq!(h.app().value, "quvyta framework");
655 h.press("ctrl+w").press("ctrl+w");
656 assert_eq!(h.app().value, "");
657 h.press("ctrl+z");
658 assert_eq!(h.app().value, "quvyta ");
659 h.press("enter");
660 assert_eq!(h.app().submitted.as_deref(), Some("quvyta "));
661 }
662
663 #[test]
664 fn the_cursor_stops_blinking_on_a_screen_nobody_touches_and_blinks_again_at_the_next_key() {
665 let mut h = Harness::new(Demo::default(), 30, 1);
666 h.press("tab").type_text("quiet");
667 assert!(h.next_frame().is_some(), "a cursor just typed with blinks");
668 h.advance(Duration::from_secs(30));
669 assert_eq!(h.next_frame(), None, "a screen left alone asks for no frame");
670 let cursor = |h: &Harness<Demo>| (h.fg(9, 0), h.bg(9, 0));
671 let resting = cursor(&h);
672 assert_ne!(resting, (h.fg(20, 0), h.bg(20, 0)), "the cursor rests drawn, not hidden");
673 h.advance(h.env().theme().motion().cursor_blink);
674 assert_eq!(cursor(&h), resting, "and it stays where it is");
675 h.press("left");
676 assert!(h.next_frame().is_some(), "the next key starts the blinking again");
677 }
678
679 #[test]
680 fn selection_copy_and_paste() {
681 let mut h = Harness::new(Demo { value: "hello world".into(), ..Demo::default() }, 30, 1);
682 h.press("tab").press("ctrl+shift+left").press("ctrl+c");
683 assert_eq!(h.copied(), &["world".to_owned()]);
684 h.paste("there");
685 assert_eq!(h.app().value, "hello there");
686 }
687
688 #[test]
689 fn scrolls_long_text_and_masks_passwords() {
690 let mut h = Harness::new(Demo { password: true, ..Demo::default() }, 12, 1);
691 h.press("tab").type_text("abcdefghijklmn");
692 let screen = h.screen();
693 assert_eq!(h.app().value, "abcdefghijklmn");
694 assert!(screen.starts_with("▌ ❯ •••••"), "{screen}");
695 assert!(!screen.contains('a'));
696 }
697
698 fn right_click(h: &mut Harness<Demo>, x: i32, y: i32) {
699 h.mouse(MouseKind::Down(MouseButton::Right), x, y);
700 h.mouse(MouseKind::Up(MouseButton::Right), x, y);
701 }
702
703 fn menu_harness(value: &str, password: bool) -> Harness<Demo> {
704 let mut h = Harness::new(Demo { value: value.into(), password, ..Demo::default() }, 30, 7);
705 h.set_reduced_motion(true);
706 h
707 }
708
709 #[test]
710 fn right_click_opens_an_edit_menu_whose_entries_need_a_selection_or_a_clipboard() {
711 let mut h = menu_harness("hello world", false);
712 right_click(&mut h, 12, 0);
713 assert_eq!(
714 h.screen(),
715 "▌ ❯ hello world\n Cut ctrl x\n Copy ctrl c\n Paste ctrl v\n Select all ctrl a\n\n\n"
716 );
717 let theme = h.env().theme();
718 let muted = theme.color("muted");
719 assert_eq!((h.fg(8, 1), h.fg(8, 2), h.fg(8, 3)), (muted, muted, muted), "nothing selected, nothing to paste");
720 assert_ne!(h.fg(8, 4), muted, "Select all always works");
721 h.press("down").press("enter");
722 assert!(!h.screen().contains("Cut"), "choosing closes the menu");
723 right_click(&mut h, 12, 0);
724 assert_ne!(h.fg(8, 1), muted, "a right click inside the selection keeps it: Cut is enabled");
725 h.click_text("Copy");
726 assert_eq!(h.clipboard(), Some("hello world"));
727 right_click(&mut h, 12, 0);
728 assert_ne!(h.fg(8, 3), muted, "now there is something to paste");
729 h.click_text("Cut");
730 assert_eq!(h.app().value, "");
731 right_click(&mut h, 6, 0);
732 h.click_text("Paste");
733 assert_eq!(h.app().value, "hello world");
734 assert!(h.is_focused("name"));
735 }
736
737 #[test]
738 fn right_click_outside_the_selection_places_the_cursor_first() {
739 let mut h = menu_harness("hello world", false);
740 h.press("tab").press("ctrl+shift+left");
741 right_click(&mut h, 4, 0);
742 let theme = h.env().theme();
743 assert_eq!(h.fg(8, 1), theme.color("muted"), "the selection is gone: Cut is disabled");
744 h.press("esc").type_text("X");
745 assert_eq!(h.app().value, "Xhello world");
746 }
747
748 #[test]
749 fn paste_reads_the_system_clipboard_before_the_last_copy_inside_the_application() {
750 let mut h = menu_harness("hello", false);
751 h.press("tab").press("ctrl+a").press("ctrl+c").press("end");
752 h.set_system_clipboard(Some(" from web"));
753 right_click(&mut h, 20, 0);
754 h.click_text("Paste");
755 assert_eq!(h.app().value, "hello from web");
756 h.set_system_clipboard(None).press("ctrl+v");
757 assert_eq!(h.app().value, "hello from webhello", "an empty system clipboard falls back");
758 }
759
760 #[test]
761 fn shift_f10_and_the_menu_key_open_it_under_the_field() {
762 let mut h = menu_harness("hello", false);
763 h.press("tab").press("shift+f10");
764 assert!(h.screen().lines().nth(1).is_some_and(|line| line.contains("Cut")), "{}", h.screen());
765 h.press("esc");
766 assert!(!h.screen().contains("Cut"));
767 h.press("menu").press("up").press("enter").type_text("!");
768 assert_eq!(h.app().value, "!", "↑ wrapped to Select all");
769 }
770
771 #[test]
772 fn the_menu_fits_a_narrow_screen_in_ascii() {
773 let mut h = Harness::new(Demo { value: "hello".into(), ..Demo::default() }, 18, 6);
774 h.set_reduced_motion(true);
775 h.set_glyph_mode(crate::icons::GlyphMode::Ascii);
776 right_click(&mut h, 15, 0);
777 assert_eq!(h.screen(), " > hello\n Cut ctrl x\n Copy ctrl c\n Paste ctrl v\n Selec~ ctrl a\n\n");
780 assert_ne!(h.bg(0, 0), h.bg(1, 0), "the pillar cell stands out from the field");
781 }
782
783 #[test]
784 fn password_fields_never_copy() {
785 let mut h = menu_harness("secret", true);
786 h.press("tab").press("ctrl+a").press("ctrl+c").press("ctrl+x");
787 assert!(h.copied().is_empty());
788 assert_eq!(h.app().value, "secret");
789 right_click(&mut h, 5, 0);
790 let theme = h.env().theme();
791 assert_eq!((h.fg(8, 1), h.fg(8, 2)), (theme.color("muted"), theme.color("muted")));
792 }
793
794 struct Rename {
796 value: String,
797 range: Option<Range<usize>>,
798 all: bool,
799 }
800
801 impl App for Rename {
802 type Msg = String;
803 fn update(&mut self, value: String) -> Command<String> {
804 self.value = value;
805 Command::none()
806 }
807 fn view(&self, ui: &mut View<'_, String>) {
808 let mut input = TextInput::new(&self.value).on_change(|value| value);
809 if let Some(range) = self.range.clone() {
810 input = input.select_on_focus(range);
811 }
812 if self.all {
813 input = input.select_all_on_focus();
814 }
815 ui.add(input).fill_width().id("name");
816 }
817 }
818
819 fn rename(value: &str, range: Option<Range<usize>>) -> Harness<Rename> {
820 Harness::new(Rename { value: value.into(), range, all: false }, 30, 1)
821 }
822
823 #[test]
824 fn typing_replaces_the_part_selected_on_focus() {
825 let mut h = rename("main.rs", Some(0..4));
826 h.press("tab");
827 assert_eq!(h.screen(), "▌ ❯ main.rs\n");
828 let (selected, plain) = (h.bg(4, 0), h.bg(9, 0));
829 assert_ne!(selected, plain, "main is selected");
830 assert_eq!(h.bg(7, 0), selected, "all four letters");
831 h.type_text("x");
832 assert_eq!(h.app().value, "x.rs");
833 }
834
835 #[test]
836 fn the_cursor_stands_at_the_end_of_the_range_and_an_arrow_drops_the_selection() {
837 let mut h = rename("main.rs", Some(0..4));
838 h.press("tab").press("right").type_text("_");
839 assert_eq!(h.app().value, "main._rs", "→ leaves the selection one step on from its end");
840 let mut h = rename("main.rs", Some(0..4));
841 h.press("tab").press("shift+right").type_text("x");
842 assert_eq!(h.app().value, "xrs", "the cursor was at the range's end, so shift+→ grows it");
843 }
844
845 #[test]
846 fn without_a_range_the_field_opens_as_before() {
847 let mut h = rename("main.rs", None);
848 h.press("tab").type_text("x");
849 assert_eq!(h.app().value, "main.rsx");
850 }
851
852 #[test]
853 fn the_range_counts_characters_and_is_cut_to_the_text() {
854 let mut h = rename("şğü.txt", Some(0..3));
855 h.press("tab").type_text("a");
856 assert_eq!(h.app().value, "a.txt", "three Turkish letters are three characters, six bytes");
857 let mut h = rename("çay", Some(1..40));
858 h.press("tab").type_text("ok");
859 assert_eq!(h.app().value, "çok");
860 let mut h = Harness::new(Rename { value: "e\u{301}te".into(), range: Some(0..1), all: false }, 30, 1);
861 h.press("tab").type_text("a");
862 assert_eq!(h.app().value, "ate", "a range never splits an accented letter built from two characters");
863 }
864
865 #[test]
866 fn select_all_on_focus_selects_everything() {
867 let mut h = Harness::new(Rename { value: "draft".into(), range: None, all: true }, 30, 1);
868 h.press("tab").type_text("final");
869 assert_eq!(h.app().value, "final");
870 }
871
872 #[test]
873 fn a_value_changed_from_outside_keeps_the_users_selection() {
874 let mut h = rename("main.rs", Some(0..4));
875 h.press("tab").press("end");
876 h.send("lib.rs".to_owned()).type_text("!");
877 assert_eq!(h.app().value, "lib.rs!", "the new value is not selected again");
878 }
879
880 #[test]
881 fn a_click_that_brings_focus_places_the_cursor() {
882 let mut h = rename("main.rs", Some(0..4));
883 h.click(9, 0).type_text("X");
884 assert_eq!(h.app().value, "main.Xrs", "the cursor lands where clicked, before r");
885 }
886
887 #[test]
888 fn coming_back_selects_the_range_again() {
889 struct Two(String);
890 impl App for Two {
891 type Msg = String;
892 fn update(&mut self, value: String) -> Command<String> {
893 self.0 = value;
894 Command::none()
895 }
896 fn view(&self, ui: &mut View<'_, String>) {
897 ui.add(TextInput::new(&self.0).select_on_focus(0..4).on_change(|value| value)).fill_width();
898 ui.add(TextInput::new("other")).fill_width();
899 }
900 }
901 let mut h = Harness::new(Two("main.rs".into()), 30, 2);
902 h.press("tab").press("end").press("tab").press("shift+tab").type_text("x");
903 assert_eq!(h.app().0, "x.rs");
904 }
905
906 #[test]
907 fn click_places_cursor_and_value_changes_resync() {
908 let mut h = Harness::new(Demo { value: "abcd".into(), ..Demo::default() }, 30, 1);
909 h.click(5, 0).type_text("X");
910 assert_eq!(h.app().value, "aXbcd");
911 }
912}