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, clipboard, and IME composition are not yet supported (a single-caret MVP).
21pub struct Input {
22    value: RwSignal<String>,
23    // Caret byte offset into `value`. Reactive so a bare caret move (arrows/home/end) re-renders even
24    // when the text is unchanged; always re-snapped to a char boundary in case the signal changed elsewhere.
25    caret: RwSignal<usize>,
26    style: Rc<dyn Fn() -> TextStyle>,
27    id: FocusId,
28    leaf: LayoutLeaf,
29    on_submit: Option<Box<dyn Fn()>>,
30    // Hint shown (muted) while the value is empty. Rendered in place of the text so the field stays a live,
31    // tappable `Input` even when empty — a separate placeholder widget swapped in would not take focus.
32    placeholder: String,
33    // Character drawn in place of every character of the value. Affects rendering only: the bound signal, the
34    // caret offsets and every edit still work on the real text.
35    mask: Option<char>,
36}
37
38impl Input {
39    pub fn new(
40        value: RwSignal<String>,
41        layout_style: LayoutStyle,
42        style_fn: impl Fn() -> TextStyle + 'static,
43    ) -> Result<Self, LayoutError> {
44        let leaf = LayoutLeaf::register(layout_style)?;
45        let caret = value.with(|s| s.len());
46        let id = focus::next_id();
47        // Join the tab order so Tab/Shift-Tab can reach this field, as the kind that takes keys as text — so
48        // an app-level shortcut table can stand aside while the caret is here.
49        focus::register_at(id, focus::FocusKind::TextEntry, leaf.node);
50        Ok(Self {
51            value,
52            caret: signal(caret),
53            style: Rc::new(style_fn),
54            id,
55            leaf,
56            on_submit: None,
57            placeholder: String::new(),
58            mask: None,
59        })
60    }
61
62    /// Draws `bullet` in place of every character, for a password or a PIN.
63    ///
64    /// Rendering only — the bound signal keeps the real text, so a submit handler reads what was typed. Worth
65    /// having as a property of the field rather than as a caller-side transformation: a caller that masked the
66    /// *signal* would have to keep a second copy of the truth, and the caret would measure the wrong string.
67    pub fn masked(mut self, bullet: char) -> Self {
68        self.mask = Some(bullet);
69        self
70    }
71
72    /// [`masked`](Self::masked) with the conventional bullet.
73    pub fn secret(self) -> Self {
74        self.masked('•')
75    }
76
77    /// What is drawn for `text`: the text itself, or one mask character per character of it.
78    fn shown(&self, text: &str) -> String {
79        match self.mask {
80            Some(bullet) => text.chars().map(|_| bullet).collect(),
81            None => text.to_string(),
82        }
83    }
84
85    /// Runs when Enter is pressed while focused (e.g. submit a form / run a search).
86    pub fn on_submit(mut self, f: impl Fn() + 'static) -> Self {
87        self.on_submit = Some(Box::new(f));
88        self
89    }
90
91    /// A muted hint shown while the value is empty (the field stays tappable/focusable, unlike a swapped-in
92    /// placeholder widget).
93    pub fn placeholder(mut self, p: impl Into<String>) -> Self {
94        self.placeholder = p.into();
95        self
96    }
97
98    /// Gives this field keyboard focus as it is built, so the surface it is on is typed into rather than
99    /// clicked into first.
100    ///
101    /// A field is otherwise focused only by a tap, which is the right default for a form but wrong for the
102    /// surface that exists *because* it wants a keystroke — a search overlay opened on a keybind, a password
103    /// prompt. Registration happens in [`new`](Self::new), so this is a request against an id that is already
104    /// in the tab order.
105    pub fn autofocus(self) -> Self {
106        focus::request(self.id);
107        self
108    }
109
110    /// The current caret byte offset, clamped to the text and snapped to a char boundary.
111    fn caret_at(&self, text: &str) -> usize {
112        let mut c = self.caret.get().min(text.len());
113        while c > 0 && !text.is_char_boundary(c) {
114            c -= 1;
115        }
116        c
117    }
118
119    /// Applies a key while focused, editing the bound signal and/or moving the caret. Returns whether the
120    /// key was consumed.
121    fn edit(&mut self, key: &Key, mods: &ModifiersState) -> EventResult {
122        let mut text = self.value.get();
123        let mut caret = self.caret_at(&text);
124        match key {
125            // A chord (Ctrl/Meta) is a shortcut, not text — leave it for global handlers (copy/paste TBD).
126            Key::Char(_) if mods.is_ctrl || mods.is_meta => return EventResult::Ignored,
127            Key::Char(c) if !c.is_control() => {
128                text.insert(caret, *c);
129                caret += c.len_utf8();
130            }
131            Key::Named(NamedKey::Space) => {
132                text.insert(caret, ' ');
133                caret += 1;
134            }
135            Key::Named(NamedKey::Backspace) => {
136                if caret == 0 {
137                    return EventResult::Ignored;
138                }
139                let prev = prev_boundary(&text, caret);
140                text.replace_range(prev..caret, "");
141                caret = prev;
142            }
143            Key::Named(NamedKey::Delete) => {
144                if caret >= text.len() {
145                    return EventResult::Ignored;
146                }
147                let next = next_boundary(&text, caret);
148                text.replace_range(caret..next, "");
149            }
150            Key::Named(NamedKey::ArrowLeft) => caret = prev_boundary(&text, caret),
151            Key::Named(NamedKey::ArrowRight) => caret = next_boundary(&text, caret),
152            Key::Named(NamedKey::Home) => caret = 0,
153            Key::Named(NamedKey::End) => caret = text.len(),
154            Key::Named(NamedKey::Enter) => {
155                if let Some(cb) = &self.on_submit {
156                    cb();
157                }
158                return EventResult::Handled;
159            }
160            Key::Named(NamedKey::Escape) => {
161                focus::release(self.id);
162                return EventResult::Handled;
163            }
164            // Tab moves focus to the next/previous field instead of inserting a tab character.
165            Key::Named(NamedKey::Tab) => {
166                if mods.is_shift {
167                    focus::focus_prev();
168                } else {
169                    focus::focus_next();
170                }
171                return EventResult::Handled;
172            }
173            _ => return EventResult::Ignored,
174        }
175        // Only push a new string when the text actually changed, so a bare caret move doesn't rebuild it.
176        if self.value.with(|s| s != &text) {
177            self.value.set(text);
178        }
179        self.caret.set(caret);
180        EventResult::Handled
181    }
182}
183
184impl Component for Input {
185    fn view(&self) -> RenderNode {
186        let r = self.leaf.rect.get();
187        let text = self.value.get();
188        let style = (self.style)();
189        let full = Rect {
190            x: 0.0,
191            y: 0.0,
192            width: r.width,
193            height: r.height,
194        };
195        // Empty value → draw the muted placeholder in the text's place (the field itself stays live: the
196        // caret and hit-test still work, so it's tappable/typable from empty).
197        let text_node = if text.is_empty() && !self.placeholder.is_empty() {
198            let muted = match style.paint {
199                Paint::Solid(c) => Paint::Solid(c.with_alpha(c.a * 0.5)),
200                _ => Paint::Solid(Color::rgba(0.5, 0.5, 0.55, 0.5)),
201            };
202            let mut ph_style = style;
203            ph_style.paint = muted;
204            RenderNode::text(self.placeholder.clone(), full, ph_style)
205        } else {
206            RenderNode::text(self.shown(&text), full, style)
207        };
208
209        // The caret is drawn only while focused; reading `is_focused` subscribes this view to focus moves.
210        if focus::is_focused(self.id) {
211            let caret = self.caret_at(&text);
212            // Measured against what is *drawn*: a mask character is not the width of the character it hides,
213            // so measuring the real prefix would put the caret somewhere the text is not.
214            let prefix = self.shown(&text[..caret]);
215            let (prefix_w, _) = crate::text_metrics::measure_text(&prefix, 1.0e6, &style);
216            let line_h = crate::text_metrics::line_height(style.font_size);
217            let caret_rect = Rect {
218                x: prefix_w,
219                y: 0.0,
220                width: CARET_WIDTH,
221                height: line_h,
222            };
223            let caret_node =
224                RenderNode::rect(caret_rect, RectStyle::default().with_fill(style.paint));
225            self.leaf
226                .at_layout_position(RenderNode::group([text_node, caret_node]))
227        } else {
228            self.leaf.at_layout_position(text_node)
229        }
230    }
231
232    fn on_event(&mut self, event: &Event) -> EventResult {
233        let rect = self.leaf.rect.get();
234        match event {
235            Event::PointerPressed {
236                x,
237                y,
238                button: PointerButton::Primary,
239                ..
240            } => {
241                if rect.contains(*x as f32, *y as f32) {
242                    focus::request_from_pointer(self.id);
243                    // MVP: land the caret at the end. Click-to-position (measuring per glyph) is a follow-up.
244                    self.caret.set(self.value.with(|s| s.len()));
245                    EventResult::Handled
246                } else {
247                    EventResult::Ignored
248                }
249            }
250            Event::KeyPressed { key, modifiers } if focus::is_focused(self.id) => {
251                self.edit(key, modifiers)
252            }
253            _ => EventResult::Ignored,
254        }
255    }
256
257    fn debug_name(&self) -> &'static str {
258        "Input"
259    }
260}
261
262impl Drop for Input {
263    fn drop(&mut self) {
264        // Leave the tab order (and drop focus if held) when the field is destroyed, e.g. by a reactive list.
265        focus::unregister(self.id);
266    }
267}
268
269impl_leaf_widget!(Input);
270
271/// The char boundary strictly before byte offset `i` (or 0).
272fn prev_boundary(s: &str, i: usize) -> usize {
273    let mut j = i.min(s.len());
274    if j == 0 {
275        return 0;
276    }
277    j -= 1;
278    while j > 0 && !s.is_char_boundary(j) {
279        j -= 1;
280    }
281    j
282}
283
284/// The char boundary strictly after byte offset `i` (or `s.len()`).
285fn next_boundary(s: &str, i: usize) -> usize {
286    let mut j = (i + 1).min(s.len());
287    while j < s.len() && !s.is_char_boundary(j) {
288        j += 1;
289    }
290    j
291}
292
293#[cfg(test)]
294mod tests {
295    use crate::context::reset_layout_runtime;
296    use layout_core::AvailableSpace;
297    use platform_core::PointerSource;
298    use renderer_core::Color;
299
300    use super::*;
301    use crate::context::{compute_layout, new_container};
302    use crate::layout_item::LayoutItem;
303
304    fn key(k: Key) -> Event {
305        Event::KeyPressed {
306            key: k,
307            modifiers: ModifiersState::default(),
308        }
309    }
310
311    // Builds a focused, laid-out input bound to `initial` and returns it plus its value signal.
312    fn focused_input(initial: &str) -> (Input, RwSignal<String>) {
313        reset_layout_runtime();
314        let value = signal(initial.to_string());
315        let input = Input::new(
316            value.clone(),
317            LayoutStyle::new().width(200.0).height(20.0),
318            || TextStyle::new(14.0, Color::BLACK),
319        )
320        .unwrap();
321        let root = new_container(
322            LayoutStyle::new().flex_column().width(200.0).height(100.0),
323            &[input.layout_node()],
324        )
325        .unwrap();
326        compute_layout(
327            root,
328            AvailableSpace::Definite(200.0),
329            AvailableSpace::Definite(100.0),
330        )
331        .unwrap();
332        focus::request(input.id);
333        (input, value)
334    }
335
336    /// The guard a global shortcut handler consults ([`focus::text_entry_takes_key`]) is a second list of
337    /// what this editor eats, kept apart from `edit` because it has to answer without running the edit. A key
338    /// added here and not there re-opens the bug it exists for: typing that also fires the app's shortcuts.
339    #[test]
340    fn the_shortcut_guard_covers_every_key_this_field_edits() {
341        let plain = ModifiersState::default();
342        let named = [
343            NamedKey::Space,
344            NamedKey::Backspace,
345            NamedKey::Delete,
346            NamedKey::ArrowLeft,
347            NamedKey::ArrowRight,
348            NamedKey::ArrowUp,
349            NamedKey::ArrowDown,
350            NamedKey::Home,
351            NamedKey::End,
352            NamedKey::Enter,
353            NamedKey::Escape,
354            NamedKey::Tab,
355            NamedKey::PageUp,
356            NamedKey::PageDown,
357            NamedKey::F5,
358            NamedKey::Insert,
359        ];
360        let keys: Vec<Key> = std::iter::once(Key::Char('3'))
361            .chain(std::iter::once(Key::Char('s')))
362            .chain(named.into_iter().map(Key::Named))
363            .collect();
364        for k in keys {
365            // A fresh field per key: Escape and Tab move focus, and an edit changes what the next key does.
366            let (mut input, _value) = focused_input("hello");
367            input.caret.set(2);
368            // Asked first, as dispatch does: a global handler decides before the field acts, and Escape is
369            // the key that proves it — the field answers it by giving up the focus the guard reads.
370            let guarded = focus::text_entry_takes_key(&k, plain);
371            let edited = input.edit(&k, &plain) == EventResult::Handled;
372            assert!(
373                !edited || guarded,
374                "{k:?} is edited by the field but the shortcut guard lets it through"
375            );
376            focus::clear();
377        }
378    }
379
380    #[test]
381    fn autofocus_makes_a_field_typable_without_a_tap() {
382        reset_layout_runtime();
383        focus::clear();
384        let value = signal(String::new());
385        let mut input = Input::new(
386            value.clone(),
387            LayoutStyle::new().width(200.0).height(20.0),
388            || TextStyle::new(14.0, Color::BLACK),
389        )
390        .unwrap()
391        .autofocus();
392        assert!(
393            focus::is_focused(input.id),
394            "the field holds focus from construction"
395        );
396        input.on_event(&key(Key::Char('x')));
397        assert_eq!(value.get(), "x", "and the very first keystroke is text");
398
399        // Without it, the same field ignores the keystroke — which is the default a form wants.
400        reset_layout_runtime();
401        focus::clear();
402        let untouched = signal(String::new());
403        let mut plain = Input::new(
404            untouched.clone(),
405            LayoutStyle::new().width(200.0).height(20.0),
406            || TextStyle::new(14.0, Color::BLACK),
407        )
408        .unwrap();
409        assert!(!focus::is_focused(plain.id));
410        plain.on_event(&key(Key::Char('x')));
411        assert_eq!(untouched.get(), "");
412    }
413
414    #[test]
415    fn typing_inserts_at_caret() {
416        let (mut input, value) = focused_input("");
417        for c in "hi".chars() {
418            input.on_event(&key(Key::Char(c)));
419        }
420        assert_eq!(value.get(), "hi");
421        assert_eq!(input.caret.get(), 2);
422    }
423
424    #[test]
425    fn backspace_and_arrows_edit_mid_string() {
426        let (mut input, value) = focused_input("abc");
427        // Caret starts at end (3). Left twice → between a and b (1).
428        input.on_event(&key(Key::Named(NamedKey::ArrowLeft)));
429        input.on_event(&key(Key::Named(NamedKey::ArrowLeft)));
430        assert_eq!(input.caret.get(), 1);
431        // Backspace removes 'a'.
432        input.on_event(&key(Key::Named(NamedKey::Backspace)));
433        assert_eq!(value.get(), "bc");
434        assert_eq!(input.caret.get(), 0);
435        // Insert at start.
436        input.on_event(&key(Key::Char('X')));
437        assert_eq!(value.get(), "Xbc");
438    }
439
440    #[test]
441    fn keys_ignored_when_not_focused() {
442        let (mut input, value) = focused_input("a");
443        focus::clear();
444        let r = input.on_event(&key(Key::Char('z')));
445        assert_eq!(r, EventResult::Ignored);
446        assert_eq!(value.get(), "a", "an unfocused input must not edit");
447    }
448
449    #[test]
450    fn a_masked_field_hides_the_text_without_changing_it() {
451        let (mut input, value) = focused_input("");
452        input = input.secret();
453        for c in ['h', 'u', 'n', 't', 'e', 'r'] {
454            input.on_event(&key(Key::Char(c)));
455        }
456        assert_eq!(
457            value.get(),
458            "hunter",
459            "the bound signal keeps the real text, which is what a submit handler reads"
460        );
461        assert_eq!(
462            input.shown(&value.get()),
463            "••••••",
464            "and the screen does not"
465        );
466
467        // One mask character per character, not per byte: a multi-byte password must not leak its length in
468        // bytes, and the caret is measured against this string.
469        assert_eq!(input.shown("mañana"), "••••••");
470        assert_eq!(input.shown(""), "");
471    }
472
473    #[test]
474    fn an_unmasked_field_is_unchanged() {
475        let (input, value) = focused_input("plain");
476        assert_eq!(input.shown(&value.get()), "plain");
477    }
478
479    #[test]
480    fn tap_focuses_and_ctrl_chord_is_ignored() {
481        let (mut input, value) = focused_input("hi");
482        focus::clear();
483        let r = input.on_event(&Event::PointerPressed {
484            x: 10.0,
485            y: 5.0,
486            button: PointerButton::Primary,
487            source: PointerSource::Mouse,
488        });
489        assert_eq!(r, EventResult::Handled);
490        assert!(
491            focus::is_focused(input.id),
492            "a tap inside focuses the input"
493        );
494        // Ctrl+V is a shortcut, not text: ignored, value unchanged.
495        let paste = Event::KeyPressed {
496            key: Key::Char('v'),
497            modifiers: ModifiersState {
498                is_ctrl: true,
499                ..Default::default()
500            },
501        };
502        assert_eq!(input.on_event(&paste), EventResult::Ignored);
503        assert_eq!(value.get(), "hi");
504    }
505}