Skip to main content

saorsa_core/
text_buffer.rs

1//! Text buffer with rope-based storage for efficient text editing.
2//!
3//! Wraps [`ropey::Rope`] with a clean API for line-oriented text editing
4//! operations used by the [`crate::widget::TextArea`] widget.
5
6use ropey::Rope;
7use std::fmt;
8
9/// A text buffer backed by a rope data structure for efficient editing.
10///
11/// Provides line-oriented access and editing operations suitable for
12/// building a text editor widget. All positions are expressed as
13/// `(line, col)` pairs using zero-based indexing.
14#[derive(Clone, Debug)]
15pub struct TextBuffer {
16    rope: Rope,
17}
18
19impl TextBuffer {
20    /// Create a new empty text buffer.
21    pub fn new() -> Self {
22        Self { rope: Rope::new() }
23    }
24
25    /// Create a text buffer from a string.
26    pub fn from_text(text: &str) -> Self {
27        Self {
28            rope: Rope::from_str(text),
29        }
30    }
31
32    /// Return the number of lines in the buffer.
33    ///
34    /// An empty buffer has 1 line. A buffer ending with a newline has
35    /// an extra empty line at the end.
36    pub fn line_count(&self) -> usize {
37        self.rope.len_lines()
38    }
39
40    /// Get the content of a line by index (without trailing newline).
41    ///
42    /// Returns `None` if the index is out of bounds.
43    pub fn line(&self, idx: usize) -> Option<String> {
44        if idx >= self.rope.len_lines() {
45            return None;
46        }
47        let line = self.rope.line(idx);
48        let text = line.to_string();
49        // Strip trailing newline characters
50        let trimmed = text.trim_end_matches('\n').trim_end_matches('\r');
51        Some(trimmed.to_string())
52    }
53
54    /// Get the character count of a line (excluding trailing newline).
55    ///
56    /// Returns `None` if the index is out of bounds.
57    pub fn line_len(&self, idx: usize) -> Option<usize> {
58        self.line(idx).map(|l| l.chars().count())
59    }
60
61    /// Return the total number of characters in the buffer.
62    pub fn total_chars(&self) -> usize {
63        self.rope.len_chars()
64    }
65
66    /// Insert a character at the given `(line, col)` position.
67    ///
68    /// If the position is beyond the end of a line, the character is
69    /// appended at the end of that line.
70    pub fn insert_char(&mut self, line: usize, col: usize, ch: char) {
71        if let Some(char_idx) = self.line_col_to_char(line, col) {
72            self.rope.insert_char(char_idx, ch);
73        }
74    }
75
76    /// Insert a string at the given `(line, col)` position.
77    ///
78    /// The string may contain newlines, which will split the line.
79    pub fn insert_str(&mut self, line: usize, col: usize, text: &str) {
80        if let Some(char_idx) = self.line_col_to_char(line, col) {
81            self.rope.insert(char_idx, text);
82        }
83    }
84
85    /// Delete a single character at the given `(line, col)` position.
86    ///
87    /// If the position is at the end of a line, the trailing newline
88    /// is removed, joining this line with the next.
89    pub fn delete_char(&mut self, line: usize, col: usize) {
90        if let Some(char_idx) = self.line_col_to_char(line, col)
91            && char_idx < self.rope.len_chars()
92        {
93            self.rope.remove(char_idx..char_idx + 1);
94        }
95    }
96
97    /// Delete a range of text between two `(line, col)` positions.
98    ///
99    /// The range is from `(start_line, start_col)` inclusive to
100    /// `(end_line, end_col)` exclusive. If start equals end, nothing
101    /// is deleted.
102    pub fn delete_range(
103        &mut self,
104        start_line: usize,
105        start_col: usize,
106        end_line: usize,
107        end_col: usize,
108    ) {
109        let start = self.line_col_to_char(start_line, start_col);
110        let end = self.line_col_to_char(end_line, end_col);
111        if let (Some(s), Some(e)) = (start, end)
112            && s < e
113            && e <= self.rope.len_chars()
114        {
115            self.rope.remove(s..e);
116        }
117    }
118
119    /// Get a range of lines as strings (without trailing newlines).
120    ///
121    /// The range is `[start, end)` (end-exclusive). Returns an empty
122    /// `Vec` if the range is invalid.
123    pub fn lines_range(&self, start: usize, end: usize) -> Vec<String> {
124        let total = self.rope.len_lines();
125        let start = start.min(total);
126        let end = end.min(total);
127        (start..end).filter_map(|i| self.line(i)).collect()
128    }
129
130    /// Convert a `(line, col)` position to a character index into the rope.
131    ///
132    /// Returns `None` if the line is out of bounds. If the column is
133    /// beyond the line length, it is clamped to the end of the line.
134    fn line_col_to_char(&self, line: usize, col: usize) -> Option<usize> {
135        if line >= self.rope.len_lines() {
136            return None;
137        }
138        let line_start = self.rope.line_to_char(line);
139        let line_rope = self.rope.line(line);
140        let line_char_len = line_rope.len_chars();
141        // Clamp col: for a line with trailing newline, allow up to
142        // the content length (before newline) so inserts happen in the
143        // right place. For the last line (no trailing newline) allow up to
144        // line_char_len.
145        let clamped_col = col.min(line_char_len);
146        Some(line_start + clamped_col)
147    }
148}
149
150impl Default for TextBuffer {
151    fn default() -> Self {
152        Self::new()
153    }
154}
155
156impl fmt::Display for TextBuffer {
157    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
158        for chunk in self.rope.chunks() {
159            f.write_str(chunk)?;
160        }
161        Ok(())
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    // --- Construction ---
170
171    #[test]
172    fn empty_buffer() {
173        let buf = TextBuffer::new();
174        assert!(buf.line_count() == 1);
175        assert!(buf.total_chars() == 0);
176        assert!(buf.to_string().is_empty());
177    }
178
179    #[test]
180    fn from_str_single_line() {
181        let buf = TextBuffer::from_text("hello");
182        assert!(buf.line_count() == 1);
183        assert!(buf.total_chars() == 5);
184        match buf.line(0) {
185            Some(ref s) if s == "hello" => {}
186            _ => unreachable!("expected 'hello'"),
187        }
188    }
189
190    #[test]
191    fn from_str_multi_line() {
192        let buf = TextBuffer::from_text("one\ntwo\nthree");
193        assert!(buf.line_count() == 3);
194        match buf.line(0) {
195            Some(ref s) if s == "one" => {}
196            _ => unreachable!("expected 'one'"),
197        }
198        match buf.line(1) {
199            Some(ref s) if s == "two" => {}
200            _ => unreachable!("expected 'two'"),
201        }
202        match buf.line(2) {
203            Some(ref s) if s == "three" => {}
204            _ => unreachable!("expected 'three'"),
205        }
206    }
207
208    // --- Line access ---
209
210    #[test]
211    fn line_out_of_bounds() {
212        let buf = TextBuffer::from_text("abc");
213        assert!(buf.line(1).is_none());
214        assert!(buf.line(100).is_none());
215    }
216
217    #[test]
218    fn line_len_returns_char_count() {
219        let buf = TextBuffer::from_text("hello\nhi");
220        match buf.line_len(0) {
221            Some(5) => {}
222            other => unreachable!("expected Some(5), got {other:?}"),
223        }
224        match buf.line_len(1) {
225            Some(2) => {}
226            other => unreachable!("expected Some(2), got {other:?}"),
227        }
228        assert!(buf.line_len(2).is_none());
229    }
230
231    #[test]
232    fn lines_range_subset() {
233        let buf = TextBuffer::from_text("a\nb\nc\nd");
234        let range = buf.lines_range(1, 3);
235        assert!(range.len() == 2);
236        assert!(range[0] == "b");
237        assert!(range[1] == "c");
238    }
239
240    #[test]
241    fn lines_range_out_of_bounds_clamped() {
242        let buf = TextBuffer::from_text("x\ny");
243        let range = buf.lines_range(0, 100);
244        assert!(range.len() == 2);
245    }
246
247    // --- Insert ---
248
249    #[test]
250    fn insert_char_middle() {
251        let mut buf = TextBuffer::from_text("ac");
252        buf.insert_char(0, 1, 'b');
253        assert!(buf.to_string() == "abc");
254    }
255
256    #[test]
257    fn insert_newline_splits_line() {
258        let mut buf = TextBuffer::from_text("hello world");
259        buf.insert_char(0, 5, '\n');
260        assert!(buf.line_count() == 2);
261        match buf.line(0) {
262            Some(ref s) if s == "hello" => {}
263            other => unreachable!("expected 'hello', got {other:?}"),
264        }
265        match buf.line(1) {
266            Some(ref s) if s == " world" => {}
267            other => unreachable!("expected ' world', got {other:?}"),
268        }
269    }
270
271    #[test]
272    fn insert_str_with_newlines() {
273        let mut buf = TextBuffer::from_text("ac");
274        buf.insert_str(0, 1, "b\nd\ne");
275        // Result: "ab\nd\nec"
276        assert!(buf.line_count() == 3);
277        match buf.line(0) {
278            Some(ref s) if s == "ab" => {}
279            other => unreachable!("expected 'ab', got {other:?}"),
280        }
281    }
282
283    // --- Delete ---
284
285    #[test]
286    fn delete_char_middle() {
287        let mut buf = TextBuffer::from_text("abc");
288        buf.delete_char(0, 1);
289        assert!(buf.to_string() == "ac");
290    }
291
292    #[test]
293    fn delete_char_joins_lines() {
294        let mut buf = TextBuffer::from_text("ab\ncd");
295        // Delete the newline at end of first line (position line 0, col 2)
296        buf.delete_char(0, 2);
297        assert!(buf.line_count() == 1);
298        assert!(buf.to_string() == "abcd");
299    }
300
301    #[test]
302    fn delete_range_within_line() {
303        let mut buf = TextBuffer::from_text("abcdef");
304        buf.delete_range(0, 1, 0, 4);
305        assert!(buf.to_string() == "aef");
306    }
307
308    #[test]
309    fn delete_range_across_lines() {
310        let mut buf = TextBuffer::from_text("hello\nworld\nfoo");
311        // Delete from (0,3) to (1,3) → "hel" + "ld\nfoo" = "helld\nfoo"
312        buf.delete_range(0, 3, 1, 3);
313        assert!(buf.to_string() == "helld\nfoo");
314    }
315
316    // --- Edge cases ---
317
318    #[test]
319    fn empty_lines() {
320        let buf = TextBuffer::from_text("\n\n\n");
321        assert!(buf.line_count() == 4);
322        match buf.line(0) {
323            Some(ref s) if s.is_empty() => {}
324            other => unreachable!("expected empty string, got {other:?}"),
325        }
326    }
327
328    #[test]
329    fn unicode_content() {
330        let buf = TextBuffer::from_text("日本語\némoji 🎉");
331        assert!(buf.line_count() == 2);
332        match buf.line(0) {
333            Some(ref s) if s == "日本語" => {}
334            other => unreachable!("expected '日本語', got {other:?}"),
335        }
336        match buf.line_len(1) {
337            Some(7) => {}
338            other => unreachable!("expected Some(7), got {other:?}"),
339        }
340    }
341
342    #[test]
343    fn display_trait() {
344        let buf = TextBuffer::from_text("hello\nworld");
345        assert!(buf.to_string() == "hello\nworld");
346    }
347
348    #[test]
349    fn default_is_empty() {
350        let buf = TextBuffer::default();
351        assert!(buf.line_count() == 1);
352        assert!(buf.total_chars() == 0);
353    }
354}