Skip to main content

tb_tui_common/
text_input.rs

1//! A minimal single-line editable text buffer for TUI prompts — modal
2//! value entry, rename fields, filter / command lines. Prompts used to
3//! hand-roll append-only editing (`Backspace`/`Char` only); this owns
4//! the text plus a cursor and the editing operations once, so prompts
5//! behave consistently and a fix here (cursor movement, word-delete,
6//! paste) lands everywhere that uses it.
7
8use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
9
10/// Single-line editable text with a cursor. `cursor` is a byte offset
11/// into `buf`, always kept on a `char` boundary and within
12/// `0..=buf.len()`.
13#[derive(Debug, Clone, Default, PartialEq, Eq)]
14pub struct TextInput {
15    buf: String,
16    cursor: usize,
17}
18
19impl TextInput {
20    /// Empty buffer, cursor at the start.
21    pub fn new() -> Self {
22        Self::default()
23    }
24
25    /// Seed with existing text, cursor at the end — the natural spot
26    /// when editing a pre-filled value (e.g. a rename).
27    pub fn with_text(text: impl Into<String>) -> Self {
28        let buf = text.into();
29        let cursor = buf.len();
30        Self { buf, cursor }
31    }
32
33    /// The current text.
34    pub fn text(&self) -> &str {
35        &self.buf
36    }
37
38    /// The text with surrounding whitespace trimmed (for accept-time
39    /// validation).
40    pub fn trimmed(&self) -> &str {
41        self.buf.trim()
42    }
43
44    /// True when the buffer is empty.
45    pub fn is_empty(&self) -> bool {
46        self.buf.is_empty()
47    }
48
49    /// Empty the buffer and reset the cursor to the start.
50    pub fn clear(&mut self) {
51        self.buf.clear();
52        self.cursor = 0;
53    }
54
55    /// Display column of the cursor — the number of characters before
56    /// it. Renderers add this to the input box's left edge to place the
57    /// terminal cursor.
58    pub fn cursor_col(&self) -> usize {
59        self.buf[..self.cursor].chars().count()
60    }
61
62    /// Insert a character at the cursor, advancing past it.
63    pub fn insert(&mut self, c: char) {
64        self.buf.insert(self.cursor, c);
65        self.cursor += c.len_utf8();
66    }
67
68    /// Insert a string at the cursor (paste), advancing past it.
69    pub fn insert_str(&mut self, s: &str) {
70        self.buf.insert_str(self.cursor, s);
71        self.cursor += s.len();
72    }
73
74    /// Delete the char before the cursor (Backspace). No-op at the start.
75    pub fn backspace(&mut self) {
76        if self.cursor == 0 {
77            return;
78        }
79        let prev = self.prev_char_len();
80        self.cursor -= prev;
81        self.buf.remove(self.cursor);
82    }
83
84    /// Delete the char at the cursor (Delete). No-op at the end.
85    pub fn delete_forward(&mut self) {
86        if self.cursor < self.buf.len() {
87            self.buf.remove(self.cursor);
88        }
89    }
90
91    /// Delete the word before the cursor (Ctrl-W): the run of trailing
92    /// spaces, then the run of non-space chars up to the previous space.
93    pub fn delete_word_back(&mut self) {
94        let head = &self.buf[..self.cursor];
95        let trimmed = head.trim_end_matches(' ');
96        let start = trimmed.rfind(' ').map(|i| i + 1).unwrap_or(0);
97        self.buf.replace_range(start..self.cursor, "");
98        self.cursor = start;
99    }
100
101    /// Move the cursor one char left.
102    pub fn left(&mut self) {
103        if self.cursor > 0 {
104            self.cursor -= self.prev_char_len();
105        }
106    }
107
108    /// Move the cursor one char right.
109    pub fn right(&mut self) {
110        if self.cursor < self.buf.len() {
111            let next = self.buf[self.cursor..]
112                .chars()
113                .next()
114                .map(char::len_utf8)
115                .unwrap_or(0);
116            self.cursor += next;
117        }
118    }
119
120    /// Move the cursor to the start.
121    pub fn home(&mut self) {
122        self.cursor = 0;
123    }
124
125    /// Move the cursor to the end.
126    pub fn end(&mut self) {
127        self.cursor = self.buf.len();
128    }
129
130    /// Apply a single editing keypress. Returns `true` if the key was an
131    /// editing action (consumed), `false` if the caller should handle it
132    /// (Enter/Esc/Tab/…). `Ctrl`/`Alt`/`Super`-modified keys other than
133    /// the recognised emacs-style chords are left unhandled so global
134    /// chords (and OS shortcuts like Cmd+V on terminals that report the
135    /// Super modifier) still reach the dispatcher rather than being typed
136    /// as text.
137    pub fn handle_key(&mut self, key: KeyEvent) -> bool {
138        let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
139        let alt = key.modifiers.contains(KeyModifiers::ALT);
140        let logo = key.modifiers.contains(KeyModifiers::SUPER);
141        match key.code {
142            KeyCode::Backspace => self.backspace(),
143            KeyCode::Delete => self.delete_forward(),
144            KeyCode::Left => self.left(),
145            KeyCode::Right => self.right(),
146            KeyCode::Home => self.home(),
147            KeyCode::End => self.end(),
148            KeyCode::Char('w') if ctrl => self.delete_word_back(),
149            KeyCode::Char('a') if ctrl => self.home(),
150            KeyCode::Char('e') if ctrl => self.end(),
151            KeyCode::Char(c) if !ctrl && !alt && !logo => self.insert(c),
152            _ => return false,
153        }
154        true
155    }
156
157    /// Byte length of the char immediately before the cursor (0 at the
158    /// start). Cursor is always on a boundary, so this is exact.
159    fn prev_char_len(&self) -> usize {
160        self.buf[..self.cursor]
161            .chars()
162            .next_back()
163            .map(char::len_utf8)
164            .unwrap_or(0)
165    }
166}
167
168impl From<&str> for TextInput {
169    fn from(s: &str) -> Self {
170        Self::with_text(s)
171    }
172}
173
174impl From<String> for TextInput {
175    fn from(s: String) -> Self {
176        Self::with_text(s)
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183    use crossterm::event::KeyEvent;
184
185    fn key(code: KeyCode) -> KeyEvent {
186        KeyEvent::new(code, KeyModifiers::NONE)
187    }
188    fn ctrl(c: char) -> KeyEvent {
189        KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
190    }
191
192    #[test]
193    fn insert_and_backspace_at_end() {
194        let mut t = TextInput::new();
195        for c in "abc".chars() {
196            t.insert(c);
197        }
198        assert_eq!(t.text(), "abc");
199        assert_eq!(t.cursor_col(), 3);
200        t.backspace();
201        assert_eq!(t.text(), "ab");
202        assert_eq!(t.cursor_col(), 2);
203    }
204
205    #[test]
206    fn mid_string_insert_and_delete() {
207        let mut t = TextInput::with_text("ac");
208        t.left(); // cursor between a and c
209        t.insert('b');
210        assert_eq!(t.text(), "abc");
211        assert_eq!(t.cursor_col(), 2);
212        t.home();
213        t.delete_forward(); // delete 'a'
214        assert_eq!(t.text(), "bc");
215        assert_eq!(t.cursor_col(), 0);
216    }
217
218    #[test]
219    fn backspace_and_left_are_noops_at_start() {
220        let mut t = TextInput::with_text("x");
221        t.home();
222        t.left();
223        assert_eq!(t.cursor_col(), 0);
224        t.backspace();
225        assert_eq!(t.text(), "x");
226        // Delete-forward at end is a no-op too.
227        t.end();
228        t.delete_forward();
229        assert_eq!(t.text(), "x");
230    }
231
232    #[test]
233    fn home_end_and_cursor_col() {
234        let mut t = TextInput::with_text("hello");
235        assert_eq!(t.cursor_col(), 5);
236        t.home();
237        assert_eq!(t.cursor_col(), 0);
238        t.right();
239        t.right();
240        assert_eq!(t.cursor_col(), 2);
241        t.end();
242        assert_eq!(t.cursor_col(), 5);
243    }
244
245    #[test]
246    fn delete_word_back_eats_trailing_spaces_then_word() {
247        let mut t = TextInput::with_text("foo bar baz");
248        t.delete_word_back();
249        assert_eq!(t.text(), "foo bar ");
250        t.delete_word_back();
251        assert_eq!(t.text(), "foo ");
252        t.delete_word_back();
253        assert_eq!(t.text(), "");
254        assert_eq!(t.cursor_col(), 0);
255    }
256
257    #[test]
258    fn utf8_cursor_stays_on_char_boundaries() {
259        let mut t = TextInput::new();
260        t.insert('é');
261        t.insert('🦀');
262        assert_eq!(t.text(), "é🦀");
263        assert_eq!(t.cursor_col(), 2);
264        t.backspace(); // remove the crab, not a partial byte
265        assert_eq!(t.text(), "é");
266        assert_eq!(t.cursor_col(), 1);
267        t.left();
268        t.insert('x'); // insert before the é
269        assert_eq!(t.text(), "xé");
270    }
271
272    #[test]
273    fn handle_key_consumes_edits_but_not_enter_or_esc() {
274        let mut t = TextInput::new();
275        assert!(t.handle_key(key(KeyCode::Char('h'))));
276        assert!(t.handle_key(key(KeyCode::Char('i'))));
277        assert_eq!(t.text(), "hi");
278        assert!(t.handle_key(ctrl('w'))); // word-delete
279        assert_eq!(t.text(), "");
280        // Enter / Esc are not editing actions — the prompt handles them.
281        assert!(!t.handle_key(key(KeyCode::Enter)));
282        assert!(!t.handle_key(key(KeyCode::Esc)));
283        // A Ctrl-modified char that isn't a known chord is left alone.
284        assert!(!t.handle_key(ctrl('t')));
285        assert_eq!(t.text(), "");
286    }
287
288    #[test]
289    fn handle_key_ignores_super_modified_chars() {
290        // Super/Cmd-modified chars (e.g. Cmd+V on terminals that report
291        // the modifier) must NOT be typed as text — they belong to the
292        // OS / global dispatcher.
293        let mut t = TextInput::new();
294        let cmd_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::SUPER);
295        assert!(!t.handle_key(cmd_v), "Super+char should not be consumed");
296        assert_eq!(t.text(), "", "Super+char must not be inserted");
297        // Plain char still inserts.
298        assert!(t.handle_key(key(KeyCode::Char('v'))));
299        assert_eq!(t.text(), "v");
300    }
301
302    #[test]
303    fn insert_str_pastes_at_cursor() {
304        let mut t = TextInput::with_text("ad");
305        t.left();
306        t.insert_str("bc");
307        assert_eq!(t.text(), "abcd");
308        assert_eq!(t.cursor_col(), 3);
309    }
310}