1use crate::buffer::{pos_to_char_idx, rope_line_char_count};
10use crate::{Position, View};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum MotionKind {
16 Char,
18 Line,
21 Block,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum Edit {
40 InsertChar { at: Position, ch: char },
45 InsertStr { at: Position, text: String },
52 DeleteRange {
63 start: Position,
64 end: Position,
65 kind: MotionKind,
66 },
67 JoinLines {
72 row: usize,
73 count: usize,
74 with_space: bool,
75 },
76 SplitLines {
80 row: usize,
81 cols: Vec<usize>,
82 inserted_space: bool,
83 },
84 Replace {
90 start: Position,
91 end: Position,
92 with: String,
93 },
94 InsertBlock { at: Position, chunks: Vec<String> },
98 DeleteBlockChunks { at: Position, widths: Vec<usize> },
103}
104
105impl View {
106 pub fn apply_edit(&mut self, edit: Edit) -> Edit {
124 match edit {
125 Edit::InsertChar { at, ch } => self.do_insert_str(at, ch.to_string()),
126 Edit::InsertStr { at, text } => self.do_insert_str(at, text),
127 Edit::DeleteRange { start, end, kind } => self.do_delete_range(start, end, kind),
128 Edit::JoinLines {
129 row,
130 count,
131 with_space,
132 } => self.do_join_lines(row, count, with_space),
133 Edit::SplitLines {
134 row,
135 cols,
136 inserted_space,
137 } => self.do_split_lines(row, cols, inserted_space),
138 Edit::Replace { start, end, with } => self.do_replace(start, end, with),
139 Edit::InsertBlock { at, chunks } => self.do_insert_block(at, chunks),
140 Edit::DeleteBlockChunks { at, widths } => self.do_delete_block_chunks(at, widths),
141 }
142 }
143
144 fn do_insert_block(&mut self, at: Position, chunks: Vec<String>) -> Edit {
145 let mut widths: Vec<usize> = Vec::with_capacity(chunks.len());
146 for (i, chunk) in chunks.into_iter().enumerate() {
147 let row = at.row + i;
148 {
151 let mut c = self.content.lock().unwrap();
152 let n = c.text.len_lines();
153 if row < n {
154 let lc = rope_line_char_count(&c.text, row);
155 if lc < at.col {
156 let pad = at.col - lc;
157 let insert_char_idx = pos_to_char_idx(&c.text, row, lc);
158 c.text.insert(insert_char_idx, &" ".repeat(pad));
159 }
160 }
161 }
162 widths.push(chunk.chars().count());
163 {
165 let mut c = self.content.lock().unwrap();
166 let n = c.text.len_lines();
167 if row < n {
168 let char_idx = pos_to_char_idx(&c.text, row, at.col);
169 c.text.insert(char_idx, &chunk);
170 }
171 }
172 }
173 self.dirty_gen_bump();
174 self.set_cursor(at);
175 Edit::DeleteBlockChunks { at, widths }
176 }
177
178 fn do_delete_block_chunks(&mut self, at: Position, widths: Vec<usize>) -> Edit {
179 let mut chunks: Vec<String> = Vec::with_capacity(widths.len());
180 for (i, w) in widths.into_iter().enumerate() {
181 let row = at.row + i;
182 let removed = {
183 let mut c = self.content.lock().unwrap();
184 let n = c.text.len_lines();
185 if row >= n {
186 String::new()
187 } else {
188 let lc = rope_line_char_count(&c.text, row);
189 let col_start = at.col.min(lc);
190 let col_end = (at.col + w).min(lc);
191 if col_start >= col_end {
192 String::new()
193 } else {
194 let char_start = pos_to_char_idx(&c.text, row, col_start);
195 let char_end = pos_to_char_idx(&c.text, row, col_end);
196 let removed: String = c.text.slice(char_start..char_end).to_string();
197 c.text.remove(char_start..char_end);
198 removed
199 }
200 }
201 };
202 chunks.push(removed);
203 }
204 self.dirty_gen_bump();
205 self.set_cursor(at);
206 Edit::InsertBlock { at, chunks }
207 }
208
209 fn do_insert_str(&mut self, at: Position, text: String) -> Edit {
210 let normalised = self.clamp_position(at);
211 let inserted_chars = text.chars().count();
212 let inserted_lines = text.split('\n').count();
213 let end = if inserted_lines > 1 {
214 let last_chars = text.rsplit('\n').next().unwrap_or("").chars().count();
215 Position::new(normalised.row + inserted_lines - 1, last_chars)
216 } else {
217 Position::new(normalised.row, normalised.col + inserted_chars)
218 };
219 {
220 let mut c = self.content.lock().unwrap();
221 let char_idx = pos_to_char_idx(&c.text, normalised.row, normalised.col);
222 c.text.insert(char_idx, &text);
223 }
224 self.dirty_gen_bump();
225 self.set_cursor(end);
226 Edit::DeleteRange {
227 start: normalised,
228 end,
229 kind: MotionKind::Char,
230 }
231 }
232
233 fn do_delete_range(&mut self, start: Position, end: Position, kind: MotionKind) -> Edit {
234 let (start, end) = order(start, end);
235 match kind {
236 MotionKind::Char => {
237 let removed = {
238 let mut c = self.content.lock().unwrap();
239 rope_cut_chars(&mut c.text, start, end)
240 };
241 self.dirty_gen_bump();
242 self.set_cursor(start);
243 Edit::InsertStr {
244 at: start,
245 text: removed,
246 }
247 }
248 MotionKind::Line => {
249 let (removed_text, new_cursor, lo) = {
250 let mut c = self.content.lock().unwrap();
251 let n = c.text.len_lines();
252 let lo = start.row.min(n.saturating_sub(1));
256 let hi = end.row.min(n.saturating_sub(1));
257
258 let mut removed_lines: Vec<String> = Vec::with_capacity(hi - lo + 1);
260 for r in lo..=hi {
261 removed_lines.push(rope_line_str_locked(&c.text, r));
262 }
263
264 let (remove_start, remove_end) = if hi + 1 < n {
270 (c.text.line_to_char(lo), c.text.line_to_char(hi + 1))
273 } else if lo > 0 {
274 (c.text.line_to_char(lo) - 1, c.text.len_chars())
277 } else {
278 (0, c.text.len_chars())
280 };
281
282 c.text.remove(remove_start..remove_end);
283 let n2 = c.text.len_lines();
286 let target_row = lo.min(n2.saturating_sub(1));
287 let removed_joined = {
288 let mut s = removed_lines.join("\n");
289 s.push('\n');
292 s
293 };
294 (removed_joined, Position::new(target_row, 0), lo)
295 };
296 self.dirty_gen_bump();
297 self.set_cursor(new_cursor);
298 Edit::InsertStr {
299 at: Position::new(lo, 0),
300 text: removed_text,
301 }
302 }
303 MotionKind::Block => {
304 let (left, right) = (start.col.min(end.col), start.col.max(end.col));
305 let mut chunks: Vec<String> = Vec::with_capacity(end.row - start.row + 1);
306 for row in start.row..=end.row {
307 let removed = {
308 let mut c = self.content.lock().unwrap();
309 let n = c.text.len_lines();
310 if row >= n {
311 String::new()
312 } else {
313 let row_start_pos = Position::new(row, left);
314 let row_end_pos = Position::new(row, right + 1);
315 rope_cut_chars(&mut c.text, row_start_pos, row_end_pos)
316 }
317 };
318 chunks.push(removed);
319 }
320 self.dirty_gen_bump();
321 self.set_cursor(Position::new(start.row, left));
322 Edit::InsertBlock {
323 at: Position::new(start.row, left),
324 chunks,
325 }
326 }
327 }
328 }
329
330 fn do_join_lines(&mut self, row: usize, count: usize, with_space: bool) -> Edit {
331 let count = count.max(1);
332 let (actual_row, split_cols) = {
333 let mut c = self.content.lock().unwrap();
334 let n = c.text.len_lines();
335 let row = row.min(n.saturating_sub(1));
336 let mut split_cols: Vec<usize> = Vec::with_capacity(count);
337
338 for _ in 0..count {
339 let n2 = c.text.len_lines();
340 if row + 1 >= n2 {
341 break;
342 }
343 let join_col = rope_line_char_count(&c.text, row);
345 split_cols.push(join_col);
346
347 let newline_char = c.text.line_to_char(row) + join_col;
349 c.text.remove(newline_char..newline_char + 1);
351
352 if with_space {
354 let n3 = c.text.len_lines();
358 let merged_len = rope_line_char_count(&c.text, row);
359 let prefix_empty = join_col == 0;
360 let suffix_empty = join_col >= merged_len;
361 if !prefix_empty && !suffix_empty {
362 c.text.insert_char(newline_char, ' ');
364 }
370 let _ = n3;
371 }
372 }
373 (row, split_cols)
374 };
375 self.dirty_gen_bump();
376 self.set_cursor(Position::new(actual_row, 0));
377 Edit::SplitLines {
378 row: actual_row,
379 cols: split_cols,
380 inserted_space: with_space,
381 }
382 }
383
384 fn do_split_lines(&mut self, row: usize, cols: Vec<usize>, inserted_space: bool) -> Edit {
385 let actual_row = {
386 let mut c = self.content.lock().unwrap();
387 let n = c.text.len_lines();
388 let row = row.min(n.saturating_sub(1));
389
390 for &col in cols.iter().rev() {
393 let mut split_col = col;
394 if inserted_space {
395 let lc = rope_line_char_count(&c.text, row);
399 if split_col < lc {
400 let space_char_idx = c.text.line_to_char(row) + split_col;
401 let ch = c.text.char(space_char_idx);
403 if ch == ' ' {
404 c.text.remove(space_char_idx..space_char_idx + 1);
405 }
406 }
407 } else {
410 let lc = rope_line_char_count(&c.text, row);
411 split_col = split_col.min(lc);
412 }
413
414 let char_idx = c.text.line_to_char(row) + split_col;
416 c.text.insert_char(char_idx, '\n');
417 }
418
419 row
420 };
421 self.dirty_gen_bump();
422 self.set_cursor(Position::new(actual_row, 0));
423 Edit::JoinLines {
424 row: actual_row,
425 count: cols.len(),
426 with_space: inserted_space,
427 }
428 }
429
430 fn do_replace(&mut self, start: Position, end: Position, with: String) -> Edit {
431 let (start, end) = order(start, end);
432 let removed = {
433 let mut c = self.content.lock().unwrap();
434 rope_cut_chars(&mut c.text, start, end)
435 };
436 let normalised = self.clamp_position(start);
437 let inserted_chars = with.chars().count();
438 let inserted_lines = with.split('\n').count();
439 let new_end = if inserted_lines > 1 {
440 let last_chars = with.rsplit('\n').next().unwrap_or("").chars().count();
441 Position::new(normalised.row + inserted_lines - 1, last_chars)
442 } else {
443 Position::new(normalised.row, normalised.col + inserted_chars)
444 };
445 {
446 let mut c = self.content.lock().unwrap();
447 let char_idx = pos_to_char_idx(&c.text, normalised.row, normalised.col);
448 c.text.insert(char_idx, &with);
449 }
450 self.dirty_gen_bump();
451 self.set_cursor(new_end);
452 Edit::Replace {
453 start: normalised,
454 end: new_end,
455 with: removed,
456 }
457 }
458}
459
460fn rope_line_str_locked(rope: &ropey::Rope, row: usize) -> String {
466 let slice = rope.line(row);
467 let s = slice.to_string();
468 if s.ends_with('\n') {
469 s[..s.len() - 1].to_string()
470 } else {
471 s
472 }
473}
474
475fn rope_cut_chars(rope: &mut ropey::Rope, start: Position, end: Position) -> String {
482 let (start, end) = order(start, end);
483 let n = rope.len_lines();
484
485 let start_row = start.row.min(n.saturating_sub(1));
487 let start_col = {
488 let lc = crate::buffer::rope_line_char_count(rope, start_row);
489 start.col.min(lc)
490 };
491 let end_row = end.row.min(n.saturating_sub(1));
492 let end_col = {
493 let lc = crate::buffer::rope_line_char_count(rope, end_row);
494 end.col.min(lc)
495 };
496
497 let char_start = rope.line_to_char(start_row) + start_col;
498 let char_end = rope.line_to_char(end_row) + end_col;
499
500 if char_start >= char_end {
501 return String::new();
502 }
503
504 let removed: String = rope.slice(char_start..char_end).to_string();
505 rope.remove(char_start..char_end);
506 removed
507}
508
509fn order(a: Position, b: Position) -> (Position, Position) {
510 if a <= b { (a, b) } else { (b, a) }
511}
512
513#[cfg(test)]
514mod tests {
515 use super::*;
516 use crate::buffer::rope_line_str;
517
518 fn round_trip_check(initial: &str, edit: Edit) {
519 let mut b = View::from_str(initial);
520 let snapshot_before = b.as_string();
521 let inverse = b.apply_edit(edit);
522 b.apply_edit(inverse);
523 assert_eq!(b.as_string(), snapshot_before);
524 }
525
526 #[test]
527 fn insert_char_round_trip() {
528 round_trip_check(
529 "abc",
530 Edit::InsertChar {
531 at: Position::new(0, 1),
532 ch: 'X',
533 },
534 );
535 }
536
537 #[test]
538 fn insert_str_multiline_round_trip() {
539 round_trip_check(
540 "abc\ndef",
541 Edit::InsertStr {
542 at: Position::new(0, 2),
543 text: "X\nY\nZ".into(),
544 },
545 );
546 }
547
548 #[test]
549 fn delete_charwise_single_row_round_trip() {
550 round_trip_check(
551 "alpha bravo charlie",
552 Edit::DeleteRange {
553 start: Position::new(0, 6),
554 end: Position::new(0, 11),
555 kind: MotionKind::Char,
556 },
557 );
558 }
559
560 #[test]
561 fn delete_charwise_multi_row_round_trip() {
562 round_trip_check(
563 "row0\nrow1\nrow2",
564 Edit::DeleteRange {
565 start: Position::new(0, 2),
566 end: Position::new(2, 2),
567 kind: MotionKind::Char,
568 },
569 );
570 }
571
572 #[test]
573 fn delete_linewise_round_trip() {
574 round_trip_check(
575 "a\nb\nc\nd",
576 Edit::DeleteRange {
577 start: Position::new(1, 0),
578 end: Position::new(2, 0),
579 kind: MotionKind::Line,
580 },
581 );
582 }
583
584 #[test]
585 fn delete_blockwise_round_trip() {
586 round_trip_check(
587 "abcdef\nghijkl\nmnopqr",
588 Edit::DeleteRange {
589 start: Position::new(0, 1),
590 end: Position::new(2, 3),
591 kind: MotionKind::Block,
592 },
593 );
594 }
595
596 #[test]
597 fn join_lines_with_space_round_trip() {
598 round_trip_check(
599 "first\nsecond\nthird",
600 Edit::JoinLines {
601 row: 0,
602 count: 2,
603 with_space: true,
604 },
605 );
606 }
607
608 #[test]
609 fn join_lines_no_space_round_trip() {
610 round_trip_check(
611 "first\nsecond",
612 Edit::JoinLines {
613 row: 0,
614 count: 1,
615 with_space: false,
616 },
617 );
618 }
619
620 #[test]
621 fn replace_round_trip() {
622 round_trip_check(
623 "foo bar baz",
624 Edit::Replace {
625 start: Position::new(0, 4),
626 end: Position::new(0, 7),
627 with: "QUUX".into(),
628 },
629 );
630 }
631
632 #[test]
636 fn delete_linewise_start_past_end_is_clamped() {
637 let mut b = View::from_str("a\nb\nc");
638 b.apply_edit(Edit::DeleteRange {
639 start: Position::new(10, 0),
640 end: Position::new(20, 0),
641 kind: MotionKind::Line,
642 });
643 assert_eq!(b.as_string(), "a\nb");
645 }
646
647 #[test]
648 fn delete_clearing_buffer_keeps_one_empty_row() {
649 let mut b = View::from_str("only");
650 b.apply_edit(Edit::DeleteRange {
651 start: Position::new(0, 0),
652 end: Position::new(0, 0),
653 kind: MotionKind::Line,
654 });
655 assert_eq!(b.row_count(), 1);
656 assert_eq!(rope_line_str(&b.rope(), 0), "");
657 }
658
659 #[test]
660 fn insert_char_lands_cursor_after() {
661 let mut b = View::from_str("abc");
662 b.set_cursor(Position::new(0, 1));
663 b.apply_edit(Edit::InsertChar {
664 at: Position::new(0, 1),
665 ch: 'X',
666 });
667 assert_eq!(b.cursor(), Position::new(0, 2));
668 assert_eq!(rope_line_str(&b.rope(), 0), "aXbc");
669 }
670
671 #[test]
672 fn block_delete_on_ragged_rows_handles_short_lines() {
673 let mut b = View::from_str("longline\nhi\nthird row");
676 let inv = b.apply_edit(Edit::DeleteRange {
677 start: Position::new(0, 2),
678 end: Position::new(2, 5),
679 kind: MotionKind::Block,
680 });
681 b.apply_edit(inv);
682 assert_eq!(b.as_string(), "longline\nhi\nthird row");
683 }
684
685 #[test]
686 fn dirty_gen_bumps_per_edit() {
687 let mut b = View::from_str("abc");
688 let g0 = b.dirty_gen();
689 b.apply_edit(Edit::InsertChar {
690 at: Position::new(0, 0),
691 ch: 'X',
692 });
693 assert_eq!(b.dirty_gen(), g0 + 1);
694 b.apply_edit(Edit::DeleteRange {
695 start: Position::new(0, 0),
696 end: Position::new(0, 1),
697 kind: MotionKind::Char,
698 });
699 assert_eq!(b.dirty_gen(), g0 + 2);
700 }
701
702 #[test]
707 fn splice_at_60k_paste_at_row_zero_is_under_200ms() {
708 let initial = "\n".repeat(60_000);
710 let mut b = View::from_str(&initial);
711 let payload = vec!["x"; 60_000].join("\n");
713 let t = std::time::Instant::now();
714 b.apply_edit(Edit::InsertStr {
715 at: Position::new(0, 0),
716 text: payload,
717 });
718 let elapsed = t.elapsed();
719 assert!(
720 elapsed.as_millis() < 200,
721 "60k-row InsertStr took {elapsed:?}; budget 200 ms"
722 );
723 }
724}