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}