Skip to main content

rgx/input/
editor.rs

1use unicode_width::UnicodeWidthStr;
2
3#[derive(Debug, Clone)]
4pub struct Editor {
5    content: String,
6    cursor: usize,
7    scroll_offset: usize,
8    vertical_scroll: usize,
9}
10
11impl Editor {
12    pub fn new() -> Self {
13        Self {
14            content: String::new(),
15            cursor: 0,
16            scroll_offset: 0,
17            vertical_scroll: 0,
18        }
19    }
20
21    pub fn with_content(content: String) -> Self {
22        let cursor = content.len();
23        Self {
24            content,
25            cursor,
26            scroll_offset: 0,
27            vertical_scroll: 0,
28        }
29    }
30
31    pub fn content(&self) -> &str {
32        &self.content
33    }
34
35    pub fn cursor(&self) -> usize {
36        self.cursor
37    }
38
39    pub fn scroll_offset(&self) -> usize {
40        self.scroll_offset
41    }
42
43    pub fn vertical_scroll(&self) -> usize {
44        self.vertical_scroll
45    }
46
47    /// Returns (line, col) of the cursor where col is the display width within the line.
48    pub fn cursor_line_col(&self) -> (usize, usize) {
49        let before = &self.content[..self.cursor];
50        let line = before.matches('\n').count();
51        let line_start = before.rfind('\n').map(|p| p + 1).unwrap_or(0);
52        let col = UnicodeWidthStr::width(&self.content[line_start..self.cursor]);
53        (line, col)
54    }
55
56    pub fn line_count(&self) -> usize {
57        self.content.matches('\n').count() + 1
58    }
59
60    /// Byte offset of the start of line `n` (0-indexed).
61    fn line_start(&self, n: usize) -> usize {
62        if n == 0 {
63            return 0;
64        }
65        let mut count = 0;
66        for (i, c) in self.content.char_indices() {
67            if c == '\n' {
68                count += 1;
69                if count == n {
70                    return i + 1;
71                }
72            }
73        }
74        self.content.len()
75    }
76
77    /// Byte offset of the end of line `n` (before the newline, or end of string).
78    fn line_end(&self, n: usize) -> usize {
79        let start = self.line_start(n);
80        match self.content[start..].find('\n') {
81            Some(pos) => start + pos,
82            None => self.content.len(),
83        }
84    }
85
86    /// Content of line `n`.
87    fn line_content(&self, n: usize) -> &str {
88        &self.content[self.line_start(n)..self.line_end(n)]
89    }
90
91    /// Visual cursor column within the current line.
92    pub fn visual_cursor(&self) -> usize {
93        let (_, col) = self.cursor_line_col();
94        col.saturating_sub(self.scroll_offset)
95    }
96
97    pub fn insert_char(&mut self, c: char) {
98        self.content.insert(self.cursor, c);
99        self.cursor += c.len_utf8();
100    }
101
102    pub fn insert_newline(&mut self) {
103        self.content.insert(self.cursor, '\n');
104        self.cursor += 1;
105    }
106
107    pub fn delete_back(&mut self) {
108        if self.cursor > 0 {
109            let prev = self.prev_char_boundary();
110            self.content.drain(prev..self.cursor);
111            self.cursor = prev;
112        }
113    }
114
115    pub fn delete_forward(&mut self) {
116        if self.cursor < self.content.len() {
117            let next = self.next_char_boundary();
118            self.content.drain(self.cursor..next);
119        }
120    }
121
122    pub fn move_left(&mut self) {
123        if self.cursor > 0 {
124            self.cursor = self.prev_char_boundary();
125        }
126    }
127
128    pub fn move_right(&mut self) {
129        if self.cursor < self.content.len() {
130            self.cursor = self.next_char_boundary();
131        }
132    }
133
134    pub fn move_up(&mut self) {
135        let (line, col) = self.cursor_line_col();
136        if line > 0 {
137            let target_line = line - 1;
138            let target_start = self.line_start(target_line);
139            let target_content = self.line_content(target_line);
140            self.cursor = target_start + byte_offset_at_width(target_content, col);
141        }
142    }
143
144    pub fn move_down(&mut self) {
145        let (line, col) = self.cursor_line_col();
146        if line + 1 < self.line_count() {
147            let target_line = line + 1;
148            let target_start = self.line_start(target_line);
149            let target_content = self.line_content(target_line);
150            self.cursor = target_start + byte_offset_at_width(target_content, col);
151        }
152    }
153
154    /// Move to start of current line.
155    pub fn move_home(&mut self) {
156        let (line, _) = self.cursor_line_col();
157        self.cursor = self.line_start(line);
158        self.scroll_offset = 0;
159    }
160
161    /// Move to end of current line.
162    pub fn move_end(&mut self) {
163        let (line, _) = self.cursor_line_col();
164        self.cursor = self.line_end(line);
165    }
166
167    /// Update horizontal scroll for the current line.
168    pub fn update_scroll(&mut self, visible_width: usize) {
169        let (_, col) = self.cursor_line_col();
170        if col < self.scroll_offset {
171            self.scroll_offset = col;
172        } else if col >= self.scroll_offset + visible_width {
173            self.scroll_offset = col - visible_width + 1;
174        }
175    }
176
177    /// Update vertical scroll to keep cursor visible within `visible_height` lines.
178    pub fn update_vertical_scroll(&mut self, visible_height: usize) {
179        let (line, _) = self.cursor_line_col();
180        if line < self.vertical_scroll {
181            self.vertical_scroll = line;
182        } else if line >= self.vertical_scroll + visible_height {
183            self.vertical_scroll = line - visible_height + 1;
184        }
185    }
186
187    fn prev_char_boundary(&self) -> usize {
188        let mut pos = self.cursor - 1;
189        while !self.content.is_char_boundary(pos) {
190            pos -= 1;
191        }
192        pos
193    }
194
195    fn next_char_boundary(&self) -> usize {
196        let mut pos = self.cursor + 1;
197        while pos < self.content.len() && !self.content.is_char_boundary(pos) {
198            pos += 1;
199        }
200        pos
201    }
202}
203
204/// Convert a target display column width to a byte offset within a line string.
205fn byte_offset_at_width(line: &str, target_width: usize) -> usize {
206    let mut width = 0;
207    for (i, c) in line.char_indices() {
208        if width >= target_width {
209            return i;
210        }
211        width += unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);
212    }
213    line.len()
214}
215
216impl Default for Editor {
217    fn default() -> Self {
218        Self::new()
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    #[test]
227    fn test_insert_and_content() {
228        let mut editor = Editor::new();
229        editor.insert_char('h');
230        editor.insert_char('i');
231        assert_eq!(editor.content(), "hi");
232        assert_eq!(editor.cursor(), 2);
233    }
234
235    #[test]
236    fn test_delete_back() {
237        let mut editor = Editor::with_content("hello".to_string());
238        editor.delete_back();
239        assert_eq!(editor.content(), "hell");
240    }
241
242    #[test]
243    fn test_cursor_movement() {
244        let mut editor = Editor::with_content("hello".to_string());
245        editor.move_left();
246        assert_eq!(editor.cursor(), 4);
247        editor.move_home();
248        assert_eq!(editor.cursor(), 0);
249        editor.move_end();
250        assert_eq!(editor.cursor(), 5);
251    }
252
253    #[test]
254    fn test_insert_newline() {
255        let mut editor = Editor::new();
256        editor.insert_char('a');
257        editor.insert_newline();
258        editor.insert_char('b');
259        assert_eq!(editor.content(), "a\nb");
260        assert_eq!(editor.cursor(), 3);
261    }
262
263    #[test]
264    fn test_cursor_line_col() {
265        let editor = Editor::with_content("abc\ndef\nghi".to_string());
266        // cursor is at end: line 2, col 3
267        assert_eq!(editor.cursor_line_col(), (2, 3));
268    }
269
270    #[test]
271    fn test_move_up_down() {
272        let mut editor = Editor::with_content("abc\ndef\nghi".to_string());
273        // cursor at end of "ghi" (line 2, col 3)
274        editor.move_up();
275        assert_eq!(editor.cursor_line_col(), (1, 3));
276        assert_eq!(&editor.content()[..editor.cursor()], "abc\ndef");
277        editor.move_up();
278        assert_eq!(editor.cursor_line_col(), (0, 3));
279        assert_eq!(&editor.content()[..editor.cursor()], "abc");
280        // move_up at top does nothing
281        editor.move_up();
282        assert_eq!(editor.cursor_line_col(), (0, 3));
283        // move back down
284        editor.move_down();
285        assert_eq!(editor.cursor_line_col(), (1, 3));
286    }
287
288    #[test]
289    fn test_move_up_clamps_column() {
290        let mut editor = Editor::with_content("abcdef\nab\nxyz".to_string());
291        // cursor at end: line 2, col 3
292        editor.move_up();
293        // line 1 is "ab" (col 2) — should clamp to end of line
294        assert_eq!(editor.cursor_line_col(), (1, 2));
295        editor.move_up();
296        // line 0 is "abcdef" — col 2
297        assert_eq!(editor.cursor_line_col(), (0, 2));
298    }
299
300    #[test]
301    fn test_line_helpers() {
302        let editor = Editor::with_content("abc\ndef\nghi".to_string());
303        assert_eq!(editor.line_count(), 3);
304        assert_eq!(editor.line_content(0), "abc");
305        assert_eq!(editor.line_content(1), "def");
306        assert_eq!(editor.line_content(2), "ghi");
307    }
308
309    #[test]
310    fn test_home_end_multiline() {
311        let mut editor = Editor::with_content("abc\ndef".to_string());
312        // cursor at end of "def" (line 1)
313        editor.move_home();
314        // should go to start of line 1
315        assert_eq!(editor.cursor(), 4); // "abc\n" = 4 bytes
316        assert_eq!(editor.cursor_line_col(), (1, 0));
317        editor.move_end();
318        assert_eq!(editor.cursor(), 7); // "abc\ndef" = 7 bytes
319        assert_eq!(editor.cursor_line_col(), (1, 3));
320    }
321
322    #[test]
323    fn test_vertical_scroll() {
324        let mut editor = Editor::with_content("a\nb\nc\nd\ne".to_string());
325        editor.update_vertical_scroll(3);
326        // cursor at line 4, visible_height 3 => scroll to 2
327        assert_eq!(editor.vertical_scroll(), 2);
328    }
329
330    #[test]
331    fn test_delete_back_across_newline() {
332        let mut editor = Editor::with_content("abc\ndef".to_string());
333        // cursor at start of "def" (byte 4)
334        editor.cursor = 4;
335        editor.delete_back();
336        assert_eq!(editor.content(), "abcdef");
337        assert_eq!(editor.cursor(), 3);
338    }
339}