Skip to main content

qframe/widgets/
text_input.rs

1//! Single-line text entry.
2
3use 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
21/// What an event did to a field, before any message is sent.
22pub(crate) struct Edit {
23    /// Whether the event was used.
24    pub(crate) handled: bool,
25    /// The new text, when the text changed.
26    pub(crate) changed: Option<String>,
27    /// Whether Enter asked to submit.
28    pub(crate) submit: bool,
29}
30
31/// A text field with real editing: cursor, selection, word jumps, undo and clipboard.
32///
33/// The application owns the value and receives every change through `on_change`; cursor,
34/// selection and undo history live in the runtime.
35///
36/// Keys: ←/→ move (with Ctrl by word, with Shift selecting), Home/End, Backspace/Delete,
37/// Ctrl+W deletes a word, Ctrl+U deletes to the start, Ctrl+A selects all, Ctrl+Z undo,
38/// Ctrl+Y or Ctrl+Shift+Z redo, Ctrl+C and Ctrl+X copy and cut the selection, Enter submits.
39/// With a selection, ← and → clear it and move one step on from its left or right end. Pasting
40/// inserts text. Clicking places the cursor; dragging selects.
41///
42/// [`select_on_focus`](Self::select_on_focus) opens the field with part of its text selected,
43/// such as the name without its extension in a rename dialog; typing then replaces just that part.
44///
45/// A right click (or Shift+F10 and the menu key) opens an edit menu with Cut, Copy, Paste and
46/// Select all. A right click inside the selection keeps it; elsewhere it first places the
47/// cursor there. Cut and Copy are disabled without a selection, and always in password fields,
48/// which never copy; Paste is disabled while there is nothing to paste. Paste reads the system
49/// clipboard, then the terminal's, then the text the application copied last. Entry names are
50/// `quvyta.edit.*`.
51///
52/// Style keys: `text-input` (`bg`, `fg`, `padding`) with `hover`, `focus`, `invalid`,
53/// `disabled`; `text-input-prompt`, `text-input-placeholder`, `text-input-selection`,
54/// `text-input-cursor`.
55pub 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    /// Whether the field had focus when last seen, so the focus selection is applied only as it
76    /// gains focus.
77    focused: bool,
78}
79
80impl<Msg: 'static> TextInput<Msg> {
81    /// A field showing `value`.
82    #[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    /// Faint text shown while the field is empty.
99    #[must_use]
100    pub fn placeholder(mut self, text: impl Into<String>) -> Self {
101        self.placeholder = text.into();
102        self
103    }
104
105    /// Masks every character.
106    #[must_use]
107    pub fn password(mut self, password: bool) -> Self {
108        self.password = password;
109        self
110    }
111
112    /// Marks the value as failing validation.
113    #[must_use]
114    pub fn invalid(mut self, invalid: bool) -> Self {
115        self.invalid = invalid;
116        self
117    }
118
119    /// Makes the field read-only and unfocusable.
120    #[must_use]
121    pub fn disabled(mut self, disabled: bool) -> Self {
122        self.disabled = disabled;
123        self
124    }
125
126    /// Limits the value to `max` characters.
127    #[must_use]
128    pub fn max_length(mut self, max: usize) -> Self {
129        self.max_length = Some(max);
130        self
131    }
132
133    /// Message carrying the new value after every edit.
134    #[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    /// Message carrying the value when Enter is pressed.
141    #[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    /// Selects the characters in `range` each time the field gains focus, with the cursor at the
148    /// range's end, e.g. `0..4` to select `main` in `main.rs`. The range counts characters, not
149    /// bytes, and is cut to the text. From then on the selection is the user's: typing replaces
150    /// it, the arrows drop it, and a value changed from outside does not bring it back. A click
151    /// that gives the field focus places the cursor instead.
152    #[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    /// Selects the whole text each time the field gains focus, like
159    /// [`select_on_focus`](Self::select_on_focus) with a range covering every character.
160    #[must_use]
161    pub fn select_all_on_focus(self) -> Self {
162        self.select_on_focus(0..usize::MAX)
163    }
164
165    /// Only characters for which `accept` is true can be typed or pasted.
166    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    /// `text` without the characters the field does not accept.
172    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    /// Notes whether the field has focus and, as it gains focus, selects the focus range.
187    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    /// The glyphs drawn for the text: the text itself or a mask.
203    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    /// The row the text uses inside `area`: after the left padding and the prompt, and short of
212    /// as much padding again on the right.
213    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
224/// The index of the grapheme that starts at or after character `index` of `text`, so a range
225/// counted in characters never splits a character built from several.
226fn 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
231/// The grapheme index of byte offset `byte` in `text`.
232fn 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        // Hover and focus raise the pillar in the left padding; the text never slides, so typing
264        // and clicking stay where they are.
265        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        // An empty focused field keeps its placeholder in place; the cursor sits on its first
296        // letter in inverted colours instead of pushing it one cell aside.
297        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    /// The edit menu; a password field never offers to copy.
370    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    /// The grapheme index a press at column `x` lands before.
375    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    /// Offers `event` to the edit menu, which opens on a right press or its keys and takes every
391    /// event while open. Returns `None` when the menu did not use the event.
392    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            // A right press inside the selection keeps it; elsewhere it places the cursor first.
402            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    /// Applies a key to `editor`: editing, moving, the clipboard chords and Enter.
449    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                // A password field never copies.
461                ('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    /// Applies `event` to the text, cursor and selection and copies to the clipboard, without
493    /// sending messages; fields built on a text input decide what the change means.
494    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        // A click that brings focus places the cursor itself, so it counts as the focus having
507        // been seen already; any other event applies the focus selection first if no frame has.
508        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/// What a key did to a field's editor.
573#[derive(Default)]
574struct KeyEdit {
575    handled: bool,
576    changed: bool,
577    submit: bool,
578    copy: Option<String>,
579}
580
581/// Timing of the cursor blink.
582#[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
589/// Draws the blinking block cursor over the glyph at `x`. The cursor stays solid for one blink
590/// period after every edit so it never disappears while typing.
591pub(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        // The menu moves left to stay on screen and cuts the longest entry; the pillar of the
757        // focused field is a coloured cell in ASCII.
758        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    /// A rename field that opens with part of the name selected.
774    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}