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}