Skip to main content

redox_core/buffer/text_buffer/
editing.rs

1//! Editing operations for `TextBuffer`.
2//!
3//! This file is meant to be included as part of the `buffer::text_buffer` module
4//! and adds editing-focused methods via an `impl TextBuffer` block.
5//!
6//! Design goals:
7//! - keep public methods small and composable
8//! - use char indices (ropey’s primary indexing model) internally
9//! - return updated `Pos`/`Selection` to make call sites explicit
10//! - support both single edits and batched edit application
11
12use crate::buffer::{Edit, EditBatchSummary, Pos, Selection, TextBuffer};
13use crate::{SOFT_TAB, SOFT_TAB_WIDTH};
14
15impl TextBuffer {
16    /// Insert `text` at the given logical position.
17    ///
18    /// Returns the new cursor position (at the end of inserted text).
19    ///
20    /// This is a primitive operation for higher-level editing commands.
21    pub fn insert(&mut self, pos: Pos, text: &str) -> Pos {
22        let at = self.pos_to_char(pos);
23        self.rope.insert(at, text);
24
25        let inserted_chars = text.chars().count();
26        self.char_to_pos(at + inserted_chars)
27    }
28
29    /// Delete a range between two positions (order-independent).
30    ///
31    /// Returns the resulting cursor position (at the start of deletion).
32    pub fn delete_range(&mut self, a: Pos, b: Pos) -> Pos {
33        let start = self.pos_to_char(crate::buffer::util::min_pos(self, a, b));
34        let end = self.pos_to_char(crate::buffer::util::max_pos(self, a, b));
35
36        if start < end {
37            self.rope.remove(start..end);
38        }
39
40        self.char_to_pos(start)
41    }
42
43    /// Delete the selection (if any). Returns `(new_cursor, did_delete)`.
44    pub fn delete_selection(&mut self, sel: Selection) -> (Pos, bool) {
45        if sel.is_empty() {
46            return (self.clamp_pos(sel.cursor), false);
47        }
48
49        let (start, end) = sel.ordered();
50        let new_cursor = self.delete_range(start, end);
51        (new_cursor, true)
52    }
53
54    /// Backspace behavior:
55    /// - if the selection is non-empty, delete it
56    /// - otherwise delete the char before the cursor (if any)
57    ///
58    /// Returns an empty selection at the updated cursor.
59    pub fn backspace(&mut self, sel: Selection) -> Selection {
60        if !sel.is_empty() {
61            let (cursor, _) = self.delete_selection(sel);
62            return Selection::empty(cursor);
63        }
64
65        let cursor = self.clamp_pos(sel.cursor);
66        let at = self.pos_to_char(cursor);
67        if at == 0 {
68            return Selection::empty(cursor);
69        }
70
71        let start = at - 1;
72        self.rope.remove(start..at);
73        let new_cursor = self.char_to_pos(start);
74        Selection::empty(new_cursor)
75    }
76
77    /// Delete (forward) behavior:
78    /// - if the selection is non-empty, delete it
79    /// - otherwise delete the char at the cursor (if any)
80    ///
81    /// Returns an empty selection at the updated cursor.
82    pub fn delete(&mut self, sel: Selection) -> Selection {
83        if !sel.is_empty() {
84            let (cursor, _) = self.delete_selection(sel);
85            return Selection::empty(cursor);
86        }
87
88        let cursor = self.clamp_pos(sel.cursor);
89        let at = self.pos_to_char(cursor);
90        let maxc = self.len_chars();
91
92        if at >= maxc {
93            return Selection::empty(cursor);
94        }
95
96        self.rope.remove(at..at + 1);
97        let new_cursor = self.char_to_pos(at);
98        Selection::empty(new_cursor)
99    }
100
101    /// Insert a newline at the cursor (or replace the selection).
102    ///
103    /// Returns an empty selection at the updated cursor.
104    pub fn insert_newline(&mut self, sel: Selection) -> Selection {
105        if !sel.is_empty() {
106            let (start, end) = sel.ordered();
107            let cursor = self.delete_range(start, end);
108            let new_cursor = self.insert(cursor, "\n");
109            return Selection::empty(new_cursor);
110        }
111
112        let cursor = self.clamp_pos(sel.cursor);
113        let new_cursor = self.insert(cursor, "\n");
114        Selection::empty(new_cursor)
115    }
116
117    /// Replace the leading whitespace on `line`.
118    ///
119    /// Returns `(removed_chars, added_chars)` when the line changed.
120    pub fn replace_line_indent(&mut self, line: usize, indent: &str) -> Option<(usize, usize)> {
121        let line = self.clamp_line(line);
122        let text = self.line_string(line);
123        let existing_chars = text
124            .chars()
125            .take_while(|ch| *ch == ' ' || *ch == '\t')
126            .count();
127        let existing: String = text.chars().take(existing_chars).collect();
128        if existing == indent {
129            return None;
130        }
131
132        let _ = self.delete_range(Pos::new(line, 0), Pos::new(line, existing_chars));
133        let _ = self.insert(Pos::new(line, 0), indent);
134        Some((existing_chars, indent.chars().count()))
135    }
136
137    /// Remove trailing spaces and tabs from every line in the buffer.
138    ///
139    /// Returns `true` when at least one line was changed.
140    pub fn trim_trailing_whitespace(&mut self) -> bool {
141        let mut changed = false;
142
143        for line in (0..self.len_lines()).rev() {
144            let text = self.line_string(line);
145            let line_len = text.chars().count();
146            let trimmed_len = text.trim_end_matches([' ', '\t']).chars().count();
147            if trimmed_len == line_len {
148                continue;
149            }
150
151            let _ = self.delete_range(Pos::new(line, trimmed_len), Pos::new(line, line_len));
152            changed = true;
153        }
154
155        changed
156    }
157
158    /// Apply an `Edit` expressed in char indices.
159    ///
160    /// Returns the resulting cursor position (end of inserted text, or start of deletion).
161    pub fn apply_edit(&mut self, edit: Edit) -> Pos {
162        let maxc = self.len_chars();
163        let start = edit.range.start.min(maxc);
164        let end = edit.range.end.min(maxc);
165        let (start, end) = if start <= end {
166            (start, end)
167        } else {
168            (end, start)
169        };
170
171        if start < end {
172            self.rope.remove(start..end);
173        }
174
175        if !edit.insert.is_empty() {
176            self.rope.insert(start, &edit.insert);
177            let inserted_chars = edit.insert.chars().count();
178            self.char_to_pos(start + inserted_chars)
179        } else {
180            self.char_to_pos(start)
181        }
182    }
183
184    /// Apply multiple edits sequentially and return a transaction-style summary.
185    ///
186    /// Edits are applied in input order against the current buffer state.
187    pub fn apply_edits(&mut self, edits: &[Edit]) -> EditBatchSummary {
188        let mut changed_start = usize::MAX;
189        let mut changed_end = 0usize;
190        let mut cursor = self.char_to_pos(self.len_chars());
191
192        for edit in edits {
193            let maxc = self.len_chars();
194            let start = edit.range.start.min(maxc);
195            let end = edit.range.end.min(maxc);
196            let (start, _) = if start <= end {
197                (start, end)
198            } else {
199                (end, start)
200            };
201
202            cursor = self.apply_edit(edit.clone());
203            let cursor_char = self.pos_to_char(cursor);
204
205            changed_start = changed_start.min(start);
206            changed_end = changed_end.max(cursor_char.max(start));
207        }
208
209        if edits.is_empty() {
210            let cursor = self.char_to_pos(self.len_chars());
211            let at = self.pos_to_char(cursor);
212            return EditBatchSummary {
213                changed_range: at..at,
214                cursor,
215                edits_applied: 0,
216            };
217        }
218
219        EditBatchSummary {
220            changed_range: changed_start..changed_end,
221            cursor,
222            edits_applied: edits.len(),
223        }
224    }
225
226    /// Replace the current selection with `text` (if selection is empty, behaves like insert).
227    /// This is a convenience method that a bunch of editor actions can use.
228    ///
229    /// Returns an empty selection at the updated cursor.
230    pub fn replace_selection(&mut self, sel: Selection, text: &str) -> Selection {
231        if !sel.is_empty() {
232            let (start, end) = sel.ordered();
233            let cursor = self.delete_range(start, end);
234            let cursor = self.insert(cursor, text);
235            Selection::empty(cursor)
236        } else {
237            let cursor = self.insert(sel.cursor, text);
238            Selection::empty(cursor)
239        }
240    }
241
242    /// Paste text before the given cursor.
243    ///
244    /// When `linewise` is true, insertion happens at the start of the current line
245    /// and the returned cursor moves to the end of inserted text.
246    /// When `linewise` is false, insertion happens at the cursor and the returned
247    /// cursor is at the end of inserted text.
248    pub fn paste_before(&mut self, cursor: Pos, text: &str, linewise: bool) -> Pos {
249        let insert_pos = if linewise {
250            let line = self.clamp_line(cursor.line);
251            self.clamp_pos(Pos::new(line, 0))
252        } else {
253            self.clamp_pos(cursor)
254        };
255
256        let end_pos = self.insert(insert_pos, text);
257        if linewise {
258            linewise_paste_cursor(end_pos)
259        } else {
260            end_pos
261        }
262    }
263
264    /// Paste text after the given cursor.
265    ///
266    /// When `linewise` is true, insertion happens at the beginning of the next
267    /// logical line (clamped at the buffer boundary), and the returned cursor
268    /// moves to the end of inserted text.
269    /// When `linewise` is false, insertion happens after the cursor char on the
270    /// current line (or at line end when already at EOL), and the returned cursor
271    /// is at the end of inserted text.
272    pub fn paste_after(&mut self, cursor: Pos, text: &str, linewise: bool) -> Pos {
273        let insert_pos = if linewise {
274            let line = self.clamp_line(cursor.line);
275            let target_line = (line + 1).min(self.len_lines());
276            self.clamp_pos(Pos::new(target_line, 0))
277        } else {
278            let line = self.clamp_line(cursor.line);
279            let line_len = self.line_len_chars(line);
280            let col = if cursor.col < line_len {
281                cursor.col.saturating_add(1)
282            } else {
283                line_len
284            };
285            Pos::new(line, col)
286        };
287
288        let end_pos = self.insert(insert_pos, text);
289        if linewise {
290            linewise_paste_cursor(end_pos)
291        } else {
292            end_pos
293        }
294    }
295
296    /// Move a contiguous line range up by one line.
297    ///
298    /// Returns the moved range after the operation, or `None` when movement is
299    /// not possible (for example when the range already starts at line 0).
300    pub fn move_line_range_up_once(
301        &mut self,
302        start_line: usize,
303        end_line_inclusive: usize,
304    ) -> Option<(usize, usize)> {
305        let (start, end) = self.normalized_line_range(start_line, end_line_inclusive);
306        if start == 0 {
307            return None;
308        }
309
310        let first = start - 1;
311        let last = end;
312        let mut entries = self.collect_line_entries(first, last);
313        entries.rotate_left(1);
314        let mut replacement = entries.join("\n");
315        if last + 1 < self.len_lines() {
316            replacement.push('\n');
317        }
318
319        let replace_start = self.line_to_char(first);
320        let replace_end = self.line_full_end_char(last);
321        self.rope.remove(replace_start..replace_end);
322        self.rope.insert(replace_start, &replacement);
323
324        Some((start - 1, end - 1))
325    }
326
327    /// Move a contiguous line range up by up to `count` lines.
328    ///
329    /// Returns the moved range after all possible steps, or `None` when the range
330    /// cannot be moved up at all.
331    pub fn move_line_range_up(
332        &mut self,
333        start_line: usize,
334        end_line_inclusive: usize,
335        count: usize,
336    ) -> Option<(usize, usize)> {
337        if count == 0 {
338            return None;
339        }
340
341        let mut current = self.normalized_line_range(start_line, end_line_inclusive);
342        let mut moved = false;
343        for _ in 0..count {
344            let Some(next) = self.move_line_range_up_once(current.0, current.1) else {
345                break;
346            };
347            current = next;
348            moved = true;
349        }
350
351        if moved { Some(current) } else { None }
352    }
353
354    /// Move a contiguous line range down by one line.
355    ///
356    /// Returns the moved range after the operation, or `None` when movement is
357    /// not possible (for example when the range already ends at the final line).
358    pub fn move_line_range_down_once(
359        &mut self,
360        start_line: usize,
361        end_line_inclusive: usize,
362    ) -> Option<(usize, usize)> {
363        let (start, end) = self.normalized_line_range(start_line, end_line_inclusive);
364        if end + 1 >= self.len_lines() {
365            return None;
366        }
367
368        let first = start;
369        let last = end + 1;
370        let mut entries = self.collect_line_entries(first, last);
371        entries.rotate_right(1);
372        let mut replacement = entries.join("\n");
373        if last + 1 < self.len_lines() {
374            replacement.push('\n');
375        }
376
377        let replace_start = self.line_to_char(first);
378        let replace_end = self.line_full_end_char(last);
379        self.rope.remove(replace_start..replace_end);
380        self.rope.insert(replace_start, &replacement);
381
382        Some((start + 1, end + 1))
383    }
384
385    /// Move a contiguous line range down by up to `count` lines.
386    ///
387    /// Returns the moved range after all possible steps, or `None` when the range
388    /// cannot be moved down at all.
389    pub fn move_line_range_down(
390        &mut self,
391        start_line: usize,
392        end_line_inclusive: usize,
393        count: usize,
394    ) -> Option<(usize, usize)> {
395        if count == 0 {
396            return None;
397        }
398
399        let mut current = self.normalized_line_range(start_line, end_line_inclusive);
400        let mut moved = false;
401        for _ in 0..count {
402            let Some(next) = self.move_line_range_down_once(current.0, current.1) else {
403                break;
404            };
405            current = next;
406            moved = true;
407        }
408
409        if moved { Some(current) } else { None }
410    }
411
412    /// Indent each line in a contiguous span by `count` tab characters.
413    ///
414    /// Returns `(line, chars_added)` for every touched line.
415    pub fn indent_line_span(
416        &mut self,
417        start_line: usize,
418        end_line_inclusive: usize,
419        count: usize,
420    ) -> Vec<(usize, usize)> {
421        if count == 0 {
422            return Vec::new();
423        }
424
425        let (start, end) = self.normalized_line_range(start_line, end_line_inclusive);
426        let indent = SOFT_TAB.repeat(count);
427        let mut added_by_line = Vec::with_capacity(end.saturating_sub(start) + 1);
428        for line in start..=end {
429            let _ = self.insert(Pos::new(line, 0), &indent);
430            added_by_line.push((line, indent.chars().count()));
431        }
432        added_by_line
433    }
434
435    /// Outdent each line in a contiguous span by up to `count` levels.
436    ///
437    /// One outdent level removes either one leading tab or up to four leading spaces.
438    /// Returns `(line, chars_removed)` for every touched line.
439    pub fn outdent_line_span(
440        &mut self,
441        start_line: usize,
442        end_line_inclusive: usize,
443        count: usize,
444    ) -> Vec<(usize, usize)> {
445        if count == 0 {
446            return Vec::new();
447        }
448
449        let (start, end) = self.normalized_line_range(start_line, end_line_inclusive);
450        let mut removed_by_line = Vec::with_capacity(end.saturating_sub(start) + 1);
451
452        for line in start..=end {
453            let text = self.line_string(line);
454            let chars: Vec<char> = text.chars().collect();
455            let mut idx = 0usize;
456            let mut levels_left = count;
457            while levels_left > 0 && idx < chars.len() {
458                if chars[idx] == '\t' {
459                    idx += 1;
460                    levels_left -= 1;
461                    continue;
462                }
463
464                let mut spaces = 0usize;
465                while idx + spaces < chars.len()
466                    && chars[idx + spaces] == ' '
467                    && spaces < SOFT_TAB_WIDTH
468                {
469                    spaces += 1;
470                }
471                if spaces == 0 {
472                    break;
473                }
474
475                idx += spaces;
476                levels_left -= 1;
477            }
478
479            if idx > 0 {
480                let _ = self.delete_range(Pos::new(line, 0), Pos::new(line, idx));
481            }
482            removed_by_line.push((line, idx));
483        }
484
485        removed_by_line
486    }
487
488    fn normalized_line_range(
489        &self,
490        start_line: usize,
491        end_line_inclusive: usize,
492    ) -> (usize, usize) {
493        let (start, end) = if start_line <= end_line_inclusive {
494            (start_line, end_line_inclusive)
495        } else {
496            (end_line_inclusive, start_line)
497        };
498        let start = self.clamp_line(start);
499        let end = self.clamp_line(end.max(start));
500        (start, end)
501    }
502
503    fn collect_line_entries(&self, start_line: usize, end_line_inclusive: usize) -> Vec<String> {
504        let mut entries = Vec::with_capacity(end_line_inclusive.saturating_sub(start_line) + 1);
505        for line in start_line..=end_line_inclusive {
506            entries.push(self.line_string(line));
507        }
508        entries
509    }
510}
511
512fn linewise_paste_cursor(end_pos: Pos) -> Pos {
513    Pos::new(end_pos.line.saturating_sub(1), 0)
514}