Skip to main content

qframe/widgets/
text_input.rs

1//! Single-line text entry.
2
3use 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
20/// What an event did to a field, before any message is sent.
21pub(crate) struct Edit {
22    /// Whether the event was used.
23    pub(crate) handled: bool,
24    /// The new text, when the text changed.
25    pub(crate) changed: Option<String>,
26    /// Whether Enter asked to submit.
27    pub(crate) submit: bool,
28}
29
30/// A text field with real editing: cursor, selection, word jumps, undo and clipboard.
31///
32/// The application owns the value and receives every change through `on_change`; cursor,
33/// selection and undo history live in the runtime.
34///
35/// Keys: ←/→ move (with Ctrl by word, with Shift selecting), Home/End, Backspace/Delete,
36/// Ctrl+W deletes a word, Ctrl+U deletes to the start, Ctrl+A selects all, Ctrl+Z undo,
37/// Ctrl+Y or Ctrl+Shift+Z redo, Ctrl+C and Ctrl+X copy and cut the selection, Enter submits.
38/// With a selection, ← and → clear it and move one step on from its left or right end. Pasting
39/// inserts text. Clicking places the cursor; dragging selects.
40///
41/// A right click (or Shift+F10 and the menu key) opens an edit menu with Cut, Copy, Paste and
42/// Select all. A right click inside the selection keeps it; elsewhere it first places the
43/// cursor there. Cut and Copy are disabled without a selection, and always in password fields,
44/// which never copy; Paste is disabled while there is nothing to paste. Paste reads the system
45/// clipboard, then the terminal's, then the text the application copied last. Entry names are
46/// `quvyta.edit.*`.
47///
48/// Style keys: `text-input` (`bg`, `fg`, `padding`) with `hover`, `focus`, `invalid`,
49/// `disabled`; `text-input-prompt`, `text-input-placeholder`, `text-input-selection`,
50/// `text-input-cursor`.
51pub 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    /// A field showing `value`.
74    #[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    /// Faint text shown while the field is empty.
90    #[must_use]
91    pub fn placeholder(mut self, text: impl Into<String>) -> Self {
92        self.placeholder = text.into();
93        self
94    }
95
96    /// Masks every character.
97    #[must_use]
98    pub fn password(mut self, password: bool) -> Self {
99        self.password = password;
100        self
101    }
102
103    /// Marks the value as failing validation.
104    #[must_use]
105    pub fn invalid(mut self, invalid: bool) -> Self {
106        self.invalid = invalid;
107        self
108    }
109
110    /// Makes the field read-only and unfocusable.
111    #[must_use]
112    pub fn disabled(mut self, disabled: bool) -> Self {
113        self.disabled = disabled;
114        self
115    }
116
117    /// Limits the value to `max` characters.
118    #[must_use]
119    pub fn max_length(mut self, max: usize) -> Self {
120        self.max_length = Some(max);
121        self
122    }
123
124    /// Message carrying the new value after every edit.
125    #[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    /// Message carrying the value when Enter is pressed.
132    #[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    /// Only characters for which `accept` is true can be typed or pasted.
139    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    /// `text` without the characters the field does not accept.
145    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    /// The glyphs drawn for the text: the text itself or a mask.
160    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    /// The row the text uses inside `area`: after the left padding and the prompt, and short of
169    /// as much padding again on the right.
170    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
181/// The grapheme index of byte offset `byte` in `text`.
182fn 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        // Hover and focus raise the pillar in the left padding; the text never slides, so typing
214        // and clicking stay where they are.
215        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        // An empty focused field keeps its placeholder in place; the cursor sits on its first
244        // letter in inverted colours instead of pushing it one cell aside.
245        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    /// The edit menu; a password field never offers to copy.
318    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    /// The grapheme index a press at column `x` lands before.
323    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    /// Offers `event` to the edit menu, which opens on a right press or its keys and takes every
339    /// event while open. Returns `None` when the menu did not use the event.
340    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            // A right press inside the selection keeps it; elsewhere it places the cursor first.
350            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    /// Applies a key to `editor`: editing, moving, the clipboard chords and Enter.
397    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                // A password field never copies.
409                ('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    /// Applies `event` to the text, cursor and selection and copies to the clipboard, without
441    /// sending messages; fields built on a text input decide what the change means.
442    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/// What a key did to a field's editor.
510#[derive(Default)]
511struct KeyEdit {
512    handled: bool,
513    changed: bool,
514    submit: bool,
515    copy: Option<String>,
516}
517
518/// Timing of the cursor blink.
519#[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
526/// Draws the blinking block cursor over the glyph at `x`. The cursor stays solid for one blink
527/// period after every edit so it never disappears while typing.
528pub(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        // The menu moves left to stay on screen and cuts the longest entry; the pillar of the
694        // focused field is a coloured cell in ASCII.
695        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}