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