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        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        // Hover and focus raise the pillar in the left padding; the text never slides, so typing
265        // and clicking stay where they are.
266        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        // An empty focused field keeps its placeholder in place; the cursor sits on its first
297        // letter in inverted colours instead of pushing it one cell aside.
298        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    /// The edit menu; a password field never offers to copy.
371    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    /// The grapheme index a press at column `x` lands before.
376    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    /// Offers `event` to the edit menu, which opens on a right press or its keys and takes every
392    /// event while open. Returns `None` when the menu did not use the event.
393    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            // A right press inside the selection keeps it; elsewhere it places the cursor first.
403            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    /// Applies a key to `editor`: editing, moving, the clipboard chords and Enter.
450    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                // A password field never copies.
462                ('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    /// Applies `event` to the text, cursor and selection and copies to the clipboard, without
494    /// sending messages; fields built on a text input decide what the change means.
495    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        // A click that brings focus places the cursor itself, so it counts as the focus having
508        // been seen already; any other event applies the focus selection first if no frame has.
509        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/// What a key did to a field's editor.
574#[derive(Default)]
575struct KeyEdit {
576    handled: bool,
577    changed: bool,
578    submit: bool,
579    copy: Option<String>,
580}
581
582/// Timing of the cursor blink.
583#[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
590/// Draws the blinking block cursor over the glyph at `x`. The cursor stays solid for one blink
591/// period after every edit so it never disappears while typing.
592pub(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        // The menu moves left to stay on screen and cuts the longest entry with an ASCII mark; the pillar of the
758        // focused field is a coloured cell in ASCII.
759        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    /// A rename field that opens with part of the name selected.
775    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}