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