Skip to main content

ravenrs_widgets/
text_field.rs

1use std::fmt;
2use std::rc::Rc;
3
4use ravenrs_core::{Border, Color, CursorIcon, Insets, Key, Modifiers, Quad, Rect, Role};
5use ravenrs_layout::{Align, Axis, Justify, LayoutStyle};
6use ravenrs_reactive::{Binding, State};
7use ravenrs_style::Semantic;
8
9use crate::context::{BuildCx, DragPhase, NodeId, Paint, QuadPaint, Semantics};
10use crate::{AnyView, ButtonSize, ButtonStyle, IconButton, IconName, View, util};
11
12/// A single-line text input bound to a `String`.
13///
14/// Supports typing, deleting, moving by character, word and line, selecting
15/// with Shift, the mouse (drag, double-click) or Cmd/Ctrl+A, copy, cut and
16/// paste, and Enter (submit). Passwords can be pasted but not copied.
17///
18/// ```
19/// use ravenrs_reactive::State;
20/// use ravenrs_widgets::{IconName, TextField, Ui};
21///
22/// let query = State::new(String::new());
23/// let field = TextField::new(query.binding()).placeholder("Search").icon(IconName::Search).clearable();
24/// let mut ui = Ui::new(field, ravenrs_style::Theme::raven_dark());
25/// ui.update();
26/// ```
27#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
28pub struct TextField {
29    value: Binding<String>,
30    placeholder: String,
31    icon: Option<IconName>,
32    secure: bool,
33    width: Option<f32>,
34    size: ButtonSize,
35    prefix: Option<AnyView>,
36    suffix: Option<AnyView>,
37    clearable: bool,
38    flat: bool,
39    end_aligned: bool,
40    autofocus: bool,
41    select_on_open: bool,
42    on_submit: Option<Rc<dyn Fn(String)>>,
43    on_leave: Option<Rc<dyn Fn()>>,
44    on_cancel: Option<Rc<dyn Fn()>>,
45    intercept: Option<crate::KeyHandler>,
46    state: util::EditorState,
47    /// Shown instead of the value while the field does not have focus.
48    pub(crate) idle_text: Option<String>,
49    /// A caret at the end stays there when a change makes the text longer.
50    pub(crate) caret_follows_end: bool,
51}
52
53impl fmt::Debug for TextField {
54    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55        f.debug_struct("TextField").field("placeholder", &self.placeholder).finish_non_exhaustive()
56    }
57}
58
59#[cfg_attr(feature = "live", ravenrs_live::expose)]
60impl TextField {
61    /// An empty-placeholder field bound to `value`.
62    pub fn new(value: Binding<String>) -> Self {
63        Self {
64            value,
65            placeholder: String::new(),
66            icon: None,
67            secure: false,
68            width: None,
69            size: ButtonSize::Medium,
70            prefix: None,
71            suffix: None,
72            clearable: false,
73            flat: false,
74            end_aligned: false,
75            autofocus: false,
76            select_on_open: false,
77            on_submit: None,
78            on_leave: None,
79            on_cancel: None,
80            intercept: None,
81            state: util::EditorState::default(),
82            idle_text: None,
83            caret_follows_end: false,
84        }
85    }
86
87    /// Muted text shown while the value is empty.
88    pub fn placeholder(mut self, text: impl Into<String>) -> Self {
89        self.placeholder = text.into();
90        self
91    }
92
93    /// An icon inside the field, before the text.
94    pub fn icon(mut self, icon: IconName) -> Self {
95        self.icon = Some(icon);
96        self
97    }
98
99    /// Shows dots instead of the characters (passwords).
100    pub fn secure(mut self) -> Self {
101        self.secure = true;
102        self
103    }
104
105    /// Fixed width in logical pixels; by default the field stretches.
106    pub fn width(mut self, width: f32) -> Self {
107        self.width = Some(width);
108        self
109    }
110
111    /// A shorter field with smaller text, for toolbars, filter rows and cells: the same
112    /// as [`Self::size`] with [`ButtonSize::Small`].
113    pub fn compact(mut self) -> Self {
114        self.size = ButtonSize::Small;
115        self
116    }
117
118    /// Sets the height and text size to line up with a [`Button`](crate::Button) of the
119    /// same size (`Medium` by default). The smaller ones have no minimum width.
120    pub fn size(mut self, size: ButtonSize) -> Self {
121        self.size = size;
122        self
123    }
124
125    /// Any view inside the field, before the text, such as a currency or a unit.
126    pub fn prefix(mut self, view: impl View + 'static) -> Self {
127        self.prefix = Some(Box::new(view));
128        self
129    }
130
131    /// Any view inside the field, after the text, such as a unit or a button.
132    pub fn suffix(mut self, view: impl View + 'static) -> Self {
133        self.suffix = Some(Box::new(view));
134        self
135    }
136
137    /// A button inside the field that empties it, shown while there is text to clear.
138    /// The keyboard comes back to the field afterwards.
139    pub fn clearable(mut self) -> Self {
140        self.clearable = true;
141        self
142    }
143
144    /// No box of its own until it is pointed at or focused, filling what it is in, for fields that
145    /// line up with what is around them, as under the columns of a grid.
146    pub fn flat(mut self) -> Self {
147        self.flat = true;
148        self
149    }
150
151    /// Puts the text at the end of the field, as under a column of numbers.
152    pub fn end_aligned(mut self) -> Self {
153        self.end_aligned = true;
154        self
155    }
156
157    /// Takes keyboard focus when it first appears.
158    pub fn autofocus(mut self) -> Self {
159        self.autofocus = true;
160        self
161    }
162
163    /// Starts with all of its text selected, so that typing replaces it.
164    pub fn select_on_open(mut self) -> Self {
165        self.select_on_open = true;
166        self
167    }
168
169    /// Called when Escape is pressed while the field is focused.
170    pub fn on_cancel(mut self, handler: impl Fn() + 'static) -> Self {
171        self.on_cancel = Some(Rc::new(handler));
172        self
173    }
174
175    /// Sees keys before the field edits; return true to consume them (lists
176    /// navigated from a search field use it for arrows and Enter).
177    pub fn intercept_keys(mut self, handler: crate::KeyHandler) -> Self {
178        self.intercept = Some(handler);
179        self
180    }
181
182    /// Called with the current text when Enter is pressed.
183    pub fn on_submit(mut self, handler: impl Fn(String) + 'static) -> Self {
184        self.on_submit = Some(Rc::new(handler));
185        self
186    }
187
188    /// Called when the keyboard goes elsewhere after being here.
189    pub fn on_leave(mut self, handler: impl Fn() + 'static) -> Self {
190        self.on_leave = Some(Rc::new(handler));
191        self
192    }
193
194    /// Turns the control off: it fades, leaves the tab order and ignores input.
195    pub fn disabled(mut self, disabled: bool) -> Self {
196        self.state.disabled = disabled;
197        self
198    }
199
200    /// Shows the value without letting it change; it can still be focused, selected and copied.
201    pub fn read_only(mut self, read_only: bool) -> Self {
202        self.state.read_only = read_only;
203        self
204    }
205}
206
207pub(crate) fn byte_index(s: &str, char_index: usize) -> usize {
208    s.char_indices().nth(char_index).map_or(s.len(), |(i, _)| i)
209}
210
211/// Caret and selection of a text field, as char indices. The selection runs
212/// from `anchor` to `caret`.
213#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
214pub(crate) struct Cursor {
215    pub caret: usize,
216    pub anchor: Option<usize>,
217}
218
219#[cfg_attr(feature = "live", ravenrs_live::expose)]
220impl Cursor {
221    /// A caret at `at` with nothing selected.
222    pub fn at(at: usize) -> Self {
223        Self { caret: at, anchor: None }
224    }
225
226    /// The selected range, if it is not empty.
227    pub fn selection(&self) -> Option<(usize, usize)> {
228        let a = self.anchor?;
229        (a != self.caret).then(|| (a.min(self.caret), a.max(self.caret)))
230    }
231
232    /// Moves the caret, extending the selection when `extend` is true.
233    pub(crate) fn move_to(&mut self, to: usize, extend: bool) {
234        if extend {
235            self.anchor.get_or_insert(self.caret);
236        } else {
237            self.anchor = None;
238        }
239        self.caret = to;
240    }
241}
242
243fn is_word(c: char) -> bool {
244    c.is_alphanumeric() || c == '_'
245}
246
247/// The start of the word before `at` (Alt/Ctrl+Left).
248fn word_start(chars: &[char], at: usize) -> usize {
249    let mut i = at.min(chars.len());
250    while i > 0 && !is_word(chars[i - 1]) {
251        i -= 1;
252    }
253    while i > 0 && is_word(chars[i - 1]) {
254        i -= 1;
255    }
256    i
257}
258
259/// The end of the word after `at` (Alt/Ctrl+Right).
260fn word_end(chars: &[char], at: usize) -> usize {
261    let mut i = at.min(chars.len());
262    while i < chars.len() && !is_word(chars[i]) {
263        i += 1;
264    }
265    while i < chars.len() && is_word(chars[i]) {
266        i += 1;
267    }
268    i
269}
270
271/// The word around `at`, for double-click selection.
272pub(crate) fn word_at(text: &str, at: usize) -> (usize, usize) {
273    let chars: Vec<char> = text.chars().collect();
274    let at = at.min(chars.len());
275    let on_word = |i: usize| chars.get(i).is_some_and(|c| is_word(*c));
276    if !on_word(at) && !(at > 0 && on_word(at - 1)) {
277        return (at, (at + 1).min(chars.len()));
278    }
279    let mut start = at;
280    while start > 0 && is_word(chars[start - 1]) {
281        start -= 1;
282    }
283    let mut end = at;
284    while end < chars.len() && is_word(chars[end]) {
285        end += 1;
286    }
287    (start, end)
288}
289
290/// Removes the selection, leaving the caret where it was. Returns false when
291/// nothing was selected.
292fn delete_selection(text: &mut String, cursor: &mut Cursor) -> bool {
293    let Some((a, b)) = cursor.selection() else { return false };
294    text.replace_range(byte_index(text, a)..byte_index(text, b), "");
295    *cursor = Cursor::at(a);
296    true
297}
298
299pub(crate) fn insert(text: &mut String, cursor: &mut Cursor, s: &str) {
300    delete_selection(text, cursor);
301    let at = byte_index(text, cursor.caret);
302    text.insert_str(at, s);
303    *cursor = Cursor::at(cursor.caret + s.chars().count());
304}
305
306/// How a field treats the clipboard.
307pub(crate) struct ClipboardAccess<'a> {
308    pub clipboard: &'a crate::Clipboard,
309    /// Passwords can be pasted into but never copied out of.
310    pub secure: bool,
311    /// Pasted text keeps its line breaks.
312    pub multiline: bool,
313}
314
315/// Applies a key to `text` and the cursor: typing, deleting, moving (Shift
316/// extends the selection; Alt or Ctrl moves by word; Cmd moves to the ends),
317/// select all, and copy, cut and paste with Cmd or Ctrl. Returns false for
318/// keys the field does not handle, including a paste the clipboard cannot
319/// read synchronously (the browser then delivers it as typed text).
320pub(crate) fn edit(
321    text: &mut String,
322    cursor: &mut Cursor,
323    key: &Key,
324    modifiers: Modifiers,
325    clipboard: Option<ClipboardAccess<'_>>,
326) -> bool {
327    let chars: Vec<char> = text.chars().collect();
328    let len = chars.len();
329    cursor.caret = cursor.caret.min(len);
330    cursor.anchor = cursor.anchor.map(|a| a.min(len));
331    let shift = modifiers.shift;
332    let word = modifiers.alt || modifiers.ctrl;
333    let command = modifiers.ctrl || modifiers.meta;
334    let multiline = clipboard.as_ref().is_some_and(|access| access.multiline);
335    if command && let Key::Character(c) = key {
336        match c.to_ascii_lowercase().as_str() {
337            "a" => *cursor = Cursor { caret: len, anchor: Some(0) },
338            "c" | "x" => {
339                let Some(access) = clipboard else { return false };
340                let Some((a, b)) = cursor.selection() else { return true };
341                if access.secure {
342                    return true;
343                }
344                access.clipboard.write(&chars[a..b].iter().collect::<String>());
345                if c.eq_ignore_ascii_case("x") {
346                    delete_selection(text, cursor);
347                }
348            }
349            "v" => {
350                let Some(pasted) = clipboard.and_then(|access| access.clipboard.read()) else { return false };
351                if multiline {
352                    insert(text, cursor, &pasted.replace("\r\n", "\n").replace('\r', "\n"));
353                } else {
354                    // A single-line field keeps the first line.
355                    let line = pasted.lines().next().unwrap_or_default();
356                    insert(text, cursor, line);
357                }
358            }
359            _ => return false,
360        }
361        return true;
362    }
363    match key {
364        // Line breaks (from a browser paste, say) become spaces in one line.
365        Key::Character(s) if !command => {
366            insert(text, cursor, &s.replace("\r\n", " ").replace(['\n', '\r'], " "))
367        }
368        Key::Space if !command => insert(text, cursor, " "),
369        Key::Backspace | Key::Delete if delete_selection(text, cursor) => {}
370        Key::Backspace if cursor.caret > 0 => {
371            let from = if modifiers.meta {
372                0
373            } else if word {
374                word_start(&chars, cursor.caret)
375            } else {
376                cursor.caret - 1
377            };
378            text.replace_range(byte_index(text, from)..byte_index(text, cursor.caret), "");
379            *cursor = Cursor::at(from);
380        }
381        Key::Delete if cursor.caret < len => {
382            let to = if word { word_end(&chars, cursor.caret) } else { cursor.caret + 1 };
383            text.replace_range(byte_index(text, cursor.caret)..byte_index(text, to), "");
384            *cursor = Cursor::at(cursor.caret);
385        }
386        Key::Backspace | Key::Delete => {}
387        Key::ArrowLeft | Key::ArrowRight => {
388            let left = *key == Key::ArrowLeft;
389            let to = match (cursor.selection(), shift) {
390                // Without Shift, an arrow collapses the selection to that side.
391                (Some((a, b)), false) if !word && !modifiers.meta => {
392                    if left {
393                        a
394                    } else {
395                        b
396                    }
397                }
398                _ if modifiers.meta => {
399                    if left {
400                        0
401                    } else {
402                        len
403                    }
404                }
405                _ if word => {
406                    if left {
407                        word_start(&chars, cursor.caret)
408                    } else {
409                        word_end(&chars, cursor.caret)
410                    }
411                }
412                _ if left => cursor.caret.saturating_sub(1),
413                _ => (cursor.caret + 1).min(len),
414            };
415            cursor.move_to(to, shift);
416        }
417        Key::Home | Key::ArrowUp => cursor.move_to(0, shift),
418        Key::End | Key::ArrowDown => cursor.move_to(len, shift),
419        _ => return false,
420    }
421    true
422}
423
424/// The char index nearest to `x` (relative to the text's left edge), given
425/// the x position of every char boundary.
426pub(crate) fn index_at(boundaries: &[f32], x: f32) -> usize {
427    let after = boundaries.partition_point(|b| *b < x);
428    match after {
429        0 => 0,
430        n if n >= boundaries.len() => boundaries.len().saturating_sub(1),
431        n => {
432            if x - boundaries[n - 1] < boundaries[n] - x {
433                n - 1
434            } else {
435                n
436            }
437        }
438    }
439}
440
441impl View for TextField {
442    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
443        let theme = *cx.theme();
444        let cursor: State<Cursor> = cx.use_state(|| {
445            if self.select_on_open {
446                Cursor { caret: self.value.get().chars().count(), anchor: Some(0) }
447            } else {
448                Cursor::at(usize::MAX)
449            }
450        });
451        let focus_requested: State<bool> = cx.use_state(|| false);
452        let state = self.state;
453        if self.autofocus && !state.disabled && !*focus_requested.peek() {
454            focus_requested.set_if_changed(true);
455            cx.request_focus();
456        }
457        // Set by the clear button, which took the focus with its click.
458        let refocus: State<bool> = cx.use_state(|| false);
459        if *refocus.peek() {
460            refocus.set(false);
461            cx.request_focus();
462        }
463        let focused = cx.is_focused() && !state.disabled;
464        let had_focus: State<bool> = cx.use_state(|| false);
465        if *had_focus.peek() != focused {
466            had_focus.set(focused);
467            if !focused && let Some(left) = &self.on_leave {
468                left();
469            }
470        }
471        let hovered = cx.is_hovered() && !state.disabled;
472        let text = self.value.get();
473        let len = text.chars().count();
474        let cur = cursor.get();
475        let caret_at = cur.caret.min(len);
476        let visible = self.idle_text.as_ref().filter(|_| !focused).unwrap_or(&text);
477        let shown: String = if self.secure { "•".repeat(len) } else { visible.clone() };
478        let dense = matches!(self.size, ButtonSize::Small | ButtonSize::Mini);
479        let attrs = util::attrs(&theme, self.size.text_style(), None);
480
481        let mut children = Vec::new();
482        if let Some(icon) = self.icon {
483            let size = if self.size == ButtonSize::Mini { 12.0 } else { 16.0 };
484            children.push(util::icon(cx, icon, size, state.ink(theme.color(Semantic::InkMuted))));
485        }
486        // Keyed, so that the clear button coming and going does not move their state.
487        if let Some(prefix) = &self.prefix {
488            children.push(cx.keyed_child(1, prefix));
489        }
490        // Where each char boundary falls, for the caret, the selection and clicks.
491        let boundaries: Rc<Vec<f32>> = Rc::new(if shown.chars().count() <= 512 {
492            let chars: Vec<char> = shown.chars().collect();
493            (0..=chars.len())
494                .map(|i| cx.measure_text(&chars[..i].iter().collect::<String>(), &attrs).width)
495                .collect()
496        } else {
497            // Long text: one measurement, spread evenly.
498            let w = cx.measure_text(&shown, &attrs).width;
499            let n = shown.chars().count();
500            (0..=n).map(|i| w * i as f32 / n as f32).collect()
501        });
502        let caret_x = boundaries.get(caret_at).copied().unwrap_or(0.0);
503        let selection = cur.selection().map(|(a, b)| {
504            let at = |i: usize| boundaries.get(i.min(len)).copied().unwrap_or(0.0);
505            (at(a), at(b))
506        });
507        let highlight = theme.color(Semantic::Accent).with_alpha(if focused { 0.32 } else { 0.16 });
508        let content = if shown.is_empty() {
509            cx.text(
510                &self.placeholder,
511                &attrs,
512                state.ink(theme.color(Semantic::InkMuted)),
513                LayoutStyle::leaf(),
514            )
515        } else {
516            cx.text(&shown, &attrs, state.ink(theme.color(Semantic::Ink)), LayoutStyle::leaf())
517        };
518        let accent = theme.color(Semantic::Accent);
519        let line = attrs.line_height;
520        let end_aligned = self.end_aligned;
521        let text_width = boundaries.last().copied().unwrap_or(0.0);
522        // How far the text starts into an area of this width.
523        let inset = move |width: f32| if end_aligned { (width - text_width).max(0.0) } else { 0.0 };
524        let text_area = cx.container(
525            LayoutStyle {
526                grow: 1.0,
527                min_width: Some(0.0),
528                align: Align::Center,
529                justify: if end_aligned { Justify::End } else { Justify::Start },
530                overflow_hidden: true,
531                ..LayoutStyle::stack(Axis::Horizontal)
532            },
533            &[content],
534            Paint::Custom(Rc::new(move |f: Rect, scene| {
535                let y = f.center().y - line / 2.0 + 2.0;
536                let start = f.x() + inset(f.width());
537                if let Some((a, b)) = selection {
538                    scene.push_quad(
539                        Quad::new(Rect::new(start + a, y, b - a, line - 4.0), highlight).radius(2.0),
540                    );
541                }
542                if focused && selection.is_none() && state.accepts_input() {
543                    let x = (start + caret_x).min(f.max_x() - 1.5);
544                    scene.push_quad(Quad::new(Rect::new(x, y, 1.5, line - 4.0), accent).radius(0.75));
545                }
546            })),
547        );
548        cx.clip(text_area);
549        // Press places the caret, dragging selects, Shift-click extends.
550        if !state.disabled {
551            let (drag_cursor, mods, b) = (cursor.clone(), cx.modifiers(), boundaries.clone());
552            cx.on_drag(
553                text_area,
554                Rc::new(move |d| {
555                    let at = index_at(&b, d.position.x - d.frame.x() - inset(d.frame.width()));
556                    match d.phase {
557                        DragPhase::Start => drag_cursor.update(|c| {
558                            if mods.get().shift {
559                                c.move_to(at, true);
560                            } else {
561                                *c = Cursor { caret: at, anchor: Some(at) };
562                            }
563                        }),
564                        DragPhase::Move => drag_cursor.update(|c| c.move_to(at, true)),
565                        _ => {}
566                    }
567                }),
568            );
569            let (word_cursor, word_value) = (cursor.clone(), self.value.clone());
570            cx.on_double_click(
571                text_area,
572                Rc::new(move || {
573                    let caret = word_cursor.get().caret;
574                    let (a, b) = word_at(&word_value.get(), caret);
575                    word_cursor.set(Cursor { caret: b, anchor: Some(a) });
576                }),
577            );
578            cx.cursor(text_area, CursorIcon::Text);
579        }
580        children.push(text_area);
581        if self.clearable && !text.is_empty() && state.accepts_input() {
582            let (value, caret, refocus) = (self.value.clone(), cursor.clone(), refocus.clone());
583            let clear = IconButton::new(IconName::X)
584                .label(cx.labels().clear.clone())
585                .style(ButtonStyle::Ghost)
586                .size(ButtonSize::Mini)
587                .on_click(move || {
588                    value.set(String::new());
589                    caret.set(Cursor::at(usize::MAX));
590                    refocus.set(true);
591                });
592            children.push(cx.keyed_child(2, &clear));
593        }
594        if let Some(suffix) = &self.suffix {
595            children.push(cx.keyed_child(3, suffix));
596        }
597
598        let border = if focused {
599            Border { width: 1.5, color: accent }
600        } else {
601            let base = theme.color(Semantic::Border);
602            Border {
603                width: 1.0,
604                color: state.ink(if hovered { base.lerp(theme.color(Semantic::Ink), 0.25) } else { base }),
605            }
606        };
607        let style = LayoutStyle {
608            padding: Insets::symmetric(0.0, if dense && !self.flat { 8.0 } else { 12.0 }),
609            height: if self.flat { None } else { Some(self.size.height()) },
610            grow: if self.flat { 1.0 } else { 0.0 },
611            width: self.width,
612            min_width: Some(if dense { 0.0 } else { 160.0 }),
613            spacing: 8.0,
614            align: Align::Center,
615            align_self: Some(if self.width.is_some() { Align::Start } else { Align::Stretch }),
616            ..LayoutStyle::stack(Axis::Horizontal)
617        };
618        let paint = if self.flat {
619            let fill = if hovered && !focused {
620                theme.color(Semantic::Ink).with_alpha(0.05)
621            } else {
622                Color::TRANSPARENT
623            };
624            QuadPaint {
625                corner_radius: theme.radii.sm,
626                border: focused.then_some(border),
627                ..QuadPaint::new(fill)
628            }
629        } else {
630            QuadPaint {
631                corner_radius: theme.radii.md,
632                border: Some(border),
633                ..QuadPaint::new(state.fill(theme.color(Semantic::Control)))
634            }
635        };
636        let node = cx.container(style, &children, Paint::Quad(paint));
637        crate::motion::control(cx, node);
638        // The visible text is exposed as the value; the placeholder names the
639        // field until a label (a FormField, say) gives it a real name.
640        cx.semantics(text_area, Semantics::hidden());
641        let mut semantics = Semantics::new(Role::TextInput).value(shown);
642        if !self.placeholder.is_empty() {
643            semantics = semantics.name(self.placeholder.clone());
644            semantics.fallback_name = true;
645        }
646        cx.semantics(node, state.semantics(semantics));
647        if state.disabled {
648            return node;
649        }
650
651        let (value, submit, cancel) = (self.value.clone(), self.on_submit.clone(), self.on_cancel.clone());
652        let (intercept, clipboard, secure) = (self.intercept.clone(), cx.clipboard(), self.secure);
653        let follows_end = self.caret_follows_end;
654        cx.on_key(
655            node,
656            Rc::new(move |key, modifiers| {
657                if let Some(i) = &intercept
658                    && i(key, modifiers)
659                {
660                    return true;
661                }
662                if *key == Key::Escape
663                    && let Some(cancel) = &cancel
664                {
665                    cancel();
666                    return true;
667                }
668                if *key == Key::Enter {
669                    if let (Some(submit), true) = (&submit, state.accepts_input()) {
670                        submit(value.get());
671                    }
672                    return true;
673                }
674                let mut text = value.get();
675                let mut c = cursor.get();
676                let access = ClipboardAccess { clipboard: &clipboard, secure, multiline: false };
677                let handled = edit(&mut text, &mut c, key, modifiers, Some(access));
678                if handled {
679                    let changed = text != value.get();
680                    if changed && !state.accepts_input() {
681                        // Copying still works; the text stays as it was.
682                        return true;
683                    }
684                    if follows_end && c.caret == text.chars().count() {
685                        c.caret = usize::MAX;
686                    }
687                    cursor.set(c);
688                    if changed {
689                        value.set(text);
690                    }
691                }
692                handled
693            }),
694        );
695        cx.focusable(node, theme.radii.md);
696        cx.cursor(node, CursorIcon::Text);
697        node
698    }
699}
700
701#[cfg(test)]
702mod tests {
703    use super::*;
704
705    fn press(text: &mut String, cursor: &mut Cursor, key: Key, modifiers: Modifiers) -> bool {
706        edit(text, cursor, &key, modifiers, None)
707    }
708
709    const NONE: Modifiers = Modifiers { shift: false, ctrl: false, alt: false, meta: false };
710    const SHIFT: Modifiers = Modifiers { shift: true, ..NONE };
711    const ALT: Modifiers = Modifiers { alt: true, ..NONE };
712    const CMD: Modifiers = Modifiers { meta: true, ..NONE };
713
714    #[test]
715    fn editing_keys() {
716        let (mut text, mut c) = (String::from("héllo"), Cursor::at(usize::MAX));
717        assert!(press(&mut text, &mut c, Key::Backspace, NONE));
718        assert_eq!((text.as_str(), c.caret), ("héll", 4));
719        press(&mut text, &mut c, Key::Home, NONE);
720        press(&mut text, &mut c, Key::ArrowRight, NONE);
721        press(&mut text, &mut c, Key::Delete, NONE);
722        assert_eq!(text, "hll");
723        press(&mut text, &mut c, Key::Character("é".into()), NONE);
724        press(&mut text, &mut c, Key::Space, NONE);
725        assert_eq!((text.as_str(), c.caret), ("hé ll", 3));
726        assert!(!press(&mut text, &mut c, Key::Tab, NONE));
727    }
728
729    #[test]
730    fn shift_selects_and_typing_replaces_the_selection() {
731        let (mut text, mut c) = (String::from("hello world"), Cursor::at(0));
732        for _ in 0..5 {
733            press(&mut text, &mut c, Key::ArrowRight, SHIFT);
734        }
735        assert_eq!(c.selection(), Some((0, 5)));
736        press(&mut text, &mut c, Key::Character("bye".into()), NONE);
737        assert_eq!((text.as_str(), c), ("bye world", Cursor::at(3)));
738        press(&mut text, &mut c, Key::End, SHIFT);
739        press(&mut text, &mut c, Key::Backspace, NONE);
740        assert_eq!(text, "bye");
741    }
742
743    #[test]
744    fn arrows_collapse_a_selection_to_its_side() {
745        let (mut text, mut c) = (String::from("abcdef"), Cursor { caret: 4, anchor: Some(1) });
746        press(&mut text, &mut c, Key::ArrowLeft, NONE);
747        assert_eq!(c, Cursor::at(1));
748        c = Cursor { caret: 1, anchor: Some(4) };
749        press(&mut text, &mut c, Key::ArrowRight, NONE);
750        assert_eq!(c, Cursor::at(4));
751    }
752
753    #[test]
754    fn words_and_line_ends() {
755        let (mut text, mut c) = (String::from("one two  three"), Cursor::at(14));
756        press(&mut text, &mut c, Key::ArrowLeft, ALT);
757        assert_eq!(c.caret, 9);
758        press(&mut text, &mut c, Key::ArrowLeft, ALT);
759        assert_eq!(c.caret, 4);
760        press(&mut text, &mut c, Key::ArrowRight, ALT);
761        assert_eq!(c.caret, 7);
762        press(&mut text, &mut c, Key::Backspace, ALT);
763        assert_eq!((text.as_str(), c.caret), ("one   three", 4));
764        press(&mut text, &mut c, Key::ArrowRight, CMD);
765        assert_eq!(c.caret, 11);
766        press(&mut text, &mut c, Key::Backspace, CMD);
767        assert_eq!(text, "");
768        assert_eq!(word_at("say hello there", 6), (4, 9));
769        assert_eq!(word_at("a  b", 2), (2, 3), "between words a double-click selects one character");
770    }
771
772    #[test]
773    fn select_all_copy_cut_and_paste() {
774        let clipboard = crate::Clipboard::default();
775        let access = || Some(ClipboardAccess { clipboard: &clipboard, secure: false, multiline: false });
776        let (mut text, mut c) = (String::from("draft"), Cursor::at(0));
777        assert!(edit(&mut text, &mut c, &Key::Character("a".into()), CMD, access()));
778        assert_eq!(c.selection(), Some((0, 5)));
779        edit(&mut text, &mut c, &Key::Character("c".into()), CMD, access());
780        assert_eq!(clipboard.read().as_deref(), Some("draft"));
781        edit(&mut text, &mut c, &Key::Character("x".into()), CMD, access());
782        assert_eq!(text, "");
783        clipboard.write("line one\nline two");
784        edit(&mut text, &mut c, &Key::Character("v".into()), CMD, access());
785        assert_eq!((text.as_str(), c.caret), ("line one", 8), "one line in a single-line field");
786        let ctrl = Modifiers { ctrl: true, ..NONE };
787        assert!(edit(&mut text, &mut c, &Key::Character("A".into()), ctrl, access()), "Ctrl works too");
788    }
789
790    #[test]
791    fn passwords_are_never_copied() {
792        let clipboard = crate::Clipboard::default();
793        let (mut text, mut c) = (String::from("secret"), Cursor { caret: 6, anchor: Some(0) });
794        let access = Some(ClipboardAccess { clipboard: &clipboard, secure: true, multiline: false });
795        assert!(edit(&mut text, &mut c, &Key::Character("x".into()), CMD, access));
796        assert_eq!((text.as_str(), clipboard.read()), ("secret", None));
797    }
798
799    #[test]
800    fn unreadable_clipboard_leaves_paste_to_the_platform() {
801        struct WriteOnly;
802        impl crate::ClipboardProvider for WriteOnly {
803            fn read_text(&self) -> Option<String> {
804                None
805            }
806            fn write_text(&self, _: &str) {}
807        }
808        let clipboard = crate::Clipboard::new(WriteOnly);
809        let (mut text, mut c) = (String::new(), Cursor::at(0));
810        let access = Some(ClipboardAccess { clipboard: &clipboard, secure: false, multiline: false });
811        assert!(!edit(&mut text, &mut c, &Key::Character("v".into()), CMD, access));
812    }
813
814    #[test]
815    fn clicks_land_on_the_nearest_boundary() {
816        let b = [0.0, 10.0, 20.0, 30.0];
817        assert_eq!(index_at(&b, -5.0), 0);
818        assert_eq!(index_at(&b, 4.0), 0);
819        assert_eq!(index_at(&b, 6.0), 1);
820        assert_eq!(index_at(&b, 26.0), 3);
821        assert_eq!(index_at(&b, 99.0), 3);
822    }
823}