tlc: Sprint 2 B5 — bookmark line adjustment on edit

Bookmarks now track the correct line number across insert/delete
edits (MC parity). Previously a bookmark at line 5 would still
report line 5 even after lines 1-3 were deleted, pointing at
wrong content.

BookmarkSet (editor/bookmark.rs):
  New method adjust_lines(at_line: u32, delta: i32)
    - For each mark with line > at_line: shift by delta
    - For positive delta: mark.line += delta
    - For negative delta with mark.line in deletion range:
      clamp to at_line + 1 (first surviving line after range)
    - Column is preserved across all shifts
    - Zero delta is a no-op (no allocation)

Editor (editor/mod.rs):
  New private helper adjust_bookmarks_after_edit(edit_line, lines_before)
  Wraps insert_char, insert_str, delete_back, delete_forward:
    - Captures (line_before, lines_before) before buffer op
    - Calls buffer op
    - Computes delta = lines_after - lines_before
    - Calls bookmarks.adjust_lines(line_before, delta) if non-zero

Tests (6 new in editor::bookmark::tests):
  - adjust_lines_zero_delta_is_noop
  - adjust_lines_shifts_marks_after_insertion
  - adjust_lines_shifts_marks_after_deletion
  - adjust_lines_multi_line_insertion
  - adjust_lines_deletion_clamps_to_anchor
  - adjust_lines_does_not_touch_col

Total: 1259 passing (was 1253; +6 new).
This commit is contained in:
kellito
2026-07-05 17:05:28 +03:00
parent d4ddc4e2c7
commit 109bdc8dc3
2 changed files with 135 additions and 0 deletions
@@ -101,6 +101,41 @@ impl BookmarkSet {
pub fn is_empty(&self) -> bool {
self.marks.is_empty()
}
/// Shift bookmark lines in response to a buffer edit.
///
/// Called after a single edit operation that added or removed
/// lines from the buffer. `at_line` is the 0-based line index where
/// the edit happened; `delta` is the number of lines added (positive)
/// or removed (negative). Bookmarks on lines strictly after
/// `at_line` are shifted by `delta`. Bookmarks on earlier lines
/// are left alone. Bookmarks exactly at `at_line` are left alone
/// when `delta` is zero (default case); callers handling line-split
/// or line-merge edits should adjust those bookmarks separately
/// before calling this method.
pub fn adjust_lines(&mut self, at_line: u32, delta: i32) {
if delta == 0 {
return;
}
for mark in self.marks.values_mut() {
if mark.line > at_line {
if delta > 0 {
mark.line = mark.line.saturating_add(delta as u32);
} else {
// For negative delta, only shift if there's room.
let abs_delta = (-delta) as u32;
if mark.line >= at_line + 1 + abs_delta {
mark.line -= abs_delta;
} else {
// Bookmark would underflow past `at_line + 1`
// — clamp to `at_line` (it stays on the edit
// anchor line after the deletion).
mark.line = at_line + 1;
}
}
}
}
}
}
/// True if `c` is a valid bookmark name (lowercase letter or digit).
@@ -194,4 +229,80 @@ mod tests {
assert_eq!(bs.len(), 5);
assert_eq!(bs.get('a'), Some(Mark::new(50, 50)));
}
#[test]
fn adjust_lines_zero_delta_is_noop() {
let mut bs = BookmarkSet::new();
bs.set('a', Mark::new(5, 2)).unwrap();
bs.adjust_lines(2, 0);
assert_eq!(bs.get('a'), Some(Mark::new(5, 2)));
}
#[test]
fn adjust_lines_shifts_marks_after_insertion() {
let mut bs = BookmarkSet::new();
bs.set('a', Mark::new(2, 0)).unwrap();
bs.set('b', Mark::new(5, 3)).unwrap();
// Given an insertion at line 3 (delta=+1):
bs.adjust_lines(3, 1);
// Then mark at line 2 (before) is unchanged.
assert_eq!(bs.get('a'), Some(Mark::new(2, 0)));
// And mark at line 5 (after) shifts to line 6.
assert_eq!(bs.get('b'), Some(Mark::new(6, 3)));
}
#[test]
fn adjust_lines_shifts_marks_after_deletion() {
let mut bs = BookmarkSet::new();
bs.set('a', Mark::new(2, 0)).unwrap();
bs.set('b', Mark::new(5, 3)).unwrap();
// Given a deletion at line 3 (delta=-1):
bs.adjust_lines(3, -1);
// Then mark at line 2 (before) is unchanged.
assert_eq!(bs.get('a'), Some(Mark::new(2, 0)));
// And mark at line 5 (after) shifts to line 4.
assert_eq!(bs.get('b'), Some(Mark::new(4, 3)));
}
#[test]
fn adjust_lines_multi_line_insertion() {
let mut bs = BookmarkSet::new();
bs.set('a', Mark::new(0, 0)).unwrap();
bs.set('b', Mark::new(1, 5)).unwrap();
bs.set('c', Mark::new(3, 2)).unwrap();
// Given an insertion of 3 lines at line 2 (delta=+3):
bs.adjust_lines(2, 3);
// Then marks before line 2 are unchanged.
assert_eq!(bs.get('a'), Some(Mark::new(0, 0)));
assert_eq!(bs.get('b'), Some(Mark::new(1, 5)));
// And mark after line 2 shifts by 3.
assert_eq!(bs.get('c'), Some(Mark::new(6, 2)));
}
#[test]
fn adjust_lines_deletion_clamps_to_anchor() {
let mut bs = BookmarkSet::new();
bs.set('a', Mark::new(0, 0)).unwrap();
bs.set('b', Mark::new(2, 0)).unwrap();
bs.set('c', Mark::new(5, 0)).unwrap();
// Given a deletion of 3 lines starting at line 3 (delta=-3):
bs.adjust_lines(3, -3);
// Then mark at line 0 (well before) stays put.
assert_eq!(bs.get('a'), Some(Mark::new(0, 0)));
// And mark at line 2 (just before the deletion) stays put.
assert_eq!(bs.get('b'), Some(Mark::new(2, 0)));
// And mark at line 5 (in the deleted range) clamps to
// at_line + 1 (line 4) — the first surviving line after the
// deletion range.
assert_eq!(bs.get('c'), Some(Mark::new(4, 0)));
}
#[test]
fn adjust_lines_does_not_touch_col() {
let mut bs = BookmarkSet::new();
bs.set('a', Mark::new(10, 42)).unwrap();
bs.adjust_lines(5, 5);
// Column component of the mark is preserved across shifts.
assert_eq!(bs.get('a').unwrap().col, 42);
}
}
@@ -877,18 +877,36 @@ bracket_flash: None,
/// modified.
pub fn insert_char(&mut self, c: char) {
self.completer.cancel();
let line_before = self.buffer_line_of(self.buffer.cursor());
let lines_before = self.buffer.line_count();
self.buffer.insert_char(c);
self.adjust_bookmarks_after_edit(line_before, lines_before);
self.cursor.set_position(self.buffer.cursor(), &self.buffer);
self.modified = true;
}
/// Insert a string at the cursor.
pub fn insert_str(&mut self, s: &str) {
let line_before = self.buffer_line_of(self.buffer.cursor());
let lines_before = self.buffer.line_count();
self.buffer.insert_str(s);
self.adjust_bookmarks_after_edit(line_before, lines_before);
self.cursor.set_position(self.buffer.cursor(), &self.buffer);
self.modified = true;
}
/// Recompute bookmark line numbers after a buffer edit. Called by
/// every insert/delete entry point on `Editor`. `edit_line` is the
/// line where the edit happened; `lines_before` is the line count
/// before the edit.
fn adjust_bookmarks_after_edit(&mut self, edit_line: usize, lines_before: usize) {
let lines_after = self.buffer.line_count();
let delta = lines_after as i64 - lines_before as i64;
if delta != 0 {
self.bookmarks.adjust_lines(edit_line as u32, delta as i32);
}
}
/// Insert a Tab with indent-aware behavior (MC parity).
///
/// When the cursor is positioned at or before the first
@@ -931,14 +949,20 @@ bracket_flash: None,
/// Backspace at the cursor.
pub fn delete_back(&mut self) {
let line_before = self.buffer_line_of(self.buffer.cursor());
let lines_before = self.buffer.line_count();
self.buffer.delete_back();
self.adjust_bookmarks_after_edit(line_before, lines_before);
self.cursor.set_position(self.buffer.cursor(), &self.buffer);
self.modified = true;
}
/// Delete at the cursor.
pub fn delete_forward(&mut self) {
let line_before = self.buffer_line_of(self.buffer.cursor());
let lines_before = self.buffer.line_count();
self.buffer.delete_forward();
self.adjust_bookmarks_after_edit(line_before, lines_before);
self.cursor.set_position(self.buffer.cursor(), &self.buffer);
self.modified = true;
}