Skip to main content

guise/input/
edit.rs

1//! Pure single-line text-editing model: a string plus a char-index cursor,
2//! with the operations a text field needs. No UI — fully unit-testable; the
3//! `TextInput` entity drives it from key events and renders from `split`.
4
5/// An editable line of text with a cursor.
6#[derive(Debug, Clone, Default)]
7pub struct TextEdit {
8    chars: Vec<char>,
9    /// Cursor position as a char index in `0..=chars.len()`.
10    cursor: usize,
11}
12
13impl TextEdit {
14    /// Start editing `text` with the cursor at the end.
15    pub fn new(text: &str) -> Self {
16        let chars: Vec<char> = text.chars().collect();
17        let cursor = chars.len();
18        Self { chars, cursor }
19    }
20
21    pub fn text(&self) -> String {
22        self.chars.iter().collect()
23    }
24
25    pub fn is_empty(&self) -> bool {
26        self.chars.is_empty()
27    }
28
29    pub fn len(&self) -> usize {
30        self.chars.len()
31    }
32
33    /// Insert `s` at the cursor, advancing past it.
34    pub fn insert(&mut self, s: &str) {
35        for c in s.chars() {
36            self.chars.insert(self.cursor, c);
37            self.cursor += 1;
38        }
39    }
40
41    /// Delete the char before the cursor. Returns whether anything changed.
42    pub fn backspace(&mut self) -> bool {
43        if self.cursor == 0 {
44            return false;
45        }
46        self.cursor -= 1;
47        self.chars.remove(self.cursor);
48        true
49    }
50
51    /// Delete the char at the cursor. Returns whether anything changed.
52    pub fn delete(&mut self) -> bool {
53        if self.cursor >= self.chars.len() {
54            return false;
55        }
56        self.chars.remove(self.cursor);
57        true
58    }
59
60    pub fn left(&mut self) {
61        self.cursor = self.cursor.saturating_sub(1);
62    }
63
64    pub fn right(&mut self) {
65        if self.cursor < self.chars.len() {
66            self.cursor += 1;
67        }
68    }
69
70    pub fn home(&mut self) {
71        self.cursor = 0;
72    }
73
74    pub fn end(&mut self) {
75        self.cursor = self.chars.len();
76    }
77
78    /// Move left to the start of the previous word (Option+Left on macOS).
79    pub fn word_left(&mut self) {
80        while self.cursor > 0 && !is_word(self.chars[self.cursor - 1]) {
81            self.cursor -= 1;
82        }
83        while self.cursor > 0 && is_word(self.chars[self.cursor - 1]) {
84            self.cursor -= 1;
85        }
86    }
87
88    /// Move right past the end of the next word (Option+Right on macOS).
89    pub fn word_right(&mut self) {
90        let n = self.chars.len();
91        while self.cursor < n && !is_word(self.chars[self.cursor]) {
92            self.cursor += 1;
93        }
94        while self.cursor < n && is_word(self.chars[self.cursor]) {
95            self.cursor += 1;
96        }
97    }
98
99    /// Delete the word before the cursor (Option+Backspace). Returns whether
100    /// anything changed.
101    pub fn delete_word_back(&mut self) -> bool {
102        let end = self.cursor;
103        self.word_left();
104        if self.cursor < end {
105            self.chars.drain(self.cursor..end);
106            true
107        } else {
108            false
109        }
110    }
111
112    /// Delete the word after the cursor (Option+Delete). Returns whether
113    /// anything changed.
114    pub fn delete_word_forward(&mut self) -> bool {
115        let start = self.cursor;
116        let n = self.chars.len();
117        let mut end = self.cursor;
118        while end < n && !is_word(self.chars[end]) {
119            end += 1;
120        }
121        while end < n && is_word(self.chars[end]) {
122            end += 1;
123        }
124        if end > start {
125            self.chars.drain(start..end);
126            true
127        } else {
128            false
129        }
130    }
131
132    /// Delete from the cursor to the line start (Cmd+Backspace). Returns
133    /// whether anything changed.
134    pub fn delete_to_start(&mut self) -> bool {
135        if self.cursor == 0 {
136            return false;
137        }
138        self.chars.drain(0..self.cursor);
139        self.cursor = 0;
140        true
141    }
142
143    /// Delete from the cursor to the line end (Cmd+Delete / Ctrl+K). Returns
144    /// whether anything changed.
145    pub fn delete_to_end(&mut self) -> bool {
146        if self.cursor >= self.chars.len() {
147            return false;
148        }
149        self.chars.truncate(self.cursor);
150        true
151    }
152
153    /// The text before and after the cursor, for rendering a caret between.
154    pub fn split(&self) -> (String, String) {
155        (
156            self.chars[..self.cursor].iter().collect(),
157            self.chars[self.cursor..].iter().collect(),
158        )
159    }
160
161    /// Move the cursor up one line, keeping the column where possible. Multiline
162    /// only (single-line text has nowhere to go).
163    pub fn up(&mut self) {
164        self.vmove(-1);
165    }
166
167    /// Move the cursor down one line, keeping the column where possible.
168    pub fn down(&mut self) {
169        self.vmove(1);
170    }
171
172    /// (line, column) of the cursor, counting `\n`-separated lines.
173    fn line_col(&self) -> (usize, usize) {
174        let mut line = 0;
175        let mut col = 0;
176        for &c in &self.chars[..self.cursor] {
177            if c == '\n' {
178                line += 1;
179                col = 0;
180            } else {
181                col += 1;
182            }
183        }
184        (line, col)
185    }
186
187    /// (start char index, length excluding newline) for each line.
188    fn line_bounds(&self) -> Vec<(usize, usize)> {
189        let mut out = Vec::new();
190        let mut start = 0;
191        let mut len = 0;
192        for (i, &c) in self.chars.iter().enumerate() {
193            if c == '\n' {
194                out.push((start, len));
195                start = i + 1;
196                len = 0;
197            } else {
198                len += 1;
199            }
200        }
201        out.push((start, len));
202        out
203    }
204
205    fn vmove(&mut self, dir: isize) {
206        let (line, col) = self.line_col();
207        let bounds = self.line_bounds();
208        let target = line as isize + dir;
209        if target < 0 || target as usize >= bounds.len() {
210            return;
211        }
212        let (start, len) = bounds[target as usize];
213        self.cursor = start + col.min(len);
214    }
215}
216
217/// Word characters for word-wise navigation/deletion.
218fn is_word(c: char) -> bool {
219    c.is_alphanumeric() || c == '_'
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    #[test]
227    fn new_places_cursor_at_end() {
228        let e = TextEdit::new("abc");
229        assert_eq!(e.split(), ("abc".into(), "".into()));
230    }
231
232    #[test]
233    fn insert_at_cursor() {
234        let mut e = TextEdit::new("ac");
235        e.left();
236        e.insert("b");
237        assert_eq!(e.text(), "abc");
238        assert_eq!(e.split(), ("ab".into(), "c".into()));
239    }
240
241    #[test]
242    fn backspace_and_delete() {
243        let mut e = TextEdit::new("abc");
244        assert!(e.backspace());
245        assert_eq!(e.text(), "ab");
246        e.home();
247        assert!(e.delete());
248        assert_eq!(e.text(), "b");
249        e.home();
250        assert!(!e.backspace());
251        e.end();
252        assert!(!e.delete());
253    }
254
255    #[test]
256    fn handles_unicode() {
257        let mut e = TextEdit::new("café");
258        assert!(e.backspace());
259        assert_eq!(e.text(), "caf");
260        e.insert("é");
261        assert_eq!(e.text(), "café");
262    }
263
264    #[test]
265    fn vertical_movement_keeps_column() {
266        // Two lines: "hello" / "hi". Cursor starts at end ("hi").
267        let mut e = TextEdit::new("hello\nhi");
268        // Column 2 on line 1.
269        e.up();
270        // Same column (2) on line 0 → between "he" and "llo".
271        assert_eq!(e.split().0, "he");
272        e.down();
273        // Back to line 1; column clamped to its length (2) → end.
274        assert_eq!(e.split(), ("hello\nhi".into(), "".into()));
275    }
276
277    #[test]
278    fn vertical_movement_stops_at_edges() {
279        let mut e = TextEdit::new("a\nb");
280        e.home(); // line 1 has only the final char; home goes to absolute start
281        e.up(); // already on first line, no-op
282        assert_eq!(e.split().0, "");
283    }
284
285    #[test]
286    fn word_navigation() {
287        let mut e = TextEdit::new("foo bar baz");
288        e.word_left();
289        assert_eq!(e.split(), ("foo bar ".into(), "baz".into()));
290        e.word_left();
291        assert_eq!(e.split(), ("foo ".into(), "bar baz".into()));
292        e.word_right();
293        assert_eq!(e.split(), ("foo bar".into(), " baz".into()));
294    }
295
296    #[test]
297    fn delete_word_back_and_forward() {
298        let mut e = TextEdit::new("foo bar baz");
299        assert!(e.delete_word_back());
300        assert_eq!(e.text(), "foo bar ");
301        e.home();
302        assert!(e.delete_word_forward());
303        assert_eq!(e.text(), " bar ");
304        // Nothing before the cursor at home: no-op.
305        e.home();
306        assert!(!e.delete_word_back());
307    }
308
309    #[test]
310    fn delete_to_line_edges() {
311        let mut e = TextEdit::new("hello world");
312        e.home();
313        e.right();
314        e.right();
315        assert!(e.delete_to_start());
316        assert_eq!(e.text(), "llo world");
317        assert!(e.delete_to_end());
318        assert_eq!(e.text(), "");
319        assert!(!e.delete_to_end());
320    }
321}