use crate::buffer::{pos_to_char_idx, rope_line_char_count};
use crate::{Buffer, Position};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MotionKind {
Char,
Line,
Block,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Edit {
InsertChar { at: Position, ch: char },
InsertStr { at: Position, text: String },
DeleteRange {
start: Position,
end: Position,
kind: MotionKind,
},
JoinLines {
row: usize,
count: usize,
with_space: bool,
},
SplitLines {
row: usize,
cols: Vec<usize>,
inserted_space: bool,
},
Replace {
start: Position,
end: Position,
with: String,
},
InsertBlock { at: Position, chunks: Vec<String> },
DeleteBlockChunks { at: Position, widths: Vec<usize> },
}
impl Buffer {
pub fn apply_edit(&mut self, edit: Edit) -> Edit {
match edit {
Edit::InsertChar { at, ch } => self.do_insert_str(at, ch.to_string()),
Edit::InsertStr { at, text } => self.do_insert_str(at, text),
Edit::DeleteRange { start, end, kind } => self.do_delete_range(start, end, kind),
Edit::JoinLines {
row,
count,
with_space,
} => self.do_join_lines(row, count, with_space),
Edit::SplitLines {
row,
cols,
inserted_space,
} => self.do_split_lines(row, cols, inserted_space),
Edit::Replace { start, end, with } => self.do_replace(start, end, with),
Edit::InsertBlock { at, chunks } => self.do_insert_block(at, chunks),
Edit::DeleteBlockChunks { at, widths } => self.do_delete_block_chunks(at, widths),
}
}
fn do_insert_block(&mut self, at: Position, chunks: Vec<String>) -> Edit {
let mut widths: Vec<usize> = Vec::with_capacity(chunks.len());
for (i, chunk) in chunks.into_iter().enumerate() {
let row = at.row + i;
{
let mut c = self.content.lock().unwrap();
let n = c.text.len_lines();
if row < n {
let lc = rope_line_char_count(&c.text, row);
if lc < at.col {
let pad = at.col - lc;
let insert_char_idx = pos_to_char_idx(&c.text, row, lc);
c.text.insert(insert_char_idx, &" ".repeat(pad));
}
}
}
widths.push(chunk.chars().count());
{
let mut c = self.content.lock().unwrap();
let n = c.text.len_lines();
if row < n {
let char_idx = pos_to_char_idx(&c.text, row, at.col);
c.text.insert(char_idx, &chunk);
}
}
}
self.dirty_gen_bump();
self.set_cursor(at);
Edit::DeleteBlockChunks { at, widths }
}
fn do_delete_block_chunks(&mut self, at: Position, widths: Vec<usize>) -> Edit {
let mut chunks: Vec<String> = Vec::with_capacity(widths.len());
for (i, w) in widths.into_iter().enumerate() {
let row = at.row + i;
let removed = {
let mut c = self.content.lock().unwrap();
let n = c.text.len_lines();
if row >= n {
String::new()
} else {
let lc = rope_line_char_count(&c.text, row);
let col_start = at.col.min(lc);
let col_end = (at.col + w).min(lc);
if col_start >= col_end {
String::new()
} else {
let char_start = pos_to_char_idx(&c.text, row, col_start);
let char_end = pos_to_char_idx(&c.text, row, col_end);
let removed: String = c.text.slice(char_start..char_end).to_string();
c.text.remove(char_start..char_end);
removed
}
}
};
chunks.push(removed);
}
self.dirty_gen_bump();
self.set_cursor(at);
Edit::InsertBlock { at, chunks }
}
fn do_insert_str(&mut self, at: Position, text: String) -> Edit {
let normalised = self.clamp_position(at);
let inserted_chars = text.chars().count();
let inserted_lines = text.split('\n').count();
let end = if inserted_lines > 1 {
let last_chars = text.rsplit('\n').next().unwrap_or("").chars().count();
Position::new(normalised.row + inserted_lines - 1, last_chars)
} else {
Position::new(normalised.row, normalised.col + inserted_chars)
};
{
let mut c = self.content.lock().unwrap();
let char_idx = pos_to_char_idx(&c.text, normalised.row, normalised.col);
c.text.insert(char_idx, &text);
}
self.dirty_gen_bump();
self.set_cursor(end);
Edit::DeleteRange {
start: normalised,
end,
kind: MotionKind::Char,
}
}
fn do_delete_range(&mut self, start: Position, end: Position, kind: MotionKind) -> Edit {
let (start, end) = order(start, end);
match kind {
MotionKind::Char => {
let removed = {
let mut c = self.content.lock().unwrap();
rope_cut_chars(&mut c.text, start, end)
};
self.dirty_gen_bump();
self.set_cursor(start);
Edit::InsertStr {
at: start,
text: removed,
}
}
MotionKind::Line => {
let lo = start.row;
let (removed_text, new_cursor) = {
let mut c = self.content.lock().unwrap();
let n = c.text.len_lines();
let hi = end.row.min(n.saturating_sub(1));
let mut removed_lines: Vec<String> = Vec::with_capacity(hi - lo + 1);
for r in lo..=hi {
removed_lines.push(rope_line_str_locked(&c.text, r));
}
let (remove_start, remove_end) = if hi + 1 < n {
(c.text.line_to_char(lo), c.text.line_to_char(hi + 1))
} else if lo > 0 {
(c.text.line_to_char(lo) - 1, c.text.len_chars())
} else {
(0, c.text.len_chars())
};
c.text.remove(remove_start..remove_end);
let n2 = c.text.len_lines();
let target_row = lo.min(n2.saturating_sub(1));
let removed_joined = {
let mut s = removed_lines.join("\n");
s.push('\n');
s
};
(removed_joined, Position::new(target_row, 0))
};
self.dirty_gen_bump();
self.set_cursor(new_cursor);
Edit::InsertStr {
at: Position::new(lo, 0),
text: removed_text,
}
}
MotionKind::Block => {
let (left, right) = (start.col.min(end.col), start.col.max(end.col));
let mut chunks: Vec<String> = Vec::with_capacity(end.row - start.row + 1);
for row in start.row..=end.row {
let removed = {
let mut c = self.content.lock().unwrap();
let n = c.text.len_lines();
if row >= n {
String::new()
} else {
let row_start_pos = Position::new(row, left);
let row_end_pos = Position::new(row, right + 1);
rope_cut_chars(&mut c.text, row_start_pos, row_end_pos)
}
};
chunks.push(removed);
}
self.dirty_gen_bump();
self.set_cursor(Position::new(start.row, left));
Edit::InsertBlock {
at: Position::new(start.row, left),
chunks,
}
}
}
}
fn do_join_lines(&mut self, row: usize, count: usize, with_space: bool) -> Edit {
let count = count.max(1);
let (actual_row, split_cols) = {
let mut c = self.content.lock().unwrap();
let n = c.text.len_lines();
let row = row.min(n.saturating_sub(1));
let mut split_cols: Vec<usize> = Vec::with_capacity(count);
for _ in 0..count {
let n2 = c.text.len_lines();
if row + 1 >= n2 {
break;
}
let join_col = rope_line_char_count(&c.text, row);
split_cols.push(join_col);
let newline_char = c.text.line_to_char(row) + join_col;
c.text.remove(newline_char..newline_char + 1);
if with_space {
let n3 = c.text.len_lines();
let merged_len = rope_line_char_count(&c.text, row);
let prefix_empty = join_col == 0;
let suffix_empty = join_col >= merged_len;
if !prefix_empty && !suffix_empty {
c.text.insert_char(newline_char, ' ');
}
let _ = n3;
}
}
(row, split_cols)
};
self.dirty_gen_bump();
self.set_cursor(Position::new(actual_row, 0));
Edit::SplitLines {
row: actual_row,
cols: split_cols,
inserted_space: with_space,
}
}
fn do_split_lines(&mut self, row: usize, cols: Vec<usize>, inserted_space: bool) -> Edit {
let actual_row = {
let mut c = self.content.lock().unwrap();
let n = c.text.len_lines();
let row = row.min(n.saturating_sub(1));
for &col in cols.iter().rev() {
let mut split_col = col;
if inserted_space {
let lc = rope_line_char_count(&c.text, row);
if split_col < lc {
let space_char_idx = c.text.line_to_char(row) + split_col;
let ch = c.text.char(space_char_idx);
if ch == ' ' {
c.text.remove(space_char_idx..space_char_idx + 1);
}
}
} else {
let lc = rope_line_char_count(&c.text, row);
split_col = split_col.min(lc);
}
let char_idx = c.text.line_to_char(row) + split_col;
c.text.insert_char(char_idx, '\n');
}
row
};
self.dirty_gen_bump();
self.set_cursor(Position::new(actual_row, 0));
Edit::JoinLines {
row: actual_row,
count: cols.len(),
with_space: inserted_space,
}
}
fn do_replace(&mut self, start: Position, end: Position, with: String) -> Edit {
let (start, end) = order(start, end);
let removed = {
let mut c = self.content.lock().unwrap();
rope_cut_chars(&mut c.text, start, end)
};
let normalised = self.clamp_position(start);
let inserted_chars = with.chars().count();
let inserted_lines = with.split('\n').count();
let new_end = if inserted_lines > 1 {
let last_chars = with.rsplit('\n').next().unwrap_or("").chars().count();
Position::new(normalised.row + inserted_lines - 1, last_chars)
} else {
Position::new(normalised.row, normalised.col + inserted_chars)
};
{
let mut c = self.content.lock().unwrap();
let char_idx = pos_to_char_idx(&c.text, normalised.row, normalised.col);
c.text.insert(char_idx, &with);
}
self.dirty_gen_bump();
self.set_cursor(new_end);
Edit::Replace {
start: normalised,
end: new_end,
with: removed,
}
}
}
fn rope_line_str_locked(rope: &ropey::Rope, row: usize) -> String {
let slice = rope.line(row);
let s = slice.to_string();
if s.ends_with('\n') {
s[..s.len() - 1].to_string()
} else {
s
}
}
fn rope_cut_chars(rope: &mut ropey::Rope, start: Position, end: Position) -> String {
let (start, end) = order(start, end);
let n = rope.len_lines();
let start_row = start.row.min(n.saturating_sub(1));
let start_col = {
let lc = crate::buffer::rope_line_char_count(rope, start_row);
start.col.min(lc)
};
let end_row = end.row.min(n.saturating_sub(1));
let end_col = {
let lc = crate::buffer::rope_line_char_count(rope, end_row);
end.col.min(lc)
};
let char_start = rope.line_to_char(start_row) + start_col;
let char_end = rope.line_to_char(end_row) + end_col;
if char_start >= char_end {
return String::new();
}
let removed: String = rope.slice(char_start..char_end).to_string();
rope.remove(char_start..char_end);
removed
}
fn order(a: Position, b: Position) -> (Position, Position) {
if a <= b { (a, b) } else { (b, a) }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::buffer::rope_line_str;
fn round_trip_check(initial: &str, edit: Edit) {
let mut b = Buffer::from_str(initial);
let snapshot_before = b.as_string();
let inverse = b.apply_edit(edit);
b.apply_edit(inverse);
assert_eq!(b.as_string(), snapshot_before);
}
#[test]
fn insert_char_round_trip() {
round_trip_check(
"abc",
Edit::InsertChar {
at: Position::new(0, 1),
ch: 'X',
},
);
}
#[test]
fn insert_str_multiline_round_trip() {
round_trip_check(
"abc\ndef",
Edit::InsertStr {
at: Position::new(0, 2),
text: "X\nY\nZ".into(),
},
);
}
#[test]
fn delete_charwise_single_row_round_trip() {
round_trip_check(
"alpha bravo charlie",
Edit::DeleteRange {
start: Position::new(0, 6),
end: Position::new(0, 11),
kind: MotionKind::Char,
},
);
}
#[test]
fn delete_charwise_multi_row_round_trip() {
round_trip_check(
"row0\nrow1\nrow2",
Edit::DeleteRange {
start: Position::new(0, 2),
end: Position::new(2, 2),
kind: MotionKind::Char,
},
);
}
#[test]
fn delete_linewise_round_trip() {
round_trip_check(
"a\nb\nc\nd",
Edit::DeleteRange {
start: Position::new(1, 0),
end: Position::new(2, 0),
kind: MotionKind::Line,
},
);
}
#[test]
fn delete_blockwise_round_trip() {
round_trip_check(
"abcdef\nghijkl\nmnopqr",
Edit::DeleteRange {
start: Position::new(0, 1),
end: Position::new(2, 3),
kind: MotionKind::Block,
},
);
}
#[test]
fn join_lines_with_space_round_trip() {
round_trip_check(
"first\nsecond\nthird",
Edit::JoinLines {
row: 0,
count: 2,
with_space: true,
},
);
}
#[test]
fn join_lines_no_space_round_trip() {
round_trip_check(
"first\nsecond",
Edit::JoinLines {
row: 0,
count: 1,
with_space: false,
},
);
}
#[test]
fn replace_round_trip() {
round_trip_check(
"foo bar baz",
Edit::Replace {
start: Position::new(0, 4),
end: Position::new(0, 7),
with: "QUUX".into(),
},
);
}
#[test]
fn delete_clearing_buffer_keeps_one_empty_row() {
let mut b = Buffer::from_str("only");
b.apply_edit(Edit::DeleteRange {
start: Position::new(0, 0),
end: Position::new(0, 0),
kind: MotionKind::Line,
});
assert_eq!(b.row_count(), 1);
assert_eq!(rope_line_str(&b.rope(), 0), "");
}
#[test]
fn insert_char_lands_cursor_after() {
let mut b = Buffer::from_str("abc");
b.set_cursor(Position::new(0, 1));
b.apply_edit(Edit::InsertChar {
at: Position::new(0, 1),
ch: 'X',
});
assert_eq!(b.cursor(), Position::new(0, 2));
assert_eq!(rope_line_str(&b.rope(), 0), "aXbc");
}
#[test]
fn block_delete_on_ragged_rows_handles_short_lines() {
let mut b = Buffer::from_str("longline\nhi\nthird row");
let inv = b.apply_edit(Edit::DeleteRange {
start: Position::new(0, 2),
end: Position::new(2, 5),
kind: MotionKind::Block,
});
b.apply_edit(inv);
assert_eq!(b.as_string(), "longline\nhi\nthird row");
}
#[test]
fn dirty_gen_bumps_per_edit() {
let mut b = Buffer::from_str("abc");
let g0 = b.dirty_gen();
b.apply_edit(Edit::InsertChar {
at: Position::new(0, 0),
ch: 'X',
});
assert_eq!(b.dirty_gen(), g0 + 1);
b.apply_edit(Edit::DeleteRange {
start: Position::new(0, 0),
end: Position::new(0, 1),
kind: MotionKind::Char,
});
assert_eq!(b.dirty_gen(), g0 + 2);
}
#[test]
fn splice_at_60k_paste_at_row_zero_is_under_200ms() {
let initial = "\n".repeat(60_000);
let mut b = Buffer::from_str(&initial);
let payload = vec!["x"; 60_000].join("\n");
let t = std::time::Instant::now();
b.apply_edit(Edit::InsertStr {
at: Position::new(0, 0),
text: payload,
});
let elapsed = t.elapsed();
assert!(
elapsed.as_millis() < 200,
"60k-row InsertStr took {elapsed:?}; budget 200 ms"
);
}
}