Skip to main content

binocular/preview/rich_text/
buffer.rs

1use std::ops::Range;
2
3#[derive(Debug, Clone, PartialEq, Eq)]
4pub struct TextBuffer {
5    content: String,
6    line_ranges: Vec<(usize, usize)>,
7}
8
9impl TextBuffer {
10    pub fn new(content: String) -> Self {
11        let line_ranges = build_line_ranges(&content);
12        Self {
13            content,
14            line_ranges,
15        }
16    }
17
18    pub fn as_str(&self) -> &str {
19        &self.content
20    }
21
22    pub fn len_bytes(&self) -> usize {
23        self.content.len()
24    }
25
26    pub fn line_ranges(&self) -> &[(usize, usize)] {
27        &self.line_ranges
28    }
29
30    pub fn line_range(&self, line_idx: usize) -> Option<(usize, usize)> {
31        self.line_ranges.get(line_idx).copied()
32    }
33
34    pub fn line_count(&self) -> usize {
35        self.line_ranges.len()
36    }
37
38    pub fn line_slice(&self, line_idx: usize) -> Option<&str> {
39        let (start, end) = self.line_range(line_idx)?;
40        Some(&self.content[start..end])
41    }
42
43    pub fn byte_index(&self, line: usize, char_idx: usize) -> usize {
44        let Some((start, end)) = self.line_range(line) else {
45            return self.content.len();
46        };
47
48        let line_text = &self.content[start..end];
49        let mut byte_offset = 0;
50        let mut current_char = 0;
51
52        for (idx, c) in line_text.char_indices() {
53            if current_char == char_idx {
54                return start + idx;
55            }
56            current_char += 1;
57            byte_offset = idx + c.len_utf8();
58        }
59
60        if current_char == char_idx {
61            start + byte_offset
62        } else {
63            end
64        }
65    }
66
67    pub fn line_char_from_byte(&self, byte_idx: usize) -> (usize, usize) {
68        let line_idx = self
69            .line_ranges
70            .iter()
71            .position(|(start, end)| byte_idx >= *start && byte_idx <= *end)
72            .unwrap_or_else(|| self.line_ranges.len().saturating_sub(1));
73
74        let Some((start, end)) = self.line_range(line_idx) else {
75            return (0, 0);
76        };
77        let clamped = byte_idx.clamp(start, end);
78        let char_idx = self.content[start..clamped].chars().count();
79        (line_idx, char_idx)
80    }
81
82    pub fn line_len_chars(&self, line_idx: usize) -> usize {
83        self.line_slice(line_idx)
84            .map(|line| {
85                line.trim_end_matches('\n')
86                    .trim_end_matches('\r')
87                    .chars()
88                    .count()
89            })
90            .unwrap_or(0)
91    }
92
93    pub fn char_at_byte(&self, byte_idx: usize) -> Option<char> {
94        self.content.get(byte_idx..)?.chars().next()
95    }
96
97    pub fn char_before_byte(&self, byte_idx: usize) -> Option<(usize, char)> {
98        self.content.get(..byte_idx)?.char_indices().last()
99    }
100
101    pub fn apply_edit(&mut self, edit: &TextEdit) -> bool {
102        match edit.kind {
103            TextEditKind::Insert => self.insert(edit.byte_idx, &edit.text),
104            TextEditKind::Delete => {
105                let end = edit.byte_idx + edit.text.len();
106                if self.content.get(edit.byte_idx..end) != Some(edit.text.as_str()) {
107                    return false;
108                }
109                self.delete_range(edit.byte_idx..end).is_some()
110            }
111        }
112    }
113
114    pub fn insert_char(&mut self, byte_idx: usize, c: char) -> Option<TextEdit> {
115        let mut text = String::new();
116        text.push(c);
117        self.insert_text(byte_idx, text)
118    }
119
120    pub fn insert_text(&mut self, byte_idx: usize, text: String) -> Option<TextEdit> {
121        if !self.insert(byte_idx, &text) {
122            return None;
123        }
124        Some(TextEdit::insert(byte_idx, text))
125    }
126
127    pub fn delete_char_before(&mut self, byte_idx: usize) -> Option<TextEdit> {
128        let (start, c) = self.char_before_byte(byte_idx)?;
129        let end = start + c.len_utf8();
130        let deleted = self.delete_range(start..end)?;
131        Some(TextEdit::delete(start, deleted))
132    }
133
134    pub fn delete_char_at(&mut self, byte_idx: usize) -> Option<TextEdit> {
135        let c = self.char_at_byte(byte_idx)?;
136        let end = byte_idx + c.len_utf8();
137        let deleted = self.delete_range(byte_idx..end)?;
138        Some(TextEdit::delete(byte_idx, deleted))
139    }
140
141    pub fn delete_range(&mut self, range: Range<usize>) -> Option<String> {
142        if range.start > range.end || range.end > self.content.len() {
143            return None;
144        }
145        let removed = self.content.get(range.clone())?.to_string();
146        self.content.replace_range(range, "");
147        self.rebuild_line_ranges();
148        Some(removed)
149    }
150
151    fn insert(&mut self, byte_idx: usize, text: &str) -> bool {
152        if byte_idx > self.content.len() || !self.content.is_char_boundary(byte_idx) {
153            return false;
154        }
155        self.content.insert_str(byte_idx, text);
156        self.rebuild_line_ranges();
157        true
158    }
159
160    fn rebuild_line_ranges(&mut self) {
161        self.line_ranges = build_line_ranges(&self.content);
162    }
163}
164
165fn build_line_ranges(content: &str) -> Vec<(usize, usize)> {
166    let mut ranges = Vec::new();
167    let mut start = 0;
168
169    for (idx, byte) in content.as_bytes().iter().enumerate() {
170        if *byte == b'\n' {
171            ranges.push((start, idx + 1));
172            start = idx + 1;
173        }
174    }
175
176    if start < content.len() {
177        ranges.push((start, content.len()));
178    } else if start == content.len() && start > 0 {
179        ranges.push((start, start));
180    }
181
182    if ranges.is_empty() {
183        ranges.push((0, 0));
184    }
185
186    ranges
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Eq)]
190pub enum TextEditKind {
191    Insert,
192    Delete,
193}
194
195#[derive(Debug, Clone, PartialEq, Eq)]
196pub struct TextEdit {
197    pub kind: TextEditKind,
198    pub byte_idx: usize,
199    pub text: String,
200}
201
202impl TextEdit {
203    pub fn insert(byte_idx: usize, text: String) -> Self {
204        Self {
205            kind: TextEditKind::Insert,
206            byte_idx,
207            text,
208        }
209    }
210
211    pub fn delete(byte_idx: usize, text: String) -> Self {
212        Self {
213            kind: TextEditKind::Delete,
214            byte_idx,
215            text,
216        }
217    }
218}
219
220#[derive(Debug, Clone, PartialEq, Eq)]
221pub struct TextUndoFrame {
222    pub undo_edit: TextEdit,
223    pub redo_edit: TextEdit,
224    pub before_cursor: (usize, usize),
225    pub after_cursor: (usize, usize),
226}
227
228impl TextUndoFrame {
229    pub fn from_forward_edit(
230        edit: TextEdit,
231        before_cursor: (usize, usize),
232        after_cursor: (usize, usize),
233    ) -> Self {
234        let inverse = match edit.kind {
235            TextEditKind::Insert => TextEdit::delete(edit.byte_idx, edit.text.clone()),
236            TextEditKind::Delete => TextEdit::insert(edit.byte_idx, edit.text.clone()),
237        };
238        Self {
239            undo_edit: inverse,
240            redo_edit: edit,
241            before_cursor,
242            after_cursor,
243        }
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    #[test]
252    fn insert_and_delete_update_line_ranges() {
253        let mut buffer = TextBuffer::new("abc\ndef".to_string());
254        buffer.insert_char(3, '\n');
255        assert_eq!(buffer.line_count(), 3);
256        assert_eq!(buffer.line_slice(1), Some("\n"));
257
258        let deleted = buffer.delete_char_before(4).unwrap();
259        assert_eq!(deleted.text, "\n");
260        assert_eq!(buffer.as_str(), "abc\ndef");
261        assert_eq!(buffer.line_count(), 2);
262    }
263
264    #[test]
265    fn delete_range_returns_removed_text() {
266        let mut buffer = TextBuffer::new("hello world".to_string());
267        let removed = buffer.delete_range(5..11).unwrap();
268        assert_eq!(removed, " world");
269        assert_eq!(buffer.as_str(), "hello");
270    }
271
272    #[test]
273    fn byte_index_uses_line_context() {
274        let buffer = TextBuffer::new("ab\ncd".to_string());
275        assert_eq!(buffer.byte_index(1, 1), 4);
276        assert_eq!(buffer.line_char_from_byte(4), (1, 1));
277    }
278
279    #[test]
280    fn delete_range_handles_multi_line_edits() {
281        let mut buffer = TextBuffer::new("one\ntwo\nthree".to_string());
282        let start = buffer.byte_index(0, 2);
283        let end = buffer.byte_index(1, 2);
284        let removed = buffer.delete_range(start..end).unwrap();
285
286        assert_eq!(removed, "e\ntw");
287        assert_eq!(buffer.as_str(), "ono\nthree");
288        assert_eq!(buffer.line_count(), 2);
289    }
290}