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]) {
593 let since = blink.now.saturating_sub(blink.last_edit);
594 let period = blink.period.max(Duration::from_millis(1));
595 let phase = since.as_millis() / period.as_millis();
596 let next = period.saturating_mul(u32::try_from(phase + 1).unwrap_or(u32::MAX)).saturating_sub(since);
597 cx.request_frame_in(next);
598 if phase.is_multiple_of(2) {
599 let style = cx.style("text-input-cursor", None, states).text();
600 cx.text(x, y, glyph, style, text::width(glyph).max(1));
601 }
602}
603
604#[cfg(test)]
605mod tests {
606 use super::*;
607 use crate::runtime::{App, Command, Harness};
608 use crate::widget::View;
609
610 #[derive(Default)]
611 struct Demo {
612 value: String,
613 submitted: Option<String>,
614 password: bool,
615 }
616
617 enum Msg {
618 Changed(String),
619 Submitted(String),
620 }
621
622 impl App for Demo {
623 type Msg = Msg;
624 fn update(&mut self, msg: Msg) -> Command<Msg> {
625 match msg {
626 Msg::Changed(value) => self.value = value,
627 Msg::Submitted(value) => self.submitted = Some(value),
628 }
629 Command::none()
630 }
631 fn view(&self, ui: &mut View<'_, Msg>) {
632 ui.add(
633 TextInput::new(&self.value)
634 .placeholder("Project name")
635 .password(self.password)
636 .max_length(20)
637 .on_change(Msg::Changed)
638 .on_submit(Msg::Submitted),
639 )
640 .fill_width()
641 .id("name");
642 }
643 }
644
645 #[test]
646 fn types_edits_and_submits() {
647 let mut h = Harness::new(Demo::default(), 30, 1);
648 assert_eq!(h.screen(), " ❯ Project name\n");
649 h.press("tab").type_text("quvyta framework");
650 assert_eq!(h.app().value, "quvyta framework");
651 h.press("ctrl+w").press("ctrl+w");
652 assert_eq!(h.app().value, "");
653 h.press("ctrl+z");
654 assert_eq!(h.app().value, "quvyta ");
655 h.press("enter");
656 assert_eq!(h.app().submitted.as_deref(), Some("quvyta "));
657 }
658
659 #[test]
660 fn selection_copy_and_paste() {
661 let mut h = Harness::new(Demo { value: "hello world".into(), ..Demo::default() }, 30, 1);
662 h.press("tab").press("ctrl+shift+left").press("ctrl+c");
663 assert_eq!(h.copied(), &["world".to_owned()]);
664 h.paste("there");
665 assert_eq!(h.app().value, "hello there");
666 }
667
668 #[test]
669 fn scrolls_long_text_and_masks_passwords() {
670 let mut h = Harness::new(Demo { password: true, ..Demo::default() }, 12, 1);
671 h.press("tab").type_text("abcdefghijklmn");
672 let screen = h.screen();
673 assert_eq!(h.app().value, "abcdefghijklmn");
674 assert!(screen.starts_with("▌ ❯ •••••"), "{screen}");
675 assert!(!screen.contains('a'));
676 }
677
678 fn right_click(h: &mut Harness<Demo>, x: i32, y: i32) {
679 h.mouse(MouseKind::Down(MouseButton::Right), x, y);
680 h.mouse(MouseKind::Up(MouseButton::Right), x, y);
681 }
682
683 fn menu_harness(value: &str, password: bool) -> Harness<Demo> {
684 let mut h = Harness::new(Demo { value: value.into(), password, ..Demo::default() }, 30, 7);
685 h.set_reduced_motion(true);
686 h
687 }
688
689 #[test]
690 fn right_click_opens_an_edit_menu_whose_entries_need_a_selection_or_a_clipboard() {
691 let mut h = menu_harness("hello world", false);
692 right_click(&mut h, 12, 0);
693 assert_eq!(
694 h.screen(),
695 "▌ ❯ hello world\n Cut ctrl x\n Copy ctrl c\n Paste ctrl v\n Select all ctrl a\n\n\n"
696 );
697 let theme = h.env().theme();
698 let muted = theme.color("muted");
699 assert_eq!((h.fg(8, 1), h.fg(8, 2), h.fg(8, 3)), (muted, muted, muted), "nothing selected, nothing to paste");
700 assert_ne!(h.fg(8, 4), muted, "Select all always works");
701 h.press("down").press("enter");
702 assert!(!h.screen().contains("Cut"), "choosing closes the menu");
703 right_click(&mut h, 12, 0);
704 assert_ne!(h.fg(8, 1), muted, "a right click inside the selection keeps it: Cut is enabled");
705 h.click_text("Copy");
706 assert_eq!(h.clipboard(), Some("hello world"));
707 right_click(&mut h, 12, 0);
708 assert_ne!(h.fg(8, 3), muted, "now there is something to paste");
709 h.click_text("Cut");
710 assert_eq!(h.app().value, "");
711 right_click(&mut h, 6, 0);
712 h.click_text("Paste");
713 assert_eq!(h.app().value, "hello world");
714 assert!(h.is_focused("name"));
715 }
716
717 #[test]
718 fn right_click_outside_the_selection_places_the_cursor_first() {
719 let mut h = menu_harness("hello world", false);
720 h.press("tab").press("ctrl+shift+left");
721 right_click(&mut h, 4, 0);
722 let theme = h.env().theme();
723 assert_eq!(h.fg(8, 1), theme.color("muted"), "the selection is gone: Cut is disabled");
724 h.press("esc").type_text("X");
725 assert_eq!(h.app().value, "Xhello world");
726 }
727
728 #[test]
729 fn paste_reads_the_system_clipboard_before_the_last_copy_inside_the_application() {
730 let mut h = menu_harness("hello", false);
731 h.press("tab").press("ctrl+a").press("ctrl+c").press("end");
732 h.set_system_clipboard(Some(" from web"));
733 right_click(&mut h, 20, 0);
734 h.click_text("Paste");
735 assert_eq!(h.app().value, "hello from web");
736 h.set_system_clipboard(None).press("ctrl+v");
737 assert_eq!(h.app().value, "hello from webhello", "an empty system clipboard falls back");
738 }
739
740 #[test]
741 fn shift_f10_and_the_menu_key_open_it_under_the_field() {
742 let mut h = menu_harness("hello", false);
743 h.press("tab").press("shift+f10");
744 assert!(h.screen().lines().nth(1).is_some_and(|line| line.contains("Cut")), "{}", h.screen());
745 h.press("esc");
746 assert!(!h.screen().contains("Cut"));
747 h.press("menu").press("up").press("enter").type_text("!");
748 assert_eq!(h.app().value, "!", "↑ wrapped to Select all");
749 }
750
751 #[test]
752 fn the_menu_fits_a_narrow_screen_in_ascii() {
753 let mut h = Harness::new(Demo { value: "hello".into(), ..Demo::default() }, 18, 6);
754 h.set_reduced_motion(true);
755 h.set_glyph_mode(crate::icons::GlyphMode::Ascii);
756 right_click(&mut h, 15, 0);
757 assert_eq!(h.screen(), " > hello\n Cut ctrl x\n Copy ctrl c\n Paste ctrl v\n Selec~ ctrl a\n\n");
760 assert_ne!(h.bg(0, 0), h.bg(1, 0), "the pillar cell stands out from the field");
761 }
762
763 #[test]
764 fn password_fields_never_copy() {
765 let mut h = menu_harness("secret", true);
766 h.press("tab").press("ctrl+a").press("ctrl+c").press("ctrl+x");
767 assert!(h.copied().is_empty());
768 assert_eq!(h.app().value, "secret");
769 right_click(&mut h, 5, 0);
770 let theme = h.env().theme();
771 assert_eq!((h.fg(8, 1), h.fg(8, 2)), (theme.color("muted"), theme.color("muted")));
772 }
773
774 struct Rename {
776 value: String,
777 range: Option<Range<usize>>,
778 all: bool,
779 }
780
781 impl App for Rename {
782 type Msg = String;
783 fn update(&mut self, value: String) -> Command<String> {
784 self.value = value;
785 Command::none()
786 }
787 fn view(&self, ui: &mut View<'_, String>) {
788 let mut input = TextInput::new(&self.value).on_change(|value| value);
789 if let Some(range) = self.range.clone() {
790 input = input.select_on_focus(range);
791 }
792 if self.all {
793 input = input.select_all_on_focus();
794 }
795 ui.add(input).fill_width().id("name");
796 }
797 }
798
799 fn rename(value: &str, range: Option<Range<usize>>) -> Harness<Rename> {
800 Harness::new(Rename { value: value.into(), range, all: false }, 30, 1)
801 }
802
803 #[test]
804 fn typing_replaces_the_part_selected_on_focus() {
805 let mut h = rename("main.rs", Some(0..4));
806 h.press("tab");
807 assert_eq!(h.screen(), "▌ ❯ main.rs\n");
808 let (selected, plain) = (h.bg(4, 0), h.bg(9, 0));
809 assert_ne!(selected, plain, "main is selected");
810 assert_eq!(h.bg(7, 0), selected, "all four letters");
811 h.type_text("x");
812 assert_eq!(h.app().value, "x.rs");
813 }
814
815 #[test]
816 fn the_cursor_stands_at_the_end_of_the_range_and_an_arrow_drops_the_selection() {
817 let mut h = rename("main.rs", Some(0..4));
818 h.press("tab").press("right").type_text("_");
819 assert_eq!(h.app().value, "main._rs", "→ leaves the selection one step on from its end");
820 let mut h = rename("main.rs", Some(0..4));
821 h.press("tab").press("shift+right").type_text("x");
822 assert_eq!(h.app().value, "xrs", "the cursor was at the range's end, so shift+→ grows it");
823 }
824
825 #[test]
826 fn without_a_range_the_field_opens_as_before() {
827 let mut h = rename("main.rs", None);
828 h.press("tab").type_text("x");
829 assert_eq!(h.app().value, "main.rsx");
830 }
831
832 #[test]
833 fn the_range_counts_characters_and_is_cut_to_the_text() {
834 let mut h = rename("şğü.txt", Some(0..3));
835 h.press("tab").type_text("a");
836 assert_eq!(h.app().value, "a.txt", "three Turkish letters are three characters, six bytes");
837 let mut h = rename("çay", Some(1..40));
838 h.press("tab").type_text("ok");
839 assert_eq!(h.app().value, "çok");
840 let mut h = Harness::new(Rename { value: "e\u{301}te".into(), range: Some(0..1), all: false }, 30, 1);
841 h.press("tab").type_text("a");
842 assert_eq!(h.app().value, "ate", "a range never splits an accented letter built from two characters");
843 }
844
845 #[test]
846 fn select_all_on_focus_selects_everything() {
847 let mut h = Harness::new(Rename { value: "draft".into(), range: None, all: true }, 30, 1);
848 h.press("tab").type_text("final");
849 assert_eq!(h.app().value, "final");
850 }
851
852 #[test]
853 fn a_value_changed_from_outside_keeps_the_users_selection() {
854 let mut h = rename("main.rs", Some(0..4));
855 h.press("tab").press("end");
856 h.send("lib.rs".to_owned()).type_text("!");
857 assert_eq!(h.app().value, "lib.rs!", "the new value is not selected again");
858 }
859
860 #[test]
861 fn a_click_that_brings_focus_places_the_cursor() {
862 let mut h = rename("main.rs", Some(0..4));
863 h.click(9, 0).type_text("X");
864 assert_eq!(h.app().value, "main.Xrs", "the cursor lands where clicked, before r");
865 }
866
867 #[test]
868 fn coming_back_selects_the_range_again() {
869 struct Two(String);
870 impl App for Two {
871 type Msg = String;
872 fn update(&mut self, value: String) -> Command<String> {
873 self.0 = value;
874 Command::none()
875 }
876 fn view(&self, ui: &mut View<'_, String>) {
877 ui.add(TextInput::new(&self.0).select_on_focus(0..4).on_change(|value| value)).fill_width();
878 ui.add(TextInput::new("other")).fill_width();
879 }
880 }
881 let mut h = Harness::new(Two("main.rs".into()), 30, 2);
882 h.press("tab").press("end").press("tab").press("shift+tab").type_text("x");
883 assert_eq!(h.app().0, "x.rs");
884 }
885
886 #[test]
887 fn click_places_cursor_and_value_changes_resync() {
888 let mut h = Harness::new(Demo { value: "abcd".into(), ..Demo::default() }, 30, 1);
889 h.click(5, 0).type_text("X");
890 assert_eq!(h.app().value, "aXbcd");
891 }
892}