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