From 109bdc8dc3de0a3c15e8f3eeaab68883c6adbcd5 Mon Sep 17 00:00:00 2001 From: kellito Date: Sun, 5 Jul 2026 17:05:28 +0300 Subject: [PATCH] =?UTF-8?q?tlc:=20Sprint=202=20B5=20=E2=80=94=20bookmark?= =?UTF-8?q?=20line=20adjustment=20on=20edit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../tui/tlc/source/src/editor/bookmark.rs | 111 ++++++++++++++++++ .../recipes/tui/tlc/source/src/editor/mod.rs | 24 ++++ 2 files changed, 135 insertions(+) diff --git a/local/recipes/tui/tlc/source/src/editor/bookmark.rs b/local/recipes/tui/tlc/source/src/editor/bookmark.rs index 903dfee4b7..b5b6dc3f4a 100644 --- a/local/recipes/tui/tlc/source/src/editor/bookmark.rs +++ b/local/recipes/tui/tlc/source/src/editor/bookmark.rs @@ -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); + } } diff --git a/local/recipes/tui/tlc/source/src/editor/mod.rs b/local/recipes/tui/tlc/source/src/editor/mod.rs index aaa2f8ae7b..3f43a2205b 100644 --- a/local/recipes/tui/tlc/source/src/editor/mod.rs +++ b/local/recipes/tui/tlc/source/src/editor/mod.rs @@ -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; }