Skip to main content

cranpose_foundation/text/
buffer.rs

1use super::TextRange;
2
3/// A mutable text buffer that can be edited.
4///
5/// This provides methods for changing text content:
6/// - [`replace`](Self::replace) - Replace a range with new text
7/// - [`append`](Self::append) - Add text at the end
8/// - [`insert`](Self::insert) - Insert text at cursor position
9/// - [`delete`](Self::delete) - Delete a range of text
10///
11/// And for manipulating cursor/selection:
12/// - [`place_cursor_at_end`](Self::place_cursor_at_end)
13/// - [`place_cursor_before_char`](Self::place_cursor_before_char)
14/// - [`select_all`](Self::select_all)
15///
16/// # Example
17///
18/// ```
19/// use cranpose_foundation::text::{TextFieldBuffer, TextRange};
20///
21/// let mut buffer = TextFieldBuffer::new("Hello");
22/// buffer.place_cursor_at_end();
23/// buffer.insert(", World!");
24/// assert_eq!(buffer.text(), "Hello, World!");
25/// ```
26#[derive(Debug, Clone)]
27pub struct TextFieldBuffer {
28    text: String,
29    selection: TextRange,
30    composition: Option<TextRange>,
31    has_changes: bool,
32}
33
34impl TextFieldBuffer {
35    /// Creates a new buffer with the given initial text.
36    /// Cursor is placed at the end of the text.
37    pub fn new(initial_text: impl Into<String>) -> Self {
38        let text: String = initial_text.into();
39        let len = text.len();
40        Self {
41            text,
42            selection: TextRange::cursor(len),
43            composition: None,
44            has_changes: false,
45        }
46    }
47
48    /// Creates a buffer with text and specified selection.
49    pub fn with_selection(text: impl Into<String>, selection: TextRange) -> Self {
50        let text: String = text.into();
51        let selection = selection.coerce_in(text.len());
52        Self {
53            text,
54            selection,
55            composition: None,
56            has_changes: false,
57        }
58    }
59
60    /// Returns the current text content.
61    pub fn text(&self) -> &str {
62        &self.text
63    }
64
65    /// Returns the length of the text in bytes.
66    pub fn len(&self) -> usize {
67        self.text.len()
68    }
69
70    /// Returns true if the buffer is empty.
71    pub fn is_empty(&self) -> bool {
72        self.text.is_empty()
73    }
74
75    /// Returns the current selection range.
76    pub fn selection(&self) -> TextRange {
77        self.selection
78    }
79
80    /// Returns the current composition (IME) range, if any.
81    pub fn composition(&self) -> Option<TextRange> {
82        self.composition
83    }
84
85    /// Returns true if there's a non-collapsed selection.
86    pub fn has_selection(&self) -> bool {
87        !self.selection.collapsed()
88    }
89
90    /// Returns true if any changes have been made.
91    pub fn has_changes(&self) -> bool {
92        self.has_changes
93    }
94
95    /// Replaces text in the given range with new text.
96    ///
97    /// The selection is adjusted based on the replacement:
98    /// - If replacing before selection, selection shifts
99    /// - If replacing within selection, cursor moves to end of replacement
100    pub fn replace(&mut self, range: TextRange, replacement: &str) {
101        let min = range.min().min(self.text.len());
102        let max = range.max().min(self.text.len());
103
104        self.text.replace_range(min..max, replacement);
105
106        let new_end = min + replacement.len();
107        self.selection = TextRange::cursor(new_end);
108
109        self.composition = None;
110        self.has_changes = true;
111    }
112
113    /// Inserts text at the current cursor position (or replaces selection).
114    pub fn insert(&mut self, text: &str) {
115        if self.has_selection() {
116            self.replace(self.selection, text);
117        } else {
118            let pos = self.selection.start.min(self.text.len());
119            self.text.insert_str(pos, text);
120            self.selection = TextRange::cursor(pos + text.len());
121            self.composition = None;
122            self.has_changes = true;
123        }
124    }
125
126    /// Appends text at the end of the buffer.
127    pub fn append(&mut self, text: &str) {
128        self.text.push_str(text);
129        self.has_changes = true;
130    }
131
132    /// Deletes text in the given range.
133    pub fn delete(&mut self, range: TextRange) {
134        self.replace(range, "");
135    }
136
137    /// Deletes the character before the cursor (backspace).
138    pub fn delete_before_cursor(&mut self) {
139        if self.has_selection() {
140            self.delete(self.selection);
141        } else if self.selection.start > 0 {
142            let pos = self.selection.start;
143            let prev_pos = self.prev_char_boundary(pos);
144            self.delete(TextRange::new(prev_pos, pos));
145        }
146    }
147
148    /// Deletes the character after the cursor (delete key).
149    pub fn delete_after_cursor(&mut self) {
150        if self.has_selection() {
151            self.delete(self.selection);
152        } else if self.selection.start < self.text.len() {
153            let pos = self.selection.start;
154            let next_pos = self.next_char_boundary(pos);
155            self.delete(TextRange::new(pos, next_pos));
156        }
157    }
158
159    /// Deletes text surrounding the cursor or selection.
160    ///
161    /// `before_bytes` and `after_bytes` are byte counts in UTF-8.
162    /// The deletion respects character boundaries and preserves any IME composition range.
163    pub fn delete_surrounding(&mut self, before_bytes: usize, after_bytes: usize) {
164        if self.text.is_empty() || (before_bytes == 0 && after_bytes == 0) {
165            return;
166        }
167
168        let selection = self.selection;
169        let mut start = selection.min().saturating_sub(before_bytes);
170        let mut end = selection
171            .max()
172            .saturating_add(after_bytes)
173            .min(self.text.len());
174
175        start = self.clamp_prev_boundary(start);
176        end = self.clamp_next_boundary(end);
177
178        if start >= end {
179            return;
180        }
181
182        let mut ranges = Vec::new();
183        if let Some(comp) = self.composition {
184            let comp_start = comp.min();
185            let comp_end = comp.max();
186
187            if end <= comp_start || start >= comp_end {
188                ranges.push((start, end));
189            } else {
190                if start < comp_start {
191                    ranges.push((start, comp_start));
192                }
193                if end > comp_end {
194                    ranges.push((comp_end, end));
195                }
196            }
197        } else {
198            ranges.push((start, end));
199        }
200
201        if ranges.is_empty() {
202            return;
203        }
204
205        ranges.sort_by_key(|(range_start, _)| *range_start);
206        let total_removed: usize = ranges.iter().map(|(s, e)| e - s).sum();
207        if total_removed == 0 {
208            return;
209        }
210
211        let original_text = self.text.clone();
212        let mut new_text = String::with_capacity(original_text.len().saturating_sub(total_removed));
213        let mut last = 0usize;
214        for (range_start, range_end) in &ranges {
215            if last < *range_start {
216                new_text.push_str(&original_text[last..*range_start]);
217            }
218            last = *range_end;
219        }
220        new_text.push_str(&original_text[last..]);
221
222        let removed_before = |pos: usize| -> usize {
223            let mut removed = 0usize;
224            for (range_start, range_end) in &ranges {
225                if pos <= *range_start {
226                    break;
227                }
228                let clamped_end = pos.min(*range_end);
229                if clamped_end > *range_start {
230                    removed += clamped_end - *range_start;
231                }
232            }
233            removed
234        };
235
236        let cursor_pos = selection.min();
237        let new_cursor = cursor_pos
238            .saturating_sub(removed_before(cursor_pos))
239            .min(new_text.len());
240
241        self.text = new_text;
242        self.selection = TextRange::cursor(new_cursor);
243        self.composition = self.composition.map(|comp| {
244            let comp_start = comp.min().saturating_sub(removed_before(comp.min()));
245            let comp_end = comp.max().saturating_sub(removed_before(comp.max()));
246            TextRange::new(comp_start, comp_end).coerce_in(self.text.len())
247        });
248        self.has_changes = true;
249    }
250
251    /// Clears all text.
252    pub fn clear(&mut self) {
253        self.text.clear();
254        self.selection = TextRange::zero();
255        self.composition = None;
256        self.has_changes = true;
257    }
258
259    /// Places the cursor at the end of the text.
260    pub fn place_cursor_at_end(&mut self) {
261        self.selection = TextRange::cursor(self.text.len());
262    }
263
264    /// Places the cursor at the start of the text.
265    pub fn place_cursor_at_start(&mut self) {
266        self.selection = TextRange::zero();
267    }
268
269    /// Places the cursor before the character at the given index.
270    pub fn place_cursor_before_char(&mut self, index: usize) {
271        let pos = index.min(self.text.len());
272        self.selection = TextRange::cursor(pos);
273    }
274
275    /// Selects all text.
276    pub fn select_all(&mut self) {
277        self.selection = TextRange::all(self.text.len());
278    }
279
280    /// Extends the selection one character to the left.
281    ///
282    /// `start` is the anchor and `end` is the moving cursor, which is what
283    /// makes this and [`Self::extend_selection_right`] inverses of each other:
284    /// shift-left followed by shift-right returns to where it began. Moving
285    /// whichever end happens to be smaller instead would leave the two growing
286    /// from opposite ends, and shift-right after shift-left would grow the
287    /// selection rather than shrink it.
288    ///
289    /// A reverse range (`start > end`) is how an anchor to the right of the
290    /// cursor is represented; see [`TextRange`].
291    pub fn extend_selection_left(&mut self) {
292        if self.selection.end > 0 {
293            let cursor = self.prev_char_boundary(self.selection.end);
294            self.selection = TextRange::new(self.selection.start, cursor);
295        }
296    }
297
298    /// Extends the selection one character to the right.
299    ///
300    /// The anchor stays where it is and the cursor moves; see
301    /// [`Self::extend_selection_left`].
302    pub fn extend_selection_right(&mut self) {
303        if self.selection.end < self.text.len() {
304            let cursor = self.next_char_boundary(self.selection.end);
305            self.selection = TextRange::new(self.selection.start, cursor);
306        }
307    }
308
309    /// Selects the given range.
310    pub fn select(&mut self, range: TextRange) {
311        self.selection = range.coerce_in(self.text.len());
312    }
313
314    /// Sets the composition (IME) range.
315    pub fn set_composition(&mut self, range: Option<TextRange>) {
316        self.composition = range.map(|r| r.coerce_in(self.text.len()));
317    }
318
319    fn prev_char_boundary(&self, from: usize) -> usize {
320        let mut pos = from.saturating_sub(1);
321        while pos > 0 && !self.text.is_char_boundary(pos) {
322            pos -= 1;
323        }
324        pos
325    }
326
327    fn next_char_boundary(&self, from: usize) -> usize {
328        let mut pos = from + 1;
329        while pos < self.text.len() && !self.text.is_char_boundary(pos) {
330            pos += 1;
331        }
332        pos.min(self.text.len())
333    }
334
335    fn clamp_prev_boundary(&self, from: usize) -> usize {
336        if self.text.is_char_boundary(from) {
337            from
338        } else {
339            self.prev_char_boundary(from)
340        }
341    }
342
343    fn clamp_next_boundary(&self, from: usize) -> usize {
344        if self.text.is_char_boundary(from) {
345            from
346        } else {
347            self.next_char_boundary(from)
348        }
349    }
350
351    /// Returns the selected text for copy operations.
352    /// Returns None if no selection.
353    pub fn copy_selection(&self) -> Option<String> {
354        if !self.has_selection() {
355            return None;
356        }
357
358        let sel_start = self.selection.min();
359        let sel_end = self.selection.max();
360        Some(self.text[sel_start..sel_end].to_string())
361    }
362
363    /// Cuts the selected text (returns it and deletes from buffer).
364    /// Returns the cut text, or None if no selection.
365    pub fn cut_selection(&mut self) -> Option<String> {
366        let copied = self.copy_selection();
367        if copied.is_some() {
368            self.delete(self.selection);
369            self.has_changes = true;
370        }
371        copied
372    }
373}
374
375impl Default for TextFieldBuffer {
376    fn default() -> Self {
377        Self::new("")
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    #[test]
386    fn new_buffer_has_cursor_at_end() {
387        let buffer = TextFieldBuffer::new("Hello");
388        assert_eq!(buffer.text(), "Hello");
389        assert_eq!(buffer.selection(), TextRange::cursor(5));
390    }
391
392    #[test]
393    fn insert_at_cursor() {
394        let mut buffer = TextFieldBuffer::new("Hello");
395        buffer.place_cursor_at_end();
396        buffer.insert(", World!");
397        assert_eq!(buffer.text(), "Hello, World!");
398        assert_eq!(buffer.selection(), TextRange::cursor(13));
399    }
400
401    #[test]
402    fn insert_in_middle() {
403        let mut buffer = TextFieldBuffer::new("Helo");
404        buffer.place_cursor_before_char(2);
405        buffer.insert("l");
406        assert_eq!(buffer.text(), "Hello");
407    }
408
409    #[test]
410    fn delete_selection() {
411        let mut buffer = TextFieldBuffer::new("Hello World");
412        buffer.select(TextRange::new(5, 11));
413        buffer.delete(buffer.selection());
414        assert_eq!(buffer.text(), "Hello");
415    }
416
417    #[test]
418    fn delete_before_cursor() {
419        let mut buffer = TextFieldBuffer::new("Hello");
420        buffer.place_cursor_at_end();
421        buffer.delete_before_cursor();
422        assert_eq!(buffer.text(), "Hell");
423    }
424
425    #[test]
426    fn select_all() {
427        let mut buffer = TextFieldBuffer::new("Hello");
428        buffer.select_all();
429        assert_eq!(buffer.selection(), TextRange::new(0, 5));
430    }
431
432    #[test]
433    fn replace_selection() {
434        let mut buffer = TextFieldBuffer::new("Hello World");
435        buffer.select(TextRange::new(6, 11));
436        buffer.insert("Rust");
437        assert_eq!(buffer.text(), "Hello Rust");
438    }
439
440    #[test]
441    fn clear_buffer() {
442        let mut buffer = TextFieldBuffer::new("Hello");
443        buffer.clear();
444        assert!(buffer.is_empty());
445        assert_eq!(buffer.selection(), TextRange::zero());
446    }
447
448    #[test]
449    fn unicode_handling() {
450        let mut buffer = TextFieldBuffer::new("Hello 🌍");
451        buffer.place_cursor_at_end();
452        buffer.delete_before_cursor();
453        assert_eq!(buffer.text(), "Hello ");
454    }
455
456    #[test]
457    fn delete_surrounding_collapsed_cursor() {
458        let mut buffer = TextFieldBuffer::new("abcdef");
459        buffer.place_cursor_before_char(3);
460        buffer.delete_surrounding(2, 2);
461        assert_eq!(buffer.text(), "af");
462        assert_eq!(buffer.selection(), TextRange::cursor(1));
463    }
464
465    #[test]
466    fn delete_surrounding_preserves_composition() {
467        let mut buffer = TextFieldBuffer::new("abcdef");
468        buffer.place_cursor_before_char(3);
469        buffer.set_composition(Some(TextRange::new(2, 4)));
470        buffer.delete_surrounding(3, 3);
471        assert_eq!(buffer.text(), "cd");
472        assert_eq!(buffer.selection(), TextRange::cursor(1));
473        assert_eq!(buffer.composition(), Some(TextRange::new(0, 2)));
474    }
475}