Skip to main content

yog_ui/
textbox.rs

1//! Driving a single-line text input (`widget::text_box()`) from the raw UI
2//! events a Yog screen receives — `click:X:Y`, `drag:X:Y`, `release:X:Y`,
3//! `key:KEY:MODS` and `char:CODEPOINT` — like [`ScrollState`](crate::ScrollState)
4//! does for scroll views.
5//!
6//! ```ignore
7//! static SEARCH: Mutex<TextBoxState> = Mutex::new(TextBoxState::new());
8//!
9//! // render: feed the text, caret and focus into the tree
10//! SEARCH.lock().unwrap().apply(root.find_mut("search").unwrap());
11//!
12//! // events: with the last frame's layout node of "search"; return `true`
13//! // from the `register_ui` handler when it's consumed, so Esc unfocuses
14//! // the box instead of closing the screen
15//! match SEARCH.lock().unwrap().handle(event, node) {
16//!     TextBoxEvent::Ignored => { /* not for the box: buttons etc. */ }
17//!     TextBoxEvent::Changed => { /* re-filter */ }
18//!     TextBoxEvent::Submitted => { /* Enter */ }
19//!     TextBoxEvent::Handled => {}
20//! }
21//! ```
22
23use std::time::Instant;
24
25use crate::event::{UiEvent, keys as key};
26use crate::layout::LayoutNode;
27use crate::text;
28use crate::widget::{TEXT_BOX_PAD_X, Widget, WidgetKind};
29
30/// Caret blink half-period.
31const BLINK_MS: u128 = 530;
32
33/// What [`TextBoxState::handle`] did with an event.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum TextBoxEvent {
36    /// Not for this box — pass it on (buttons, other boxes, ...). A click
37    /// outside a focused box unfocuses it and is still `Ignored`.
38    Ignored,
39    /// Used by the box (focus, caret, selection, Esc unfocusing, ...).
40    Handled,
41    /// The text changed (typing, Backspace, Delete).
42    Changed,
43    /// Enter was pressed while focused.
44    Submitted,
45}
46
47impl TextBoxEvent {
48    /// The event was used by the box — the screen should consume it (for a
49    /// `register_ui` handler: return `true`).
50    pub fn consumed(self) -> bool {
51        self != TextBoxEvent::Ignored
52    }
53}
54
55/// Text, caret, selection and focus of one text box.
56#[derive(Debug, Clone)]
57pub struct TextBoxState {
58    text: String,
59    /// Caret, in characters.
60    caret: usize,
61    /// Selection anchor; `== caret` = no selection.
62    anchor: usize,
63    /// First visible character.
64    first: usize,
65    focused: bool,
66    /// A click inside is being dragged (extends the selection).
67    dragging: bool,
68    /// Last input, for restarting the caret blink.
69    last_input: Option<Instant>,
70    // Learned from the widget in `apply`.
71    max_length: usize,
72    pad_l: f32,
73    pad_r: f32,
74    font_scale: f32,
75    font: String,
76    on_change: Option<String>,
77    on_submit: Option<String>,
78}
79
80impl Default for TextBoxState {
81    fn default() -> Self {
82        Self::new()
83    }
84}
85
86/// Byte offset of character `i` in `s` (`s.len()` past the end).
87fn byte_at(s: &str, i: usize) -> usize {
88    s.char_indices().nth(i).map_or(s.len(), |(b, _)| b)
89}
90
91/// Whether `c` may be typed into a box — what vanilla allows in chat.
92fn allowed(c: char) -> bool {
93    c != '§' && c >= ' ' && c != '\u{7f}'
94}
95
96impl TextBoxState {
97    pub const fn new() -> Self {
98        Self {
99            text: String::new(),
100            caret: 0,
101            anchor: 0,
102            first: 0,
103            focused: false,
104            dragging: false,
105            last_input: None,
106            max_length: 0,
107            pad_l: TEXT_BOX_PAD_X,
108            pad_r: TEXT_BOX_PAD_X,
109            font_scale: 1.0,
110            font: String::new(),
111            on_change: None,
112            on_submit: None,
113        }
114    }
115
116    pub fn text(&self) -> &str {
117        &self.text
118    }
119
120    /// Replaces the text (cut to the max length), caret at the end.
121    pub fn set_text(&mut self, text: impl Into<String>) {
122        self.text = text.into();
123        if self.max_length > 0 {
124            let cut = byte_at(&self.text, self.max_length);
125            self.text.truncate(cut);
126        }
127        self.caret = self.len();
128        self.anchor = self.caret;
129        self.first = 0;
130    }
131
132    /// Empties the box.
133    pub fn clear(&mut self) {
134        self.set_text("");
135    }
136
137    pub fn is_focused(&self) -> bool {
138        self.focused
139    }
140
141    /// Gives the box keyboard focus, caret at the end.
142    pub fn focus(&mut self) {
143        self.focused = true;
144        self.caret = self.len();
145        self.anchor = self.caret;
146        self.touch();
147    }
148
149    /// Takes keyboard focus away.
150    pub fn blur(&mut self) {
151        self.focused = false;
152        self.dragging = false;
153        self.anchor = self.caret;
154    }
155
156    /// Caret position, in characters.
157    pub fn caret(&self) -> usize {
158        self.caret
159    }
160
161    /// Selected characters `start..end` (empty when nothing is selected).
162    pub fn selection(&self) -> (usize, usize) {
163        (self.caret.min(self.anchor), self.caret.max(self.anchor))
164    }
165
166    /// Selects all the text.
167    pub fn select_all(&mut self) {
168        self.anchor = 0;
169        self.caret = self.len();
170    }
171
172    /// The `on_change`/`on_submit` event name the widget declared for a
173    /// [`TextBoxEvent::Changed`]/[`TextBoxEvent::Submitted`] (learned in
174    /// [`Self::apply`]); `None` for other results or when it has none.
175    pub fn event_name(&self, ev: TextBoxEvent) -> Option<&str> {
176        match ev {
177            TextBoxEvent::Changed => self.on_change.as_deref(),
178            TextBoxEvent::Submitted => self.on_submit.as_deref(),
179            _ => None,
180        }
181    }
182
183    fn len(&self) -> usize {
184        self.text.chars().count()
185    }
186
187    fn touch(&mut self) {
188        self.last_input = Some(Instant::now());
189    }
190
191    /// Writes the state into the text box widget `w` before layout: text,
192    /// caret, selection, scroll, focus. Also picks up the widget's max
193    /// length, padding, font and event names. Does nothing to other kinds.
194    pub fn apply(&mut self, w: &mut Widget) {
195        let WidgetKind::TextBox(tb) = &mut w.kind else {
196            return;
197        };
198        self.max_length = tb.max_length;
199        self.on_change = tb.on_change.clone();
200        self.on_submit = tb.on_submit.clone();
201        self.pad_l = w.style.pad[3];
202        self.pad_r = w.style.pad[1];
203        self.font_scale = w.style.font_scale;
204        self.font = w.style.font.clone();
205        if self.max_length > 0 && self.len() > self.max_length {
206            let cut = byte_at(&self.text, self.max_length);
207            self.text.truncate(cut);
208        }
209        let n = self.len();
210        self.caret = self.caret.min(n);
211        self.anchor = self.anchor.min(n);
212        self.first = self.first.min(self.caret);
213        tb.text = self.text.clone();
214        tb.caret = self.focused.then_some(self.caret);
215        tb.caret_visible = self.focused
216            && self
217                .last_input
218                .is_none_or(|t| (t.elapsed().as_millis() / BLINK_MS).is_multiple_of(2));
219        tb.selection = if self.focused {
220            self.selection()
221        } else {
222            (0, 0)
223        };
224        tb.first = self.first;
225        w.focused = self.focused;
226    }
227
228    /// Width available to the text inside `node`.
229    fn inner_w(&self, node: &LayoutNode) -> f32 {
230        (node.rect.w - 2.0 - self.pad_l - self.pad_r).max(0.0)
231    }
232
233    fn char_w(&self, c: char) -> f32 {
234        text::char_width_in(c, &self.font) * self.font_scale
235    }
236
237    /// Scrolls so the caret is visible in `node`.
238    fn scroll_to_caret(&mut self, node: &LayoutNode) {
239        self.first = text_box_first(
240            &self.text,
241            self.first,
242            self.caret,
243            self.inner_w(node),
244            |c| self.char_w(c),
245        );
246    }
247
248    /// The character boundary nearest to GUI x `mx` in `node`.
249    fn index_at(&self, node: &LayoutNode, mx: f32) -> usize {
250        let mut x = node.rect.x + 1.0 + self.pad_l;
251        let mut i = self.first;
252        for c in self.text.chars().skip(self.first) {
253            let w = self.char_w(c);
254            if mx < x + w / 2.0 {
255                return i;
256            }
257            x += w;
258            i += 1;
259        }
260        i
261    }
262
263    /// Deletes the selection; `true` if there was one.
264    fn delete_selection(&mut self) -> bool {
265        let (a, b) = self.selection();
266        if a == b {
267            return false;
268        }
269        let (ba, bb) = (byte_at(&self.text, a), byte_at(&self.text, b));
270        self.text.replace_range(ba..bb, "");
271        self.caret = a;
272        self.anchor = a;
273        true
274    }
275
276    fn insert(&mut self, c: char) -> bool {
277        let had_selection = self.delete_selection();
278        if self.max_length > 0 && self.len() >= self.max_length {
279            return had_selection;
280        }
281        let at = byte_at(&self.text, self.caret);
282        self.text.insert(at, c);
283        self.caret += 1;
284        self.anchor = self.caret;
285        true
286    }
287
288    fn move_to(&mut self, i: usize, extend: bool) {
289        self.caret = i.min(self.len());
290        if !extend {
291            self.anchor = self.caret;
292        }
293    }
294
295    /// Applies one UI event; `node` is the box's laid-out node from the
296    /// frame the user saw (`UiRoot::find_node`).
297    pub fn handle(&mut self, event: &str, node: &LayoutNode) -> TextBoxEvent {
298        self.handle_event(&UiEvent::parse(event), node)
299    }
300
301    /// [`Self::handle`] for a parsed event.
302    pub fn handle_event(&mut self, event: &UiEvent<'_>, node: &LayoutNode) -> TextBoxEvent {
303        let result = self.handle_inner(event, node);
304        if result.consumed() {
305            self.touch();
306            self.scroll_to_caret(node);
307        }
308        result
309    }
310
311    fn handle_inner(&mut self, event: &UiEvent<'_>, node: &LayoutNode) -> TextBoxEvent {
312        let (k, mods) = match *event {
313            UiEvent::Click { x: mx, y: my } => {
314                if node.visible && node.enabled && node.rect.contains(mx, my) {
315                    self.focused = true;
316                    self.dragging = true;
317                    let i = self.index_at(node, mx);
318                    self.move_to(i, false);
319                    return TextBoxEvent::Handled;
320                }
321                if self.focused {
322                    self.blur();
323                }
324                return TextBoxEvent::Ignored;
325            }
326            UiEvent::Drag { x: mx, .. } => {
327                if !self.dragging {
328                    return TextBoxEvent::Ignored;
329                }
330                let i = self.index_at(node, mx);
331                self.move_to(i, true);
332                return TextBoxEvent::Handled;
333            }
334            UiEvent::Release { .. } => {
335                return if std::mem::take(&mut self.dragging) {
336                    TextBoxEvent::Handled
337                } else {
338                    TextBoxEvent::Ignored
339                };
340            }
341            _ if !self.focused => return TextBoxEvent::Ignored,
342            UiEvent::Char(c) => {
343                if !allowed(c) {
344                    return TextBoxEvent::Handled;
345                }
346                return if self.insert(c) {
347                    TextBoxEvent::Changed
348                } else {
349                    TextBoxEvent::Handled
350                };
351            }
352            UiEvent::Key { key, mods } => (key, mods),
353            _ => return TextBoxEvent::Ignored,
354        };
355        let shift = mods & key::MOD_SHIFT != 0;
356        let ctrl = mods & (key::MOD_CONTROL | key::MOD_SUPER) != 0;
357        match k {
358            key::ESCAPE => self.blur(),
359            key::ENTER | key::KP_ENTER => return TextBoxEvent::Submitted,
360            key::BACKSPACE => {
361                if !self.delete_selection() {
362                    if self.caret == 0 {
363                        return TextBoxEvent::Handled;
364                    }
365                    self.anchor = self.caret - 1;
366                    self.delete_selection();
367                }
368                return TextBoxEvent::Changed;
369            }
370            key::DELETE => {
371                if !self.delete_selection() {
372                    if self.caret >= self.len() {
373                        return TextBoxEvent::Handled;
374                    }
375                    self.anchor = self.caret + 1;
376                    self.delete_selection();
377                }
378                return TextBoxEvent::Changed;
379            }
380            key::LEFT => {
381                let (a, b) = self.selection();
382                let to = if !shift && a != b {
383                    a
384                } else {
385                    self.caret.saturating_sub(1)
386                };
387                self.move_to(to, shift);
388            }
389            key::RIGHT => {
390                let (a, b) = self.selection();
391                let to = if !shift && a != b { b } else { self.caret + 1 };
392                self.move_to(to, shift);
393            }
394            key::HOME => self.move_to(0, shift),
395            key::END => self.move_to(self.len(), shift),
396            key::A if ctrl => self.select_all(),
397            // Any other key while focused is the box's too (so it doesn't
398            // trigger the screen's own shortcuts).
399            _ => {}
400        }
401        TextBoxEvent::Handled
402    }
403}
404
405/// The first visible character of a box `inner_w` wide whose first visible
406/// character was `first`, adjusted so the caret is in view. Shared by
407/// [`TextBoxState`] and the renderer.
408pub(crate) fn text_box_first(
409    text: &str,
410    first: usize,
411    caret: usize,
412    inner_w: f32,
413    char_w: impl Fn(char) -> f32,
414) -> usize {
415    let mut first = first.min(caret);
416    let widths: Vec<f32> = text.chars().map(char_w).collect();
417    let caret = caret.min(widths.len());
418    while first < caret && widths[first..caret].iter().sum::<f32>() > inner_w {
419        first += 1;
420    }
421    first
422}
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427    use crate::layout::{self, FlexDir};
428    use crate::widget;
429
430    fn node() -> LayoutNode {
431        let root = widget::panel(FlexDir::Column)
432            .child(widget::text_box("").id("box").w(50.0).at(10.0, 10.0));
433        let n = layout::compute(&root, 300.0, 300.0);
434        layout::find_node(&n, "box").unwrap().clone()
435    }
436
437    fn typed(s: &mut TextBoxState, n: &LayoutNode, text: &str) {
438        for c in text.chars() {
439            assert_eq!(
440                s.handle(&format!("char:{}", c as u32), n),
441                TextBoxEvent::Changed
442            );
443        }
444    }
445
446    #[test]
447    fn focus_type_and_edit() {
448        let n = node();
449        assert_eq!((n.rect.x, n.rect.w, n.rect.h), (10.0, 50.0, 16.0));
450        let mut s = TextBoxState::new();
451        assert_eq!(s.handle("char:65", &n), TextBoxEvent::Ignored, "unfocused");
452        assert_eq!(s.handle("click:20:15", &n), TextBoxEvent::Handled);
453        assert!(s.is_focused());
454        typed(&mut s, &n, "héllo");
455        assert_eq!(s.text(), "héllo");
456        assert_eq!(s.handle("key:259:0", &n), TextBoxEvent::Changed);
457        assert_eq!(s.text(), "héll");
458        assert_eq!(s.handle("key:268:0", &n), TextBoxEvent::Handled);
459        assert_eq!(s.handle("key:261:0", &n), TextBoxEvent::Changed);
460        assert_eq!(s.text(), "éll");
461        assert_eq!(s.handle("key:262:1", &n), TextBoxEvent::Handled);
462        assert_eq!(s.selection(), (0, 1));
463        typed(&mut s, &n, "E");
464        assert_eq!(s.text(), "Ell");
465        assert_eq!(s.handle("key:65:2", &n), TextBoxEvent::Handled);
466        assert_eq!(s.selection(), (0, 3));
467        assert_eq!(s.handle("key:259:0", &n), TextBoxEvent::Changed);
468        assert_eq!(s.text(), "");
469        assert_eq!(s.handle("key:257:0", &n), TextBoxEvent::Submitted);
470        // Esc unfocuses (consumed); the next Esc is the screen's.
471        assert_eq!(s.handle("key:256:0", &n), TextBoxEvent::Handled);
472        assert_eq!(s.handle("key:256:0", &n), TextBoxEvent::Ignored);
473    }
474
475    #[test]
476    fn click_outside_unfocuses_and_passes_on() {
477        let n = node();
478        let mut s = TextBoxState::new();
479        s.handle("click:20:15", &n);
480        assert_eq!(s.handle("click:200:200", &n), TextBoxEvent::Ignored);
481        assert!(!s.is_focused());
482    }
483
484    #[test]
485    fn max_length_and_scrolling() {
486        let n = node();
487        let mut s = TextBoxState::new();
488        let mut w = widget::text_box("").max_length(12);
489        s.apply(&mut w);
490        s.handle("click:20:15", &n);
491        for _ in 0..20 {
492            s.handle("char:120", &n); // 'x', 6 px each
493        }
494        assert_eq!(s.text().len(), 12);
495        // 50 - 2 border - 8 padding = 40 px: six x's fit; caret at 12.
496        assert_eq!(s.first, 6);
497        s.handle("key:268:0", &n);
498        assert_eq!(s.first, 0);
499        // Clicking maps x to the nearest boundary.
500        s.handle("click:26:15", &n); // 10 + 1 + 4 = 15 → (26-15)/6 ≈ 1.8 → 2
501        assert_eq!(s.caret(), 2);
502        s.apply(&mut w);
503        match &w.kind {
504            WidgetKind::TextBox(tb) => {
505                assert_eq!(tb.caret, Some(2));
506                assert_eq!(tb.text.len(), 12);
507            }
508            _ => unreachable!(),
509        }
510    }
511
512    #[test]
513    fn event_names_come_from_the_widget() {
514        let mut s = TextBoxState::new();
515        let mut w = widget::text_box("").on_change("search").on_submit("go");
516        s.apply(&mut w);
517        assert_eq!(s.event_name(TextBoxEvent::Changed), Some("search"));
518        assert_eq!(s.event_name(TextBoxEvent::Submitted), Some("go"));
519        assert_eq!(s.event_name(TextBoxEvent::Handled), None);
520    }
521}