Skip to main content

retroglyph_ui/state/
text_input.rs

1use alloc::string::String;
2
3use retroglyph_core::event::{Event, KeyCode, KeyModifiers};
4use retroglyph_core::text::{char_width, width_usize};
5
6/// A `String` value, a byte cursor into it, and a horizontal scroll offset.
7///
8/// The state a single-line editable text field needs, mirroring [`ListState`](crate::ListState)'s
9/// split between "what's selected/typed" and "how it's drawn".
10///
11/// Holds no reference to any widget: the same `TextInputState` can be reused across frames (and
12/// across a resized field) the way `ListState` is reused across a resized list.
13///
14/// `cursor` is a byte index into `value`, not a char or display-column index, and every mutating
15/// method here maintains the invariant that it always lands on a char boundary: `insert`/
16/// `insert_str`/`backspace`/`delete` never split a multi-byte character, and `move_left`/
17/// `move_right` step by whole `char`s. Display-column math (where the caret actually draws, and
18/// how far the field has scrolled) is a separate concern handled by
19/// [`ensure_visible`](Self::ensure_visible) and the [`TextInput`](crate::TextInput) widget itself, via
20/// `retroglyph_core::text::width_usize`: a byte or char count is the wrong unit once the value
21/// contains a double-width character.
22///
23/// Handles typed characters (`Event::Key(KeyCode::Char(c))`) and pasted text (`Event::Paste`)
24/// only, not IME/text composition or multi-line editing.
25///
26/// Cursor movement and deletion step by Unicode `char` (codepoint), not by grapheme cluster: a
27/// base character plus a combining mark is two `char`s, so `backspace` there removes only the
28/// mark and `move_left` stops between the two. A field over text with combining marks or
29/// emoji-ZWJ sequences will show per-codepoint, not per-glyph, editing.
30#[derive(Clone, Debug, Default, PartialEq, Eq)]
31pub struct TextInputState {
32    value: String,
33    cursor: usize,
34    scroll: u16,
35}
36
37impl TextInputState {
38    /// An empty field: empty value, cursor at `0`, scroll at `0`.
39    #[must_use]
40    pub const fn new() -> Self {
41        Self {
42            value: String::new(),
43            cursor: 0,
44            scroll: 0,
45        }
46    }
47
48    /// The current text content.
49    #[must_use]
50    pub fn value(&self) -> &str {
51        &self.value
52    }
53
54    /// Replace the entire content and move the cursor to its end. Resets the scroll offset to
55    /// zero. Call [`ensure_visible`](Self::ensure_visible) afterward if the new value should
56    /// scroll to keep the cursor (still at the end) in view.
57    ///
58    /// Sets the cursor to `value.len()`, a trivially-valid char boundary, so this skips the
59    /// debug-only boundary guard the other mutators carry.
60    pub fn set_value(&mut self, s: impl Into<String>) {
61        self.value = s.into();
62        self.cursor = self.value.len();
63        self.scroll = 0;
64    }
65
66    /// The cursor's byte offset into [`value`](Self::value). Always on a char boundary.
67    #[must_use]
68    pub const fn cursor(&self) -> usize {
69        self.cursor
70    }
71
72    /// The current horizontal scroll offset, in display columns from the start of `value`. See
73    /// [`ensure_visible`](Self::ensure_visible).
74    #[must_use]
75    pub const fn scroll(&self) -> u16 {
76        self.scroll
77    }
78
79    /// Insert `c` at the cursor and move the cursor past it.
80    pub fn insert(&mut self, c: char) {
81        self.value.insert(self.cursor, c);
82        self.cursor += c.len_utf8();
83        self.debug_assert_cursor_valid();
84    }
85
86    /// Insert `s` at the cursor and move the cursor past it, e.g. from an
87    /// [`Event::Paste`](retroglyph_core::event::Event::Paste).
88    pub fn insert_str(&mut self, s: &str) {
89        self.value.insert_str(self.cursor, s);
90        self.cursor += s.len();
91        self.debug_assert_cursor_valid();
92    }
93
94    /// Delete the character before the cursor, if any, and move the cursor onto its place.
95    pub fn backspace(&mut self) {
96        let Some(prev) = self.prev_boundary() else {
97            return;
98        };
99        self.value.drain(prev..self.cursor);
100        self.cursor = prev;
101        self.debug_assert_cursor_valid();
102    }
103
104    /// Delete the character at the cursor, if any. The cursor itself does not move.
105    pub fn delete(&mut self) {
106        let Some(next) = self.next_boundary() else {
107            return;
108        };
109        self.value.drain(self.cursor..next);
110        self.debug_assert_cursor_valid();
111    }
112
113    /// Move the cursor one character left, if not already at the start.
114    pub fn move_left(&mut self) {
115        if let Some(prev) = self.prev_boundary() {
116            self.cursor = prev;
117        }
118        self.debug_assert_cursor_valid();
119    }
120
121    /// Move the cursor one character right, if not already at the end.
122    pub fn move_right(&mut self) {
123        if let Some(next) = self.next_boundary() {
124            self.cursor = next;
125        }
126        self.debug_assert_cursor_valid();
127    }
128
129    /// Move the cursor to the start of the value.
130    ///
131    /// Sets the cursor to `0`, a trivially-valid char boundary, so this skips the debug-only
132    /// boundary guard the other mutators carry.
133    pub const fn move_home(&mut self) {
134        self.cursor = 0;
135    }
136
137    /// Move the cursor to the end of the value.
138    ///
139    /// Sets the cursor to `value.len()`, a trivially-valid char boundary, so this skips the
140    /// debug-only boundary guard the other mutators carry.
141    pub const fn move_end(&mut self) {
142        self.cursor = self.value.len();
143    }
144
145    /// Apply one input event: typed characters, Backspace/Delete, Left/Right/Home/End, and
146    /// pasted text. Returns whether the event was consumed (so a caller can decide whether to
147    /// fall through to its own key handling, e.g. Enter/Escape/Tab, none of which this consumes).
148    ///
149    /// Key releases and auto-repeats other than presses are ignored except that auto-repeat
150    /// presses are treated the same as a press (matches [`FocusRing`](crate::FocusRing)/
151    /// [`Shortcuts`](crate::Shortcuts)'s own `is_down` gating). [`Event`](retroglyph_core::event::Event)
152    /// has no IME/composition variant to begin with; see the scope note above.
153    pub fn handle_event(&mut self, event: &Event) -> bool {
154        match event {
155            Event::Key(key) if key.is_down() => match key.code {
156                KeyCode::Char(c)
157                    if (key.modifiers
158                        & (KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER))
159                        .is_empty() =>
160                {
161                    self.insert(c);
162                    true
163                }
164                KeyCode::Backspace => {
165                    self.backspace();
166                    true
167                }
168                KeyCode::Delete => {
169                    self.delete();
170                    true
171                }
172                KeyCode::Left => {
173                    self.move_left();
174                    true
175                }
176                KeyCode::Right => {
177                    self.move_right();
178                    true
179                }
180                KeyCode::Home => {
181                    self.move_home();
182                    true
183                }
184                KeyCode::End => {
185                    self.move_end();
186                    true
187                }
188                _ => false,
189            },
190            Event::Paste(s) => {
191                self.insert_str(s);
192                true
193            }
194            _ => false,
195        }
196    }
197
198    /// Nudge the scroll offset by the minimum amount needed to keep the cursor within a
199    /// `width`-column window, the way [`ListState::ensure_visible`](crate::ListState::ensure_visible)
200    /// keeps a selection in view.
201    ///
202    /// A no-op if `width` is zero or the cursor is already visible. Call this once per frame
203    /// before rendering (with the actual, current field width, since that can change on resize)
204    /// rather than only after an edit: it's cheap and idempotent.
205    pub fn ensure_visible(&mut self, width: u16) {
206        if width == 0 {
207            return;
208        }
209        let caret_col = self.caret_column();
210        if caret_col < self.scroll {
211            self.scroll = self.snap_to_char_boundary(caret_col);
212        } else if caret_col >= self.scroll.saturating_add(width) {
213            let target = caret_col.saturating_add(1).saturating_sub(width);
214            self.scroll = self.snap_to_char_boundary(target);
215        }
216    }
217
218    /// The largest display column `<= col` at which some character in `value` starts, so that
219    /// `retroglyph_core::text::split_at_width(value, that_column)` never has to refuse to split a
220    /// wide character's own cell (see issue #712).
221    ///
222    /// Mirrors `split_at_width`'s own column-accumulation walk: a column is only ever a valid
223    /// split point if it lines up with a char boundary, not the right half of a double-width
224    /// glyph's footprint.
225    #[must_use]
226    fn snap_to_char_boundary(&self, col: u16) -> u16 {
227        let mut cols = 0u16;
228        for ch in self.value.chars() {
229            let next = cols.saturating_add(char_width(ch));
230            if next > col {
231                break;
232            }
233            cols = next;
234        }
235        cols
236    }
237
238    /// The cursor's position in display columns from the start of `value`, saturating at
239    /// `u16::MAX` the way [`width`](retroglyph_core::text::width) does.
240    #[must_use]
241    fn caret_column(&self) -> u16 {
242        #[allow(clippy::cast_possible_truncation)] // saturated to u16::MAX below
243        let col = width_usize(&self.value[..self.cursor]).min(usize::from(u16::MAX)) as u16;
244        col
245    }
246
247    /// The byte index of the char boundary immediately before the cursor, or `None` at the
248    /// start of the value.
249    fn prev_boundary(&self) -> Option<usize> {
250        self.value[..self.cursor]
251            .char_indices()
252            .next_back()
253            .map(|(i, _)| i)
254    }
255
256    /// The byte index of the char boundary immediately after the cursor, or `None` at the end
257    /// of the value.
258    fn next_boundary(&self) -> Option<usize> {
259        let mut chars = self.value[self.cursor..].char_indices();
260        chars.next()?;
261        Some(
262            chars
263                .next()
264                .map_or(self.value.len(), |(i, _)| self.cursor + i),
265        )
266    }
267
268    /// Debug-only guard for the char-boundary invariant documented on the type. Not compiled into
269    /// release builds; it exists so a future edit that lets `cursor` land mid-character fails here
270    /// with a clear message instead of far away in a byte-slice panic.
271    #[inline]
272    fn debug_assert_cursor_valid(&self) {
273        debug_assert!(
274            self.value.is_char_boundary(self.cursor),
275            "cursor {} is not a char boundary in {:?}",
276            self.cursor,
277            self.value
278        );
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use retroglyph_core::event::{KeyEvent, KeyEventKind};
286
287    #[test]
288    fn insert_and_backspace_move_the_byte_cursor() {
289        let mut state = TextInputState::new();
290        state.insert('h');
291        state.insert('i');
292        assert_eq!(state.value(), "hi");
293        assert_eq!(state.cursor(), 2);
294
295        state.backspace();
296        assert_eq!(state.value(), "h");
297        assert_eq!(state.cursor(), 1);
298    }
299
300    #[test]
301    fn backspace_and_delete_stay_on_char_boundaries_for_multibyte_chars() {
302        let mut state = TextInputState::new();
303        state.set_value("aあb");
304        // "aあb": a=1 byte, あ=3 bytes, b=1 byte. Cursor starts at end (5).
305        state.move_left(); // before 'b', cursor = 4
306        state.backspace(); // removes 'あ' (3 bytes), not a partial byte of it
307        assert_eq!(state.value(), "ab");
308        assert_eq!(state.cursor(), 1);
309
310        state.set_value("aあb");
311        state.move_home();
312        state.move_right(); // after 'a', cursor = 1
313        state.delete(); // removes 'あ' whole
314        assert_eq!(state.value(), "ab");
315        assert_eq!(state.cursor(), 1);
316    }
317
318    #[test]
319    fn move_left_right_home_end_traverse_whole_characters() {
320        let mut state = TextInputState::new();
321        state.set_value("hi");
322        state.move_home();
323        assert_eq!(state.cursor(), 0);
324        state.move_right();
325        assert_eq!(state.cursor(), 1);
326        state.move_end();
327        assert_eq!(state.cursor(), 2);
328        state.move_left();
329        assert_eq!(state.cursor(), 1);
330    }
331
332    #[test]
333    fn insert_str_handles_paste() {
334        let mut state = TextInputState::new();
335        state.insert_str("hello");
336        assert_eq!(state.value(), "hello");
337        assert_eq!(state.cursor(), 5);
338    }
339
340    #[test]
341    fn handle_event_consumes_typed_keys_and_paste() {
342        let mut state = TextInputState::new();
343        let key = |code| Event::Key(KeyEvent::new(code, KeyModifiers::NONE));
344
345        assert!(state.handle_event(&key(KeyCode::Char('x'))));
346        assert_eq!(state.value(), "x");
347
348        assert!(state.handle_event(&Event::Paste("yz".into())));
349        assert_eq!(state.value(), "xyz");
350
351        assert!(state.handle_event(&key(KeyCode::Backspace)));
352        assert_eq!(state.value(), "xy");
353
354        // Not consumed: this widget has no opinion on Enter/Escape/Tab.
355        assert!(!state.handle_event(&key(KeyCode::Enter)));
356    }
357
358    #[test]
359    fn text_input_handle_event_does_not_type_ctrl_shortcut_characters() {
360        let mut state = TextInputState::new();
361        let ctrl_s = Event::Key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL));
362        let consumed = state.handle_event(&ctrl_s);
363
364        assert_eq!(state.value(), "");
365        assert!(!consumed);
366    }
367
368    #[test]
369    fn handle_event_still_types_shifted_characters() {
370        let mut state = TextInputState::new();
371        let shift_s = Event::Key(KeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT));
372        let consumed = state.handle_event(&shift_s);
373
374        assert_eq!(state.value(), "S");
375        assert!(consumed);
376    }
377
378    #[test]
379    fn handle_event_ignores_key_releases() {
380        let mut state = TextInputState::new();
381        let release = Event::Key(KeyEvent::with_kind(
382            KeyCode::Char('x'),
383            KeyModifiers::NONE,
384            KeyEventKind::Release,
385        ));
386        assert!(!state.handle_event(&release));
387        assert_eq!(state.value(), "");
388    }
389
390    #[test]
391    fn ensure_visible_scrolls_to_keep_the_caret_in_view() {
392        let mut state = TextInputState::new();
393        state.set_value("hello world");
394        state.ensure_visible(5);
395        // Cursor is at the end (column 11); a 5-wide window scrolls to show it.
396        assert_eq!(state.scroll(), 11 + 1 - 5);
397
398        state.move_home();
399        state.ensure_visible(5);
400        assert_eq!(state.scroll(), 0);
401    }
402
403    #[test]
404    fn ensure_visible_accounts_for_wide_characters() {
405        let mut state = TextInputState::new();
406        state.set_value("aああ"); // columns: a=1, あ=2, あ=2 -> caret at end is column 5
407        state.ensure_visible(3);
408        assert_eq!(state.scroll(), 5 + 1 - 3);
409    }
410
411    #[test]
412    fn ensure_visible_does_not_overflow_when_the_caret_column_saturates() {
413        // retroglyph#729: `caret_col + 1 - width` used to overflow on the add once a long enough
414        // paste saturated `caret_column()` at `u16::MAX`.
415        let mut state = TextInputState::new();
416        state.insert_str(&"a".repeat(70_000));
417        state.ensure_visible(5);
418        assert_eq!(state.scroll(), u16::MAX - 5);
419    }
420
421    #[test]
422    fn ensure_visible_is_a_noop_for_zero_width() {
423        let mut state = TextInputState::new();
424        state.set_value("hello");
425        state.ensure_visible(0);
426        assert_eq!(state.scroll(), 0);
427    }
428
429    #[test]
430    fn text_input_ensure_visible_scroll_does_not_split_a_wide_character() {
431        // retroglyph#712: naive column arithmetic (caret_col + 1 - width) landed scroll == 1,
432        // inside "あ"'s own two-column cell, which `split_at_width` refuses to split. Snapping
433        // down to the nearest char boundary keeps scroll at 0, the start of the first "あ".
434        let mut state = TextInputState::new();
435        state.set_value("ああ"); // two 2-column characters, caret at end is column 4
436        state.ensure_visible(4);
437        assert_eq!(state.scroll(), 0);
438    }
439}