Skip to main content

cranpose_foundation/text/
buffer.rs

1use smallvec::SmallVec;
2use unicode_segmentation::GraphemeCursor;
3
4use super::{TextFieldValue, TextRange};
5
6/// A mutable text buffer that can be edited.
7///
8/// This provides methods for changing text content:
9/// - [`replace`](Self::replace) - Replace a range with new text
10/// - [`append`](Self::append) - Add text at the end
11/// - [`insert`](Self::insert) - Insert text at cursor position
12/// - [`delete`](Self::delete) - Delete a range of text
13///
14/// And for manipulating cursor/selection:
15/// - [`place_cursor_at_end`](Self::place_cursor_at_end)
16/// - [`place_cursor_before_char`](Self::place_cursor_before_char)
17/// - [`move_cursor_left`](Self::move_cursor_left)
18/// - [`select_all`](Self::select_all)
19///
20/// Keyboard editing steps over whole user-perceived characters, Unicode's
21/// extended grapheme clusters: a flag, an emoji joined with zero-width
22/// joiners, a letter with its combining marks. Offsets are UTF-8 bytes.
23///
24/// # Example
25///
26/// ```
27/// use cranpose_foundation::text::{TextFieldBuffer, TextRange};
28///
29/// let mut buffer = TextFieldBuffer::new("Hello");
30/// buffer.place_cursor_at_end();
31/// buffer.insert(", World!");
32/// assert_eq!(buffer.text(), "Hello, World!");
33/// ```
34#[derive(Debug, Clone)]
35pub struct TextFieldBuffer {
36    text: String,
37    selection: TextRange,
38    composition: Option<TextRange>,
39    has_changes: bool,
40}
41
42impl TextFieldBuffer {
43    /// Creates a new buffer with the given initial text.
44    /// Cursor is placed at the end of the text.
45    pub fn new(initial_text: impl Into<String>) -> Self {
46        let text: String = initial_text.into();
47        let len = text.len();
48        Self {
49            text,
50            selection: TextRange::cursor(len),
51            composition: None,
52            has_changes: false,
53        }
54    }
55
56    /// Creates a buffer with text and specified selection.
57    pub fn with_selection(text: impl Into<String>, selection: TextRange) -> Self {
58        let text: String = text.into();
59        let selection = selection.coerce_in(text.len());
60        Self {
61            text,
62            selection,
63            composition: None,
64            has_changes: false,
65        }
66    }
67
68    /// Returns the current text content.
69    pub fn text(&self) -> &str {
70        &self.text
71    }
72
73    /// Returns the length of the text in bytes.
74    pub fn len(&self) -> usize {
75        self.text.len()
76    }
77
78    /// Returns true if the buffer is empty.
79    pub fn is_empty(&self) -> bool {
80        self.text.is_empty()
81    }
82
83    /// Returns the current selection range.
84    pub fn selection(&self) -> TextRange {
85        self.selection
86    }
87
88    /// Returns the current composition (IME) range, if any.
89    pub fn composition(&self) -> Option<TextRange> {
90        self.composition
91    }
92
93    /// Returns true if there's a non-collapsed selection.
94    pub fn has_selection(&self) -> bool {
95        !self.selection.collapsed()
96    }
97
98    /// Returns true if any changes have been made.
99    pub fn has_changes(&self) -> bool {
100        self.has_changes
101    }
102
103    /// Replaces text in the given range with new text.
104    ///
105    /// The selection is adjusted based on the replacement:
106    /// - If replacing before selection, selection shifts
107    /// - If replacing within selection, cursor moves to end of replacement
108    pub fn replace(&mut self, range: TextRange, replacement: &str) {
109        let min = range.min().min(self.text.len());
110        let max = range.max().min(self.text.len());
111
112        self.text.replace_range(min..max, replacement);
113
114        let new_end = min + replacement.len();
115        self.selection = TextRange::cursor(new_end);
116
117        self.composition = None;
118        self.has_changes = true;
119    }
120
121    /// Inserts text at the current cursor position (or replaces selection).
122    pub fn insert(&mut self, text: &str) {
123        if self.has_selection() {
124            self.replace(self.selection, text);
125        } else {
126            let pos = self.selection.start.min(self.text.len());
127            self.text.insert_str(pos, text);
128            self.selection = TextRange::cursor(pos + text.len());
129            self.composition = None;
130            self.has_changes = true;
131        }
132    }
133
134    /// Appends text at the end of the buffer.
135    pub fn append(&mut self, text: &str) {
136        self.text.push_str(text);
137        self.has_changes = true;
138    }
139
140    /// Deletes text in the given range.
141    pub fn delete(&mut self, range: TextRange) {
142        self.replace(range, "");
143    }
144
145    /// Deletes the selection, or the character before the cursor (backspace).
146    pub fn delete_before_cursor(&mut self) {
147        if self.has_selection() {
148            self.delete(self.selection);
149        } else if self.selection.start > 0 {
150            let pos = self.selection.start;
151            let prev_pos = self.prev_grapheme_boundary(pos);
152            self.delete(TextRange::new(prev_pos, pos));
153        }
154    }
155
156    /// Deletes the selection, or the character after the cursor (delete key).
157    pub fn delete_after_cursor(&mut self) {
158        if self.has_selection() {
159            self.delete(self.selection);
160        } else if self.selection.start < self.text.len() {
161            let pos = self.selection.start;
162            let next_pos = self.next_grapheme_boundary(pos);
163            self.delete(TextRange::new(pos, next_pos));
164        }
165    }
166
167    /// Deletes text surrounding the cursor or selection.
168    ///
169    /// `before_bytes` and `after_bytes` are byte counts in UTF-8.
170    /// The deletion respects character boundaries and preserves any IME composition range.
171    pub fn delete_surrounding(&mut self, before_bytes: usize, after_bytes: usize) {
172        if self.text.is_empty() || (before_bytes == 0 && after_bytes == 0) {
173            return;
174        }
175
176        let selection = self.selection;
177        let mut start = selection.min().saturating_sub(before_bytes);
178        let mut end = selection
179            .max()
180            .saturating_add(after_bytes)
181            .min(self.text.len());
182
183        start = self.text.floor_char_boundary(start);
184        end = self.text.ceil_char_boundary(end);
185
186        if start >= end {
187            return;
188        }
189
190        // At most two ranges, either side of a composition, in text order.
191        let mut ranges: SmallVec<[(usize, usize); 2]> = SmallVec::new();
192        if let Some(comp) = self.composition {
193            let comp_start = comp.min();
194            let comp_end = comp.max();
195
196            if end <= comp_start || start >= comp_end {
197                ranges.push((start, end));
198            } else {
199                if start < comp_start {
200                    ranges.push((start, comp_start));
201                }
202                if end > comp_end {
203                    ranges.push((comp_end, end));
204                }
205            }
206        } else {
207            ranges.push((start, end));
208        }
209
210        if ranges.is_empty() {
211            return;
212        }
213
214        for &(range_start, range_end) in ranges.iter().rev() {
215            self.text.replace_range(range_start..range_end, "");
216        }
217
218        let removed_before = |pos: usize| -> usize {
219            let mut removed = 0usize;
220            for (range_start, range_end) in &ranges {
221                if pos <= *range_start {
222                    break;
223                }
224                let clamped_end = pos.min(*range_end);
225                if clamped_end > *range_start {
226                    removed += clamped_end - *range_start;
227                }
228            }
229            removed
230        };
231
232        let cursor_pos = selection.min();
233        let new_cursor = cursor_pos
234            .saturating_sub(removed_before(cursor_pos))
235            .min(self.text.len());
236
237        self.selection = TextRange::cursor(new_cursor);
238        self.composition = self.composition.map(|comp| {
239            let comp_start = comp.min().saturating_sub(removed_before(comp.min()));
240            let comp_end = comp.max().saturating_sub(removed_before(comp.max()));
241            TextRange::new(comp_start, comp_end).coerce_in(self.text.len())
242        });
243        self.has_changes = true;
244    }
245
246    /// Clears all text.
247    pub fn clear(&mut self) {
248        self.text.clear();
249        self.selection = TextRange::zero();
250        self.composition = None;
251        self.has_changes = true;
252    }
253
254    /// Places the cursor at the end of the text.
255    pub fn place_cursor_at_end(&mut self) {
256        self.selection = TextRange::cursor(self.text.len());
257    }
258
259    /// Places the cursor at the start of the text.
260    pub fn place_cursor_at_start(&mut self) {
261        self.selection = TextRange::zero();
262    }
263
264    /// Places the cursor before the character at the given index.
265    pub fn place_cursor_before_char(&mut self, index: usize) {
266        let pos = index.min(self.text.len());
267        self.selection = TextRange::cursor(pos);
268    }
269
270    /// Collapses a selection to its start, or moves the cursor one character
271    /// to the left (the left arrow key).
272    pub fn move_cursor_left(&mut self) {
273        let target = if self.has_selection() {
274            self.selection.min()
275        } else {
276            self.prev_grapheme_boundary(self.selection.start)
277        };
278        self.selection = TextRange::cursor(target);
279    }
280
281    /// Collapses a selection to its end, or moves the cursor one character to
282    /// the right (the right arrow key).
283    pub fn move_cursor_right(&mut self) {
284        let target = if self.has_selection() {
285            self.selection.max()
286        } else {
287            self.next_grapheme_boundary(self.selection.end)
288        };
289        self.selection = TextRange::cursor(target);
290    }
291
292    /// Selects all text.
293    pub fn select_all(&mut self) {
294        self.selection = TextRange::all(self.text.len());
295    }
296
297    /// Extends the selection one character to the left.
298    ///
299    /// `start` is the anchor and `end` is the moving cursor, which is what
300    /// makes this and [`Self::extend_selection_right`] inverses of each other:
301    /// shift-left followed by shift-right returns to where it began. Moving
302    /// whichever end happens to be smaller instead would leave the two growing
303    /// from opposite ends, and shift-right after shift-left would grow the
304    /// selection rather than shrink it.
305    ///
306    /// A reverse range (`start > end`) is how an anchor to the right of the
307    /// cursor is represented; see [`TextRange`].
308    pub fn extend_selection_left(&mut self) {
309        if self.selection.end > 0 {
310            let cursor = self.prev_grapheme_boundary(self.selection.end);
311            self.selection = TextRange::new(self.selection.start, cursor);
312        }
313    }
314
315    /// Extends the selection one character to the right.
316    ///
317    /// The anchor stays where it is and the cursor moves; see
318    /// [`Self::extend_selection_left`].
319    pub fn extend_selection_right(&mut self) {
320        if self.selection.end < self.text.len() {
321            let cursor = self.next_grapheme_boundary(self.selection.end);
322            self.selection = TextRange::new(self.selection.start, cursor);
323        }
324    }
325
326    /// Selects the given range.
327    pub fn select(&mut self, range: TextRange) {
328        self.selection = range.coerce_in(self.text.len());
329    }
330
331    /// Sets the composition (IME) range.
332    pub fn set_composition(&mut self, range: Option<TextRange>) {
333        self.composition = range.map(|r| r.coerce_in(self.text.len()));
334    }
335
336    /// The cursor holds the whole text as its one chunk, so it never asks
337    /// for more context and a step always succeeds.
338    fn grapheme_cursor(&self, from: usize) -> GraphemeCursor {
339        let from = self.text.floor_char_boundary(from);
340        GraphemeCursor::new(from, self.text.len(), true)
341    }
342
343    fn prev_grapheme_boundary(&self, from: usize) -> usize {
344        self.grapheme_cursor(from)
345            .prev_boundary(&self.text, 0)
346            .ok()
347            .flatten()
348            .unwrap_or(0)
349    }
350
351    fn next_grapheme_boundary(&self, from: usize) -> usize {
352        self.grapheme_cursor(from)
353            .next_boundary(&self.text, 0)
354            .ok()
355            .flatten()
356            .unwrap_or(self.text.len())
357    }
358
359    pub(crate) fn into_value(self) -> TextFieldValue {
360        TextFieldValue {
361            text: self.text,
362            selection: self.selection,
363            composition: self.composition,
364        }
365    }
366
367    /// Returns the selected text for copy operations.
368    /// Returns None if no selection.
369    pub fn copy_selection(&self) -> Option<String> {
370        if !self.has_selection() {
371            return None;
372        }
373
374        let sel_start = self.selection.min();
375        let sel_end = self.selection.max();
376        Some(self.text[sel_start..sel_end].to_string())
377    }
378
379    /// Cuts the selected text (returns it and deletes from buffer).
380    /// Returns the cut text, or None if no selection.
381    pub fn cut_selection(&mut self) -> Option<String> {
382        let copied = self.copy_selection();
383        if copied.is_some() {
384            self.delete(self.selection);
385            self.has_changes = true;
386        }
387        copied
388    }
389}
390
391impl Default for TextFieldBuffer {
392    fn default() -> Self {
393        Self::new("")
394    }
395}
396
397#[cfg(test)]
398#[path = "tests/buffer_tests.rs"]
399mod tests;