Skip to main content

telar_ui_core/
input.rs

1use std::rc::Rc;
2
3use geometry_core::Rect;
4use layout_core::{LayoutError, LayoutStyle};
5use platform_core::{Event, Key, ModifiersState, NamedKey, PointerButton};
6use reactive_core::{RwSignal, signal};
7use renderer_core::{Color, Paint, RectStyle, ShapeStyle, TextStyle};
8use ui_tree::{Component, EventResult, RenderNode};
9
10use crate::focus::{self, FocusId};
11use crate::impl_leaf_widget;
12use crate::layout_leaf::LayoutLeaf;
13
14/// Width of the caret, in logical px.
15const CARET_WIDTH: f32 = 1.5;
16
17/// A single-line editable text field bound to a `RwSignal<String>`. A base primitive: unstyled (no
18/// border or background — wrap it in a `box` for the look) and keyboard-driven. It requests focus on
19/// tap and, while focused, edits the bound signal from key events, drawing a caret at the insertion
20/// point. Selection (`Shift`+arrows/Home/End, `Ctrl+A`) with copy, cut and paste; IME composition is not yet
21/// supported. Drag-to-select waits on click-to-position, which this field does not have either.
22pub struct Input {
23    value: RwSignal<String>,
24    // Caret byte offset into `value`. Reactive so a bare caret move (arrows/home/end) re-renders even
25    // when the text is unchanged; always re-snapped to a char boundary in case the signal changed elsewhere.
26    caret: RwSignal<usize>,
27    // The other end of a selection, or `None` when there is none. Byte offset like `caret`, and either side
28    // of it: a selection extended leftwards has its anchor after its caret.
29    anchor: RwSignal<Option<usize>>,
30    style: Rc<dyn Fn() -> TextStyle>,
31    id: FocusId,
32    leaf: LayoutLeaf,
33    on_submit: Option<Box<dyn Fn()>>,
34    // Hint shown (muted) while the value is empty. Rendered in place of the text so the field stays a live,
35    // tappable `Input` even when empty — a separate placeholder widget swapped in would not take focus.
36    placeholder: String,
37    // Character drawn in place of every character of the value. Affects rendering only: the bound signal, the
38    // caret offsets and every edit still work on the real text.
39    mask: Option<char>,
40}
41
42impl Input {
43    pub fn new(
44        value: RwSignal<String>,
45        layout_style: LayoutStyle,
46        style_fn: impl Fn() -> TextStyle + 'static,
47    ) -> Result<Self, LayoutError> {
48        let leaf = LayoutLeaf::register(layout_style)?;
49        let caret = value.with(|s| s.len());
50        let id = focus::next_id();
51        // Join the tab order so Tab/Shift-Tab can reach this field, as the kind that takes keys as text — so
52        // an app-level shortcut table can stand aside while the caret is here.
53        focus::register_at(id, focus::FocusKind::TextEntry, leaf.node);
54        Ok(Self {
55            value,
56            caret: signal(caret),
57            anchor: signal(None),
58            style: Rc::new(style_fn),
59            id,
60            leaf,
61            on_submit: None,
62            placeholder: String::new(),
63            mask: None,
64        })
65    }
66
67    /// Draws `bullet` in place of every character, for a password or a PIN.
68    ///
69    /// Rendering only — the bound signal keeps the real text, so a submit handler reads what was typed. Worth
70    /// having as a property of the field rather than as a caller-side transformation: a caller that masked the
71    /// *signal* would have to keep a second copy of the truth, and the caret would measure the wrong string.
72    pub fn masked(mut self, bullet: char) -> Self {
73        self.mask = Some(bullet);
74        self
75    }
76
77    /// [`masked`](Self::masked) with the conventional bullet.
78    pub fn secret(self) -> Self {
79        self.masked('•')
80    }
81
82    /// What is drawn for `text`: the text itself, or one mask character per character of it.
83    fn shown(&self, text: &str) -> String {
84        match self.mask {
85            Some(bullet) => text.chars().map(|_| bullet).collect(),
86            None => text.to_string(),
87        }
88    }
89
90    /// Runs when Enter is pressed while focused (e.g. submit a form / run a search).
91    pub fn on_submit(mut self, f: impl Fn() + 'static) -> Self {
92        self.on_submit = Some(Box::new(f));
93        self
94    }
95
96    /// A muted hint shown while the value is empty (the field stays tappable/focusable, unlike a swapped-in
97    /// placeholder widget).
98    pub fn placeholder(mut self, p: impl Into<String>) -> Self {
99        self.placeholder = p.into();
100        self
101    }
102
103    /// Gives this field keyboard focus as it is built, so the surface it is on is typed into rather than
104    /// clicked into first.
105    ///
106    /// A field is otherwise focused only by a tap, which is the right default for a form but wrong for the
107    /// surface that exists *because* it wants a keystroke — a search overlay opened on a keybind, a password
108    /// prompt. Registration happens in [`new`](Self::new), so this is a request against an id that is already
109    /// in the tab order.
110    pub fn autofocus(self) -> Self {
111        focus::request(self.id);
112        self
113    }
114
115    /// The current caret byte offset, clamped to the text and snapped to a char boundary.
116    fn caret_at(&self, text: &str) -> usize {
117        let mut c = self.caret.get().min(text.len());
118        while c > 0 && !text.is_char_boundary(c) {
119            c -= 1;
120        }
121        c
122    }
123
124    /// The selected byte range, low end first, or `None` when nothing is selected. An anchor sitting on the
125    /// caret is not a selection — it is where one would start from.
126    fn selection(&self, text: &str) -> Option<(usize, usize)> {
127        let caret = self.caret_at(text);
128        let mut anchor = self.anchor.get()?.min(text.len());
129        while anchor > 0 && !text.is_char_boundary(anchor) {
130            anchor -= 1;
131        }
132        (anchor != caret).then(|| (anchor.min(caret), anchor.max(caret)))
133    }
134
135    /// The selected text, for a copy or a cut.
136    fn selected_text(&self, text: &str) -> Option<String> {
137        self.selection(text)
138            .map(|(from, to)| text[from..to].to_string())
139    }
140
141    /// Removes the selection from `text` and reports where the caret lands, or `None` when there was none.
142    /// Every edit runs through this first: typing over a selection replaces it, which is the behaviour that
143    /// makes a selection worth having.
144    fn take_selection(&self, text: &mut String) -> Option<usize> {
145        let (from, to) = self.selection(text)?;
146        text.replace_range(from..to, "");
147        Some(from)
148    }
149
150    /// Applies a key while focused, editing the bound signal and/or moving the caret. Returns whether the
151    /// key was consumed.
152    fn edit(&mut self, key: &Key, mods: &ModifiersState) -> EventResult {
153        let mut text = self.value.get();
154        let mut caret = self.caret_at(&text);
155        let chord = mods.is_ctrl || mods.is_meta;
156        // Where a movement key leaves the anchor: `Shift` keeps (or starts) a selection, anything else drops
157        // it. Set after the match so each arm can still read the selection it is replacing.
158        let mut anchor = if mods.is_shift {
159            Some(self.anchor.get().unwrap_or(caret))
160        } else {
161            None
162        };
163        match key {
164            Key::Char('a') | Key::Char('A') if chord => {
165                anchor = Some(0);
166                caret = text.len();
167            }
168            Key::Char('c') | Key::Char('C') if chord => {
169                let Some(selected) = self.selected_text(&text) else {
170                    return EventResult::Ignored;
171                };
172                services_core::set_clipboard_text(&selected);
173                // The selection survives a copy, as it does everywhere else.
174                return EventResult::Handled;
175            }
176            Key::Char('x') | Key::Char('X') if chord => {
177                let Some(selected) = self.selected_text(&text) else {
178                    return EventResult::Ignored;
179                };
180                services_core::set_clipboard_text(&selected);
181                caret = self.take_selection(&mut text).unwrap_or(caret);
182            }
183            Key::Char('v') | Key::Char('V') if chord => {
184                let Some(pasted) = services_core::clipboard_text() else {
185                    return EventResult::Ignored;
186                };
187                // A single-line field takes the first line: a multi-line paste would otherwise put a `\n` in a
188                // value nothing can render, and every field is bound to a signal something else reads.
189                let pasted = pasted.lines().next().unwrap_or_default().to_string();
190                let had_selection = self.take_selection(&mut text);
191                if pasted.is_empty() && had_selection.is_none() {
192                    return EventResult::Ignored;
193                }
194                caret = had_selection.unwrap_or(caret);
195                text.insert_str(caret, &pasted);
196                caret += pasted.len();
197            }
198            // Any other chord is a shortcut, not text — leave it for global handlers.
199            Key::Char(_) if chord => return EventResult::Ignored,
200            Key::Char(c) if !c.is_control() => {
201                caret = self.take_selection(&mut text).unwrap_or(caret);
202                text.insert(caret, *c);
203                caret += c.len_utf8();
204            }
205            Key::Named(NamedKey::Space) => {
206                caret = self.take_selection(&mut text).unwrap_or(caret);
207                text.insert(caret, ' ');
208                caret += 1;
209            }
210            // Backspace and Delete take the selection when there is one, and one character when there is not.
211            Key::Named(NamedKey::Backspace) => {
212                if let Some(at) = self.take_selection(&mut text) {
213                    caret = at;
214                } else {
215                    if caret == 0 {
216                        return EventResult::Ignored;
217                    }
218                    let prev = prev_boundary(&text, caret);
219                    text.replace_range(prev..caret, "");
220                    caret = prev;
221                }
222            }
223            Key::Named(NamedKey::Delete) => {
224                if let Some(at) = self.take_selection(&mut text) {
225                    caret = at;
226                } else {
227                    if caret >= text.len() {
228                        return EventResult::Ignored;
229                    }
230                    let next = next_boundary(&text, caret);
231                    text.replace_range(caret..next, "");
232                }
233            }
234            // An unshifted arrow with a selection collapses to its edge rather than moving from the caret:
235            // pressing Left with three characters selected puts the caret before them, not inside them.
236            Key::Named(NamedKey::ArrowLeft) => {
237                caret = match self.selection(&text) {
238                    Some((from, _)) if !mods.is_shift => from,
239                    _ => prev_boundary(&text, caret),
240                }
241            }
242            Key::Named(NamedKey::ArrowRight) => {
243                caret = match self.selection(&text) {
244                    Some((_, to)) if !mods.is_shift => to,
245                    _ => next_boundary(&text, caret),
246                }
247            }
248            Key::Named(NamedKey::Home) => caret = 0,
249            Key::Named(NamedKey::End) => caret = text.len(),
250            Key::Named(NamedKey::Enter) => {
251                if let Some(cb) = &self.on_submit {
252                    cb();
253                }
254                return EventResult::Handled;
255            }
256            Key::Named(NamedKey::Escape) => {
257                focus::release(self.id);
258                return EventResult::Handled;
259            }
260            // Tab moves focus to the next/previous field instead of inserting a tab character.
261            Key::Named(NamedKey::Tab) => {
262                if mods.is_shift {
263                    focus::focus_prev();
264                } else {
265                    focus::focus_next();
266                }
267                return EventResult::Handled;
268            }
269            _ => return EventResult::Ignored,
270        }
271        // Only push a new string when the text actually changed, so a bare caret move doesn't rebuild it.
272        if self.value.with(|s| s != &text) {
273            self.value.set(text);
274        }
275        self.caret.set(caret);
276        // An anchor that caught up with the caret is no selection at all, and keeping it would make the next
277        // unshifted arrow collapse to a range of nothing.
278        self.anchor.set(anchor.filter(|a| *a != caret));
279        EventResult::Handled
280    }
281}
282
283impl Component for Input {
284    fn view(&self) -> RenderNode {
285        let r = self.leaf.rect.get();
286        let text = self.value.get();
287        let style = (self.style)();
288        let full = Rect {
289            x: 0.0,
290            y: 0.0,
291            width: r.width,
292            height: r.height,
293        };
294        // Empty value → draw the muted placeholder in the text's place (the field itself stays live: the
295        // caret and hit-test still work, so it's tappable/typable from empty).
296        let text_node = if text.is_empty() && !self.placeholder.is_empty() {
297            let muted = match style.paint {
298                Paint::Solid(c) => Paint::Solid(c.with_alpha(c.a * 0.5)),
299                _ => Paint::Solid(Color::rgba(0.5, 0.5, 0.55, 0.5)),
300            };
301            let mut ph_style = style;
302            ph_style.paint = muted;
303            RenderNode::text(self.placeholder.clone(), full, ph_style)
304        } else {
305            RenderNode::text(self.shown(&text), full, style)
306        };
307
308        // The caret is drawn only while focused; reading `is_focused` subscribes this view to focus moves.
309        if focus::is_focused(self.id) {
310            let caret = self.caret_at(&text);
311            // The selection paints *behind* the text, in the ink at low alpha rather than a token of its own:
312            // a field is unstyled by design and has no palette to reach for, and the ink is the one colour it
313            // is already guaranteed to contrast with.
314            let highlight = self.selection(&text).map(|(from, to)| {
315                let measure = |upto: usize| {
316                    crate::text_metrics::measure_text(&self.shown(&text[..upto]), 1.0e6, &style).0
317                };
318                let (start, end) = (measure(from), measure(to));
319                let fill = match style.paint {
320                    Paint::Solid(c) => c.with_alpha(0.25),
321                    _ => Color::rgba(0.4, 0.6, 0.9, 0.3),
322                };
323                RenderNode::rect(
324                    Rect {
325                        x: start,
326                        y: 0.0,
327                        width: (end - start).max(1.0),
328                        height: crate::text_metrics::line_height(style.font_size),
329                    },
330                    RectStyle::default().with_fill(Paint::Solid(fill)),
331                )
332            });
333            // Measured against what is *drawn*: a mask character is not the width of the character it hides,
334            // so measuring the real prefix would put the caret somewhere the text is not.
335            let prefix = self.shown(&text[..caret]);
336            let (prefix_w, _) = crate::text_metrics::measure_text(&prefix, 1.0e6, &style);
337            let line_h = crate::text_metrics::line_height(style.font_size);
338            let caret_rect = Rect {
339                x: prefix_w,
340                y: 0.0,
341                width: CARET_WIDTH,
342                height: line_h,
343            };
344            let caret_node =
345                RenderNode::rect(caret_rect, RectStyle::default().with_fill(style.paint));
346            let layers = match highlight {
347                Some(highlight) => vec![highlight, text_node, caret_node],
348                None => vec![text_node, caret_node],
349            };
350            self.leaf.at_layout_position(RenderNode::group(layers))
351        } else {
352            self.leaf.at_layout_position(text_node)
353        }
354    }
355
356    fn on_event(&mut self, event: &Event) -> EventResult {
357        let rect = self.leaf.rect.get();
358        match event {
359            Event::PointerPressed {
360                x,
361                y,
362                button: PointerButton::Primary,
363                ..
364            } => {
365                if rect.contains(*x as f32, *y as f32) {
366                    focus::request_from_pointer(self.id);
367                    // MVP: land the caret at the end. Click-to-position (measuring per glyph) is a follow-up,
368                    // and drag-to-select waits on it — there is no x-to-offset mapping to drag along yet.
369                    self.caret.set(self.value.with(|s| s.len()));
370                    self.anchor.set(None);
371                    EventResult::Handled
372                } else {
373                    EventResult::Ignored
374                }
375            }
376            Event::KeyPressed { key, modifiers } if focus::is_focused(self.id) => {
377                self.edit(key, modifiers)
378            }
379            _ => EventResult::Ignored,
380        }
381    }
382
383    fn debug_name(&self) -> &'static str {
384        "Input"
385    }
386}
387
388impl Drop for Input {
389    fn drop(&mut self) {
390        // Leave the tab order (and drop focus if held) when the field is destroyed, e.g. by a reactive list.
391        focus::unregister(self.id);
392    }
393}
394
395impl_leaf_widget!(Input);
396
397/// The char boundary strictly before byte offset `i` (or 0).
398fn prev_boundary(s: &str, i: usize) -> usize {
399    let mut j = i.min(s.len());
400    if j == 0 {
401        return 0;
402    }
403    j -= 1;
404    while j > 0 && !s.is_char_boundary(j) {
405        j -= 1;
406    }
407    j
408}
409
410/// The char boundary strictly after byte offset `i` (or `s.len()`).
411fn next_boundary(s: &str, i: usize) -> usize {
412    let mut j = (i + 1).min(s.len());
413    while j < s.len() && !s.is_char_boundary(j) {
414        j += 1;
415    }
416    j
417}
418
419#[cfg(test)]
420mod tests {
421    use crate::context::reset_layout_runtime;
422    use layout_core::AvailableSpace;
423    use platform_core::PointerSource;
424    use renderer_core::Color;
425
426    use super::*;
427    use crate::context::{compute_layout, new_container};
428    use crate::layout_item::LayoutItem;
429
430    fn key(k: Key) -> Event {
431        Event::KeyPressed {
432            key: k,
433            modifiers: ModifiersState::default(),
434        }
435    }
436
437    fn chord(k: Key) -> Event {
438        Event::KeyPressed {
439            key: k,
440            modifiers: ModifiersState {
441                is_ctrl: true,
442                ..ModifiersState::default()
443            },
444        }
445    }
446
447    fn shifted(k: Key) -> Event {
448        Event::KeyPressed {
449            key: k,
450            modifiers: ModifiersState {
451                is_shift: true,
452                ..ModifiersState::default()
453            },
454        }
455    }
456
457    #[test]
458    fn shift_arrows_grow_a_selection_and_a_plain_one_drops_it() {
459        let (mut input, _) = focused_input("hello");
460        input.on_event(&shifted(Key::Named(NamedKey::ArrowLeft)));
461        input.on_event(&shifted(Key::Named(NamedKey::ArrowLeft)));
462        assert_eq!(input.selection("hello"), Some((3, 5)), "two chars selected");
463        input.on_event(&key(Key::Named(NamedKey::ArrowRight)));
464        assert_eq!(input.selection("hello"), None, "a plain arrow drops it");
465    }
466
467    /// The behaviour that makes a selection worth having: what you type lands *instead of* it.
468    #[test]
469    fn typing_over_a_selection_replaces_it() {
470        let (mut input, value) = focused_input("hello");
471        input.on_event(&chord(Key::Char('a')));
472        input.on_event(&key(Key::Char('x')));
473        assert_eq!(value.get(), "x");
474    }
475
476    #[test]
477    fn backspace_takes_the_selection_rather_than_one_character() {
478        let (mut input, value) = focused_input("hello");
479        input.on_event(&shifted(Key::Named(NamedKey::ArrowLeft)));
480        input.on_event(&shifted(Key::Named(NamedKey::ArrowLeft)));
481        input.on_event(&key(Key::Named(NamedKey::Backspace)));
482        assert_eq!(value.get(), "hel");
483    }
484
485    /// An unshifted arrow with a selection collapses to its edge — pressing Left with three characters
486    /// selected puts the caret before them, not one step in from wherever the caret happened to be.
487    #[test]
488    fn a_plain_arrow_collapses_to_the_selection_edge() {
489        let (mut input, _) = focused_input("hello");
490        input.on_event(&chord(Key::Char('a')));
491        input.on_event(&key(Key::Named(NamedKey::ArrowLeft)));
492        assert_eq!(input.caret.get(), 0, "collapsed to the low edge");
493    }
494
495    #[test]
496    fn cut_removes_the_selection_and_copy_leaves_it() {
497        let (mut input, value) = focused_input("hello");
498        input.on_event(&chord(Key::Char('a')));
499        input.on_event(&chord(Key::Char('c')));
500        assert_eq!(value.get(), "hello", "copy leaves the text alone");
501        assert_eq!(input.selection("hello"), Some((0, 5)), "and the selection");
502        input.on_event(&chord(Key::Char('x')));
503        assert_eq!(value.get(), "", "cut takes it");
504    }
505
506    /// Copy and cut with nothing selected report `Ignored`, so a global shortcut table still sees the chord
507    /// instead of it being swallowed by a field that did nothing with it.
508    #[test]
509    fn copy_without_a_selection_is_not_consumed() {
510        let (mut input, _) = focused_input("hello");
511        assert_eq!(input.on_event(&chord(Key::Char('c'))), EventResult::Ignored);
512    }
513    // Builds a focused, laid-out input bound to `initial` and returns it plus its value signal.
514    fn focused_input(initial: &str) -> (Input, RwSignal<String>) {
515        reset_layout_runtime();
516        let value = signal(initial.to_string());
517        let input = Input::new(
518            value.clone(),
519            LayoutStyle::new().width(200.0).height(20.0),
520            || TextStyle::new(14.0, Color::BLACK),
521        )
522        .unwrap();
523        let root = new_container(
524            LayoutStyle::new().flex_column().width(200.0).height(100.0),
525            &[input.layout_node()],
526        )
527        .unwrap();
528        compute_layout(
529            root,
530            AvailableSpace::Definite(200.0),
531            AvailableSpace::Definite(100.0),
532        )
533        .unwrap();
534        focus::request(input.id);
535        (input, value)
536    }
537
538    /// The guard a global shortcut handler consults ([`focus::text_entry_takes_key`]) is a second list of
539    /// what this editor eats, kept apart from `edit` because it has to answer without running the edit. A key
540    /// added here and not there re-opens the bug it exists for: typing that also fires the app's shortcuts.
541    #[test]
542    fn the_shortcut_guard_covers_every_key_this_field_edits() {
543        let plain = ModifiersState::default();
544        let named = [
545            NamedKey::Space,
546            NamedKey::Backspace,
547            NamedKey::Delete,
548            NamedKey::ArrowLeft,
549            NamedKey::ArrowRight,
550            NamedKey::ArrowUp,
551            NamedKey::ArrowDown,
552            NamedKey::Home,
553            NamedKey::End,
554            NamedKey::Enter,
555            NamedKey::Escape,
556            NamedKey::Tab,
557            NamedKey::PageUp,
558            NamedKey::PageDown,
559            NamedKey::F5,
560            NamedKey::Insert,
561        ];
562        let keys: Vec<Key> = std::iter::once(Key::Char('3'))
563            .chain(std::iter::once(Key::Char('s')))
564            .chain(named.into_iter().map(Key::Named))
565            .collect();
566        for k in keys {
567            // A fresh field per key: Escape and Tab move focus, and an edit changes what the next key does.
568            let (mut input, _value) = focused_input("hello");
569            input.caret.set(2);
570            // Asked first, as dispatch does: a global handler decides before the field acts, and Escape is
571            // the key that proves it — the field answers it by giving up the focus the guard reads.
572            let guarded = focus::text_entry_takes_key(&k, plain);
573            let edited = input.edit(&k, &plain) == EventResult::Handled;
574            assert!(
575                !edited || guarded,
576                "{k:?} is edited by the field but the shortcut guard lets it through"
577            );
578            focus::clear();
579        }
580    }
581
582    #[test]
583    fn autofocus_makes_a_field_typable_without_a_tap() {
584        reset_layout_runtime();
585        focus::clear();
586        let value = signal(String::new());
587        let mut input = Input::new(
588            value.clone(),
589            LayoutStyle::new().width(200.0).height(20.0),
590            || TextStyle::new(14.0, Color::BLACK),
591        )
592        .unwrap()
593        .autofocus();
594        assert!(
595            focus::is_focused(input.id),
596            "the field holds focus from construction"
597        );
598        input.on_event(&key(Key::Char('x')));
599        assert_eq!(value.get(), "x", "and the very first keystroke is text");
600
601        // Without it, the same field ignores the keystroke — which is the default a form wants.
602        reset_layout_runtime();
603        focus::clear();
604        let untouched = signal(String::new());
605        let mut plain = Input::new(
606            untouched.clone(),
607            LayoutStyle::new().width(200.0).height(20.0),
608            || TextStyle::new(14.0, Color::BLACK),
609        )
610        .unwrap();
611        assert!(!focus::is_focused(plain.id));
612        plain.on_event(&key(Key::Char('x')));
613        assert_eq!(untouched.get(), "");
614    }
615
616    #[test]
617    fn typing_inserts_at_caret() {
618        let (mut input, value) = focused_input("");
619        for c in "hi".chars() {
620            input.on_event(&key(Key::Char(c)));
621        }
622        assert_eq!(value.get(), "hi");
623        assert_eq!(input.caret.get(), 2);
624    }
625
626    #[test]
627    fn backspace_and_arrows_edit_mid_string() {
628        let (mut input, value) = focused_input("abc");
629        // Caret starts at end (3). Left twice → between a and b (1).
630        input.on_event(&key(Key::Named(NamedKey::ArrowLeft)));
631        input.on_event(&key(Key::Named(NamedKey::ArrowLeft)));
632        assert_eq!(input.caret.get(), 1);
633        // Backspace removes 'a'.
634        input.on_event(&key(Key::Named(NamedKey::Backspace)));
635        assert_eq!(value.get(), "bc");
636        assert_eq!(input.caret.get(), 0);
637        // Insert at start.
638        input.on_event(&key(Key::Char('X')));
639        assert_eq!(value.get(), "Xbc");
640    }
641
642    #[test]
643    fn keys_ignored_when_not_focused() {
644        let (mut input, value) = focused_input("a");
645        focus::clear();
646        let r = input.on_event(&key(Key::Char('z')));
647        assert_eq!(r, EventResult::Ignored);
648        assert_eq!(value.get(), "a", "an unfocused input must not edit");
649    }
650
651    #[test]
652    fn a_masked_field_hides_the_text_without_changing_it() {
653        let (mut input, value) = focused_input("");
654        input = input.secret();
655        for c in ['h', 'u', 'n', 't', 'e', 'r'] {
656            input.on_event(&key(Key::Char(c)));
657        }
658        assert_eq!(
659            value.get(),
660            "hunter",
661            "the bound signal keeps the real text, which is what a submit handler reads"
662        );
663        assert_eq!(
664            input.shown(&value.get()),
665            "••••••",
666            "and the screen does not"
667        );
668
669        // One mask character per character, not per byte: a multi-byte password must not leak its length in
670        // bytes, and the caret is measured against this string.
671        assert_eq!(input.shown("mañana"), "••••••");
672        assert_eq!(input.shown(""), "");
673    }
674
675    #[test]
676    fn an_unmasked_field_is_unchanged() {
677        let (input, value) = focused_input("plain");
678        assert_eq!(input.shown(&value.get()), "plain");
679    }
680
681    #[test]
682    fn tap_focuses_and_ctrl_chord_is_ignored() {
683        let (mut input, value) = focused_input("hi");
684        focus::clear();
685        let r = input.on_event(&Event::PointerPressed {
686            x: 10.0,
687            y: 5.0,
688            button: PointerButton::Primary,
689            source: PointerSource::Mouse,
690        });
691        assert_eq!(r, EventResult::Handled);
692        assert!(
693            focus::is_focused(input.id),
694            "a tap inside focuses the input"
695        );
696        // Ctrl+V is a shortcut, not text: ignored, value unchanged.
697        let paste = Event::KeyPressed {
698            key: Key::Char('v'),
699            modifiers: ModifiersState {
700                is_ctrl: true,
701                ..Default::default()
702            },
703        };
704        assert_eq!(input.on_event(&paste), EventResult::Ignored);
705        assert_eq!(value.get(), "hi");
706    }
707}