redox-core 0.5.3

Core text editing primitives for the redox-editor project
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
//! Editing operations for `TextBuffer`.
//!
//! This file is meant to be included as part of the `buffer::text_buffer` module
//! and adds editing-focused methods via an `impl TextBuffer` block.
//!
//! Design goals:
//! - keep public methods small and composable
//! - use char indices (ropey’s primary indexing model) internally
//! - return updated `Pos`/`Selection` to make call sites explicit
//! - support both single edits and batched edit application

use crate::buffer::{Edit, EditBatchSummary, Pos, Selection, TextBuffer};

impl TextBuffer {
    /// Insert `text` at the given logical position.
    ///
    /// Returns the new cursor position (at the end of inserted text).
    ///
    /// This is a primitive operation for higher-level editing commands.
    pub fn insert(&mut self, pos: Pos, text: &str) -> Pos {
        let at = self.pos_to_char(pos);
        self.rope.insert(at, text);

        let inserted_chars = text.chars().count();
        self.char_to_pos(at + inserted_chars)
    }

    /// Delete a range between two positions (order-independent).
    ///
    /// Returns the resulting cursor position (at the start of deletion).
    pub fn delete_range(&mut self, a: Pos, b: Pos) -> Pos {
        let start = self.pos_to_char(crate::buffer::util::min_pos(self, a, b));
        let end = self.pos_to_char(crate::buffer::util::max_pos(self, a, b));

        if start < end {
            self.rope.remove(start..end);
        }

        self.char_to_pos(start)
    }

    /// Delete the selection (if any). Returns `(new_cursor, did_delete)`.
    pub fn delete_selection(&mut self, sel: Selection) -> (Pos, bool) {
        if sel.is_empty() {
            return (self.clamp_pos(sel.cursor), false);
        }

        let (start, end) = sel.ordered();
        let new_cursor = self.delete_range(start, end);
        (new_cursor, true)
    }

    /// Backspace behavior:
    /// - if the selection is non-empty, delete it
    /// - otherwise delete the char before the cursor (if any)
    ///
    /// Returns an empty selection at the updated cursor.
    pub fn backspace(&mut self, sel: Selection) -> Selection {
        if !sel.is_empty() {
            let (cursor, _) = self.delete_selection(sel);
            return Selection::empty(cursor);
        }

        let cursor = self.clamp_pos(sel.cursor);
        let at = self.pos_to_char(cursor);
        if at == 0 {
            return Selection::empty(cursor);
        }

        let start = at - 1;
        self.rope.remove(start..at);
        let new_cursor = self.char_to_pos(start);
        Selection::empty(new_cursor)
    }

    /// Delete (forward) behavior:
    /// - if the selection is non-empty, delete it
    /// - otherwise delete the char at the cursor (if any)
    ///
    /// Returns an empty selection at the updated cursor.
    pub fn delete(&mut self, sel: Selection) -> Selection {
        if !sel.is_empty() {
            let (cursor, _) = self.delete_selection(sel);
            return Selection::empty(cursor);
        }

        let cursor = self.clamp_pos(sel.cursor);
        let at = self.pos_to_char(cursor);
        let maxc = self.len_chars();

        if at >= maxc {
            return Selection::empty(cursor);
        }

        self.rope.remove(at..at + 1);
        let new_cursor = self.char_to_pos(at);
        Selection::empty(new_cursor)
    }

    /// Insert a newline at the cursor (or replace the selection).
    ///
    /// Returns an empty selection at the updated cursor.
    pub fn insert_newline(&mut self, sel: Selection) -> Selection {
        if !sel.is_empty() {
            let (start, end) = sel.ordered();
            let cursor = self.delete_range(start, end);
            let new_cursor = self.insert(cursor, "\n");
            return Selection::empty(new_cursor);
        }

        let cursor = self.clamp_pos(sel.cursor);
        let new_cursor = self.insert(cursor, "\n");
        Selection::empty(new_cursor)
    }

    /// Replace the leading whitespace on `line`.
    ///
    /// Returns `(removed_chars, added_chars)` when the line changed.
    pub fn replace_line_indent(&mut self, line: usize, indent: &str) -> Option<(usize, usize)> {
        let line = self.clamp_line(line);
        let text = self.line_string(line);
        let existing_chars = text
            .chars()
            .take_while(|ch| *ch == ' ' || *ch == '\t')
            .count();
        let existing: String = text.chars().take(existing_chars).collect();
        if existing == indent {
            return None;
        }

        let _ = self.delete_range(Pos::new(line, 0), Pos::new(line, existing_chars));
        let _ = self.insert(Pos::new(line, 0), indent);
        Some((existing_chars, indent.chars().count()))
    }

    /// Remove trailing spaces and tabs from every line in the buffer.
    ///
    /// Returns `true` when at least one line was changed.
    pub fn trim_trailing_whitespace(&mut self) -> bool {
        let mut changed = false;

        for line in (0..self.len_lines()).rev() {
            let text = self.line_string(line);
            let line_len = text.chars().count();
            let trimmed_len = text.trim_end_matches([' ', '\t']).chars().count();
            if trimmed_len == line_len {
                continue;
            }

            let _ = self.delete_range(Pos::new(line, trimmed_len), Pos::new(line, line_len));
            changed = true;
        }

        changed
    }

    /// Apply an `Edit` expressed in char indices.
    ///
    /// Returns the resulting cursor position (end of inserted text, or start of deletion).
    pub fn apply_edit(&mut self, edit: Edit) -> Pos {
        let maxc = self.len_chars();
        let start = edit.range.start.min(maxc);
        let end = edit.range.end.min(maxc);
        let (start, end) = if start <= end {
            (start, end)
        } else {
            (end, start)
        };

        if start < end {
            self.rope.remove(start..end);
        }

        if !edit.insert.is_empty() {
            self.rope.insert(start, &edit.insert);
            let inserted_chars = edit.insert.chars().count();
            self.char_to_pos(start + inserted_chars)
        } else {
            self.char_to_pos(start)
        }
    }

    /// Apply multiple edits sequentially and return a transaction-style summary.
    ///
    /// Edits are applied in input order against the current buffer state.
    pub fn apply_edits(&mut self, edits: &[Edit]) -> EditBatchSummary {
        let mut changed_start = usize::MAX;
        let mut changed_end = 0usize;
        let mut cursor = self.char_to_pos(self.len_chars());

        for edit in edits {
            let maxc = self.len_chars();
            let start = edit.range.start.min(maxc);
            let end = edit.range.end.min(maxc);
            let (start, _) = if start <= end {
                (start, end)
            } else {
                (end, start)
            };

            cursor = self.apply_edit(edit.clone());
            let cursor_char = self.pos_to_char(cursor);

            changed_start = changed_start.min(start);
            changed_end = changed_end.max(cursor_char.max(start));
        }

        if edits.is_empty() {
            let cursor = self.char_to_pos(self.len_chars());
            let at = self.pos_to_char(cursor);
            return EditBatchSummary {
                changed_range: at..at,
                cursor,
                edits_applied: 0,
            };
        }

        EditBatchSummary {
            changed_range: changed_start..changed_end,
            cursor,
            edits_applied: edits.len(),
        }
    }

    /// Replace the current selection with `text` (if selection is empty, behaves like insert).
    /// This is a convenience method that a bunch of editor actions can use.
    ///
    /// Returns an empty selection at the updated cursor.
    pub fn replace_selection(&mut self, sel: Selection, text: &str) -> Selection {
        if !sel.is_empty() {
            let (start, end) = sel.ordered();
            let cursor = self.delete_range(start, end);
            let cursor = self.insert(cursor, text);
            Selection::empty(cursor)
        } else {
            let cursor = self.insert(sel.cursor, text);
            Selection::empty(cursor)
        }
    }

    /// Paste text before the given cursor.
    ///
    /// When `linewise` is true, insertion happens at the start of the current line
    /// and the returned cursor moves to the end of inserted text.
    /// When `linewise` is false, insertion happens at the cursor and the returned
    /// cursor is at the end of inserted text.
    pub fn paste_before(&mut self, cursor: Pos, text: &str, linewise: bool) -> Pos {
        let insert_pos = if linewise {
            let line = self.clamp_line(cursor.line);
            self.clamp_pos(Pos::new(line, 0))
        } else {
            self.clamp_pos(cursor)
        };

        let end_pos = self.insert(insert_pos, text);
        if linewise {
            linewise_paste_cursor(end_pos)
        } else {
            end_pos
        }
    }

    /// Paste text after the given cursor.
    ///
    /// When `linewise` is true, insertion happens at the beginning of the next
    /// logical line (clamped at the buffer boundary), and the returned cursor
    /// moves to the end of inserted text.
    /// When `linewise` is false, insertion happens after the cursor char on the
    /// current line (or at line end when already at EOL), and the returned cursor
    /// is at the end of inserted text.
    pub fn paste_after(&mut self, cursor: Pos, text: &str, linewise: bool) -> Pos {
        let insert_pos = if linewise {
            let line = self.clamp_line(cursor.line);
            let target_line = (line + 1).min(self.len_lines());
            self.clamp_pos(Pos::new(target_line, 0))
        } else {
            let line = self.clamp_line(cursor.line);
            let line_len = self.line_len_chars(line);
            let col = if cursor.col < line_len {
                cursor.col.saturating_add(1)
            } else {
                line_len
            };
            Pos::new(line, col)
        };

        let end_pos = self.insert(insert_pos, text);
        if linewise {
            linewise_paste_cursor(end_pos)
        } else {
            end_pos
        }
    }

    /// Move a contiguous line range up by one line.
    ///
    /// Returns the moved range after the operation, or `None` when movement is
    /// not possible (for example when the range already starts at line 0).
    pub fn move_line_range_up_once(
        &mut self,
        start_line: usize,
        end_line_inclusive: usize,
    ) -> Option<(usize, usize)> {
        let (start, end) = self.normalized_line_range(start_line, end_line_inclusive);
        if start == 0 {
            return None;
        }

        let first = start - 1;
        let last = end;
        let mut entries = self.collect_line_entries(first, last);
        entries.rotate_left(1);
        let mut replacement = entries.join("\n");
        if last + 1 < self.len_lines() {
            replacement.push('\n');
        }

        let replace_start = self.line_to_char(first);
        let replace_end = self.line_full_end_char(last);
        self.rope.remove(replace_start..replace_end);
        self.rope.insert(replace_start, &replacement);

        Some((start - 1, end - 1))
    }

    /// Move a contiguous line range up by up to `count` lines.
    ///
    /// Returns the moved range after all possible steps, or `None` when the range
    /// cannot be moved up at all.
    pub fn move_line_range_up(
        &mut self,
        start_line: usize,
        end_line_inclusive: usize,
        count: usize,
    ) -> Option<(usize, usize)> {
        if count == 0 {
            return None;
        }

        let mut current = self.normalized_line_range(start_line, end_line_inclusive);
        let mut moved = false;
        for _ in 0..count {
            let Some(next) = self.move_line_range_up_once(current.0, current.1) else {
                break;
            };
            current = next;
            moved = true;
        }

        if moved { Some(current) } else { None }
    }

    /// Move a contiguous line range down by one line.
    ///
    /// Returns the moved range after the operation, or `None` when movement is
    /// not possible (for example when the range already ends at the final line).
    pub fn move_line_range_down_once(
        &mut self,
        start_line: usize,
        end_line_inclusive: usize,
    ) -> Option<(usize, usize)> {
        let (start, end) = self.normalized_line_range(start_line, end_line_inclusive);
        if end + 1 >= self.len_lines() {
            return None;
        }

        let first = start;
        let last = end + 1;
        let mut entries = self.collect_line_entries(first, last);
        entries.rotate_right(1);
        let mut replacement = entries.join("\n");
        if last + 1 < self.len_lines() {
            replacement.push('\n');
        }

        let replace_start = self.line_to_char(first);
        let replace_end = self.line_full_end_char(last);
        self.rope.remove(replace_start..replace_end);
        self.rope.insert(replace_start, &replacement);

        Some((start + 1, end + 1))
    }

    /// Move a contiguous line range down by up to `count` lines.
    ///
    /// Returns the moved range after all possible steps, or `None` when the range
    /// cannot be moved down at all.
    pub fn move_line_range_down(
        &mut self,
        start_line: usize,
        end_line_inclusive: usize,
        count: usize,
    ) -> Option<(usize, usize)> {
        if count == 0 {
            return None;
        }

        let mut current = self.normalized_line_range(start_line, end_line_inclusive);
        let mut moved = false;
        for _ in 0..count {
            let Some(next) = self.move_line_range_down_once(current.0, current.1) else {
                break;
            };
            current = next;
            moved = true;
        }

        if moved { Some(current) } else { None }
    }

    /// Indent each line in a contiguous span by `count` tab characters.
    ///
    /// Returns `(line, chars_added)` for every touched line.
    pub fn indent_line_span(
        &mut self,
        start_line: usize,
        end_line_inclusive: usize,
        count: usize,
    ) -> Vec<(usize, usize)> {
        if count == 0 {
            return Vec::new();
        }

        let (start, end) = self.normalized_line_range(start_line, end_line_inclusive);
        let indent = "\t".repeat(count);
        let mut added_by_line = Vec::with_capacity(end.saturating_sub(start) + 1);
        for line in start..=end {
            let _ = self.insert(Pos::new(line, 0), &indent);
            added_by_line.push((line, count));
        }
        added_by_line
    }

    /// Outdent each line in a contiguous span by up to `count` levels.
    ///
    /// One outdent level removes either one leading tab or up to four leading spaces.
    /// Returns `(line, chars_removed)` for every touched line.
    pub fn outdent_line_span(
        &mut self,
        start_line: usize,
        end_line_inclusive: usize,
        count: usize,
    ) -> Vec<(usize, usize)> {
        const TAB_STOP: usize = 4;

        if count == 0 {
            return Vec::new();
        }

        let (start, end) = self.normalized_line_range(start_line, end_line_inclusive);
        let mut removed_by_line = Vec::with_capacity(end.saturating_sub(start) + 1);

        for line in start..=end {
            let text = self.line_string(line);
            let chars: Vec<char> = text.chars().collect();
            let mut idx = 0usize;
            let mut levels_left = count;
            while levels_left > 0 && idx < chars.len() {
                if chars[idx] == '\t' {
                    idx += 1;
                    levels_left -= 1;
                    continue;
                }

                let mut spaces = 0usize;
                while idx + spaces < chars.len() && chars[idx + spaces] == ' ' && spaces < TAB_STOP
                {
                    spaces += 1;
                }
                if spaces == 0 {
                    break;
                }

                idx += spaces;
                levels_left -= 1;
            }

            if idx > 0 {
                let _ = self.delete_range(Pos::new(line, 0), Pos::new(line, idx));
            }
            removed_by_line.push((line, idx));
        }

        removed_by_line
    }

    fn normalized_line_range(
        &self,
        start_line: usize,
        end_line_inclusive: usize,
    ) -> (usize, usize) {
        let (start, end) = if start_line <= end_line_inclusive {
            (start_line, end_line_inclusive)
        } else {
            (end_line_inclusive, start_line)
        };
        let start = self.clamp_line(start);
        let end = self.clamp_line(end.max(start));
        (start, end)
    }

    fn collect_line_entries(&self, start_line: usize, end_line_inclusive: usize) -> Vec<String> {
        let mut entries = Vec::with_capacity(end_line_inclusive.saturating_sub(start_line) + 1);
        for line in start_line..=end_line_inclusive {
            entries.push(self.line_string(line));
        }
        entries
    }
}

fn linewise_paste_cursor(end_pos: Pos) -> Pos {
    Pos::new(end_pos.line.saturating_sub(1), 0)
}