cranpose_foundation/text/
buffer.rs

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