ailloli_ui_text/
buffer.rs

1//! Rope-backed text buffer with per-paragraph revision tracking.
2//!
3//! - `ropey` enables O(log n) edits and fast paragraph splitting.
4//! - Each paragraph (separated by `\n`) has its own `revision`; edits bump only
5//!   paragraphs intersecting the changed byte range.
6//! - Layout caches can invalidate per paragraph instead of flushing entirely.
7
8use core::ops::Range;
9
10use ropey::Rope;
11
12/// Metadata for one paragraph: UTF-8 byte range and revision counter.
13///
14/// Paragraph ranges are half-open. A terminating newline belongs to the
15/// paragraph before it. A nonempty buffer ending in `\n` has no extra empty
16/// paragraph, while an entirely empty buffer has one `0..0` paragraph.
17///
18/// # Examples
19///
20/// ```
21/// use ailloli_ui_text::ParagraphMeta;
22/// let meta = ParagraphMeta { byte_range: 2..8, revision: 3 };
23/// assert_eq!(meta.byte_range, 2..8);
24/// assert_eq!(meta.revision, 3);
25/// ```
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct ParagraphMeta {
28    /// Half-open UTF-8 byte range in the complete buffer.
29    pub byte_range: Range<usize>,
30    /// Wrapping revision assigned when this indexed paragraph was rebuilt.
31    pub revision: u64,
32}
33
34/// Mutable text storage with a paragraph index (logical line = `\n`-separated).
35///
36/// Text is stored in a [`Rope`], while paragraph metadata is rebuilt after each
37/// edit. Public offsets are UTF-8 bytes, not character or grapheme indices.
38/// Cloning copies the current rope handle and metadata as independent mutable
39/// values.
40///
41/// # Examples
42///
43/// ```
44/// use ailloli_ui_text::TextBuffer;
45/// let buffer = TextBuffer::from_string("first\nsecond");
46/// assert_eq!(buffer.len_bytes(), 12);
47/// assert_eq!(buffer.paragraphs().len(), 2);
48/// ```
49#[derive(Debug, Clone)]
50pub struct TextBuffer {
51    /// Ropey storage indexed by UTF-8 byte and Unicode scalar positions.
52    rope: Rope,
53    /// Global wrapping edit counter.
54    revision: u64,
55    /// Current newline-delimited paragraph index.
56    paragraphs: Vec<ParagraphMeta>,
57}
58
59impl Default for TextBuffer {
60    fn default() -> Self {
61        Self::new()
62    }
63}
64
65impl TextBuffer {
66    /// Creates an empty buffer at revision zero.
67    ///
68    /// The paragraph index contains one empty `0..0` entry so methods such as
69    /// [`Self::paragraph_at`] always have a paragraph to return.
70    ///
71    /// # Examples
72    ///
73    /// ```
74    /// use ailloli_ui_text::TextBuffer;
75    /// let buffer = TextBuffer::new();
76    /// assert_eq!(buffer.revision(), 0);
77    /// assert_eq!(buffer.paragraphs()[0].byte_range, 0..0);
78    /// ```
79    pub fn new() -> Self {
80        Self::from_string(String::new())
81    }
82
83    /// Creates a revision-zero buffer from owned or borrowed UTF-8 text.
84    ///
85    /// Newline bytes terminate and belong to their preceding paragraph. A
86    /// trailing newline does not create an additional empty paragraph.
87    ///
88    /// # Examples
89    ///
90    /// ```
91    /// use ailloli_ui_text::TextBuffer;
92    /// let buffer = TextBuffer::from_string("a\nb\n");
93    /// assert_eq!(buffer.paragraphs().len(), 2);
94    /// assert_eq!(buffer.paragraph_text(0).as_deref(), Some("a\n"));
95    /// ```
96    pub fn from_string(s: impl Into<String>) -> Self {
97        let s = s.into();
98        let rope = Rope::from_str(&s);
99        let mut me = Self {
100            rope,
101            revision: 0,
102            paragraphs: Vec::new(),
103        };
104        me.rebuild_paragraphs();
105        me
106    }
107
108    /// Returns the global wrapping edit counter.
109    ///
110    /// Every call to [`Self::edit`] increments the counter, including edits that
111    /// leave text unchanged. It starts at zero and wraps from `u64::MAX` to zero.
112    ///
113    /// # Examples
114    ///
115    /// ```
116    /// use ailloli_ui_text::TextBuffer;
117    /// let mut buffer = TextBuffer::new();
118    /// buffer.edit(0..0, "");
119    /// assert_eq!(buffer.revision(), 1);
120    /// ```
121    pub fn revision(&self) -> u64 {
122        self.revision
123    }
124
125    /// Returns the current UTF-8 byte length.
126    ///
127    /// This differs from Unicode scalar and grapheme counts for non-ASCII text.
128    ///
129    /// # Examples
130    ///
131    /// ```
132    /// use ailloli_ui_text::TextBuffer;
133    /// assert_eq!(TextBuffer::from_string("é").len_bytes(), 2);
134    /// ```
135    pub fn len_bytes(&self) -> usize {
136        self.rope.len_bytes()
137    }
138
139    /// Copies the entire rope into a contiguous [`String`].
140    ///
141    /// Despite the method name, the result is owned because a Rope is not
142    /// necessarily contiguous. This operation is O(N) in the text length.
143    ///
144    /// # Examples
145    ///
146    /// ```
147    /// use ailloli_ui_text::TextBuffer;
148    /// let text: String = TextBuffer::from_string("hello").as_str();
149    /// assert_eq!(text, "hello");
150    /// ```
151    pub fn as_str(&self) -> String {
152        self.rope.to_string()
153    }
154
155    /// Borrows current paragraph metadata in document order.
156    ///
157    /// The slice is never empty for buffers built through public constructors.
158    /// It is invalidated by the next mutable edit.
159    ///
160    /// # Examples
161    ///
162    /// ```
163    /// use ailloli_ui_text::TextBuffer;
164    /// let buffer = TextBuffer::from_string("a\nb");
165    /// assert_eq!(buffer.paragraphs()[0].byte_range, 0..2);
166    /// assert_eq!(buffer.paragraphs()[1].byte_range, 2..3);
167    /// ```
168    pub fn paragraphs(&self) -> &[ParagraphMeta] {
169        &self.paragraphs
170    }
171
172    /// Copies one indexed paragraph into a [`String`].
173    ///
174    /// The returned text includes its terminating newline when present.
175    /// `None` means `idx` is outside [`Self::paragraphs`].
176    ///
177    /// # Examples
178    ///
179    /// ```
180    /// use ailloli_ui_text::TextBuffer;
181    /// let buffer = TextBuffer::from_string("a\nb");
182    /// assert_eq!(buffer.paragraph_text(0).as_deref(), Some("a\n"));
183    /// assert_eq!(buffer.paragraph_text(2), None);
184    /// ```
185    pub fn paragraph_text(&self, idx: usize) -> Option<String> {
186        let p = self.paragraphs.get(idx)?;
187        let slice = self.rope.byte_slice(p.byte_range.start..p.byte_range.end);
188        Some(slice.to_string())
189    }
190
191    /// Returns one paragraph's revision, or `None` for an invalid index.
192    ///
193    /// Paragraph revisions initially equal zero. After an edit they identify
194    /// index entries considered touched or whose byte range changed; they are
195    /// not guaranteed to advance for a global no-op.
196    ///
197    /// # Examples
198    ///
199    /// ```
200    /// use ailloli_ui_text::TextBuffer;
201    /// let buffer = TextBuffer::from_string("a\nb");
202    /// assert_eq!(buffer.revision_of_paragraph(0), Some(0));
203    /// assert_eq!(buffer.revision_of_paragraph(9), None);
204    /// ```
205    pub fn revision_of_paragraph(&self, idx: usize) -> Option<u64> {
206        self.paragraphs.get(idx).map(|p| p.revision)
207    }
208
209    /// Index of the paragraph containing byte `b` (clamped to the last paragraph).
210    ///
211    /// Newline bytes belong to the paragraph they terminate. An index at or
212    /// beyond `len_bytes()` maps to the last paragraph. An empty buffer returns
213    /// zero for every input.
214    ///
215    /// # Examples
216    ///
217    /// ```
218    /// use ailloli_ui_text::TextBuffer;
219    /// let buffer = TextBuffer::from_string("a\nb");
220    /// assert_eq!(buffer.paragraph_at(1), 0); // newline
221    /// assert_eq!(buffer.paragraph_at(2), 1);
222    /// assert_eq!(buffer.paragraph_at(usize::MAX), 1);
223    /// ```
224    pub fn paragraph_at(&self, b: usize) -> usize {
225        match self.paragraphs.binary_search_by(|p| {
226            if p.byte_range.contains(&b) {
227                core::cmp::Ordering::Equal
228            } else if b < p.byte_range.start {
229                core::cmp::Ordering::Greater
230            } else {
231                core::cmp::Ordering::Less
232            }
233        }) {
234            Ok(i) => i,
235            Err(i) => i.min(self.paragraphs.len().saturating_sub(1)),
236        }
237    }
238
239    /// Replaces `range` (UTF-8 bytes) with `new_text`. Bumps global and touched paragraph revisions.
240    ///
241    /// Each endpoint is first clamped to `len_bytes()`. If the range is
242    /// reversed, its start is lowered to the clamped end, producing an insertion
243    /// rather than swapping endpoints. Ropey maps an endpoint inside a multibyte
244    /// scalar to that scalar's character index; callers should therefore pass
245    /// valid UTF-8 boundaries when exact replacement is required. The global
246    /// revision wraps on overflow and advances even for a no-op.
247    ///
248    /// Paragraph metadata is rebuilt in O(N) text time. Entries intersecting the
249    /// old nonempty range or whose same-index byte range changed receive the new
250    /// revision; index shifts can consequently invalidate later paragraphs.
251    ///
252    /// # Examples
253    ///
254    /// ```
255    /// use ailloli_ui_text::TextBuffer;
256    /// let mut buffer = TextBuffer::from_string("abc");
257    /// buffer.edit(1..2, "X");
258    /// assert_eq!(buffer.as_str(), "aXc");
259    /// assert_eq!(buffer.revision(), 1);
260    /// ```
261    pub fn edit(&mut self, range: Range<usize>, new_text: &str) {
262        let start = range.start.min(self.rope.len_bytes());
263        let end = range.end.min(self.rope.len_bytes());
264        let start = start.min(end);
265
266        let touched = self.paragraphs_in_byte_range(start..end);
267        let start_char = self.rope.byte_to_char(start);
268        let end_char = self.rope.byte_to_char(end);
269
270        if end_char > start_char {
271            self.rope.remove(start_char..end_char);
272        }
273        if !new_text.is_empty() {
274            self.rope.insert(start_char, new_text);
275        }
276
277        self.revision = self.revision.wrapping_add(1);
278        self.rebuild_paragraphs_after_edit(touched);
279    }
280
281    /// Returns paragraph indices whose half-open byte ranges intersect `byte_range`.
282    ///
283    /// The input is not clamped or reordered. Empty or reversed ranges return an
284    /// empty vector. Because a newline belongs to its preceding paragraph, a
285    /// range containing only that newline intersects that preceding paragraph.
286    ///
287    /// # Examples
288    ///
289    /// ```
290    /// use ailloli_ui_text::TextBuffer;
291    /// let buffer = TextBuffer::from_string("aa\nbb\ncc");
292    /// assert_eq!(buffer.paragraphs_in_byte_range(2..6), [0, 1]);
293    /// assert!(buffer.paragraphs_in_byte_range(3..3).is_empty());
294    /// ```
295    pub fn paragraphs_in_byte_range(&self, byte_range: Range<usize>) -> Vec<usize> {
296        if self.paragraphs.is_empty() {
297            return Vec::new();
298        }
299        let mut out = Vec::new();
300        for (i, p) in self.paragraphs.iter().enumerate() {
301            if p.byte_range.end <= byte_range.start {
302                continue;
303            }
304            if p.byte_range.start >= byte_range.end {
305                break;
306            }
307            out.push(i);
308        }
309        out
310    }
311
312    /// Rebuilds all paragraph ranges at the current global revision.
313    fn rebuild_paragraphs(&mut self) {
314        let revision = self.revision;
315        self.paragraphs.clear();
316        let total = self.rope.len_bytes();
317        if total == 0 {
318            self.paragraphs.push(ParagraphMeta {
319                byte_range: 0..0,
320                revision,
321            });
322            return;
323        }
324        let s = self.rope.to_string();
325        let mut start = 0;
326        for (i, ch) in s.char_indices() {
327            if ch == '\n' {
328                let end = i + ch.len_utf8();
329                self.paragraphs.push(ParagraphMeta {
330                    byte_range: start..end,
331                    revision,
332                });
333                start = end;
334            }
335        }
336        if start < total {
337            self.paragraphs.push(ParagraphMeta {
338                byte_range: start..total,
339                revision,
340            });
341        }
342    }
343
344    /// Rebuilds metadata and preserves revisions for same-index untouched ranges.
345    fn rebuild_paragraphs_after_edit(&mut self, touched_before: Vec<usize>) {
346        let before = std::mem::take(&mut self.paragraphs);
347        self.rebuild_paragraphs();
348        let rev = self.revision;
349        for (i, p) in self.paragraphs.iter_mut().enumerate() {
350            let touched = touched_before.contains(&i)
351                || before
352                    .get(i)
353                    .is_none_or(|prev| prev.byte_range != p.byte_range);
354            if touched {
355                p.revision = rev;
356            } else {
357                p.revision = before[i].revision;
358            }
359        }
360    }
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366
367    #[test]
368    fn from_string_splits_paragraphs() {
369        let b = TextBuffer::from_string("hello\nworld\n!");
370        assert_eq!(b.paragraphs().len(), 3);
371        assert_eq!(b.paragraph_text(0).as_deref(), Some("hello\n"));
372        assert_eq!(b.paragraph_text(1).as_deref(), Some("world\n"));
373        assert_eq!(b.paragraph_text(2).as_deref(), Some("!"));
374    }
375
376    #[test]
377    fn edit_bumps_revision_and_touches_only_affected_paragraphs() {
378        let mut b = TextBuffer::from_string("para_one\npara_two\npara_three\n");
379        let rev0 = b.revision();
380        let revs_before: Vec<u64> = b.paragraphs().iter().map(|p| p.revision).collect();
381
382        // Edit dans le paragraphe #1 (para_two).
383        let p1 = b.paragraphs()[1].byte_range.clone();
384        let mid = p1.start + 2;
385        b.edit(mid..mid, "X");
386
387        assert!(b.revision() > rev0);
388        let revs_after: Vec<u64> = b.paragraphs().iter().map(|p| p.revision).collect();
389        // Paragraph 0 revision must be unchanged.
390        assert_eq!(revs_before[0], revs_after[0]);
391        // Le paragraphe 1 doit avoir une nouvelle revision.
392        assert_ne!(revs_before[1], revs_after[1]);
393    }
394
395    #[test]
396    fn paragraph_at_returns_index_of_containing_paragraph() {
397        let b = TextBuffer::from_string("aaaa\nbbbb\ncccc");
398        assert_eq!(b.paragraph_at(0), 0);
399        assert_eq!(b.paragraph_at(4), 0);
400        assert_eq!(b.paragraph_at(5), 1);
401        assert_eq!(b.paragraph_at(9), 1);
402        assert_eq!(b.paragraph_at(11), 2);
403    }
404
405    #[test]
406    fn paragraphs_in_byte_range_handles_cross_boundary() {
407        let b = TextBuffer::from_string("aa\nbb\ncc");
408        // "aa\n" = 0..3, "bb\n" = 3..6, "cc" = 6..8.
409        let inter = b.paragraphs_in_byte_range(2..6);
410        assert_eq!(inter, vec![0, 1]);
411        let inter_all = b.paragraphs_in_byte_range(0..8);
412        assert_eq!(inter_all, vec![0, 1, 2]);
413    }
414}